@use "/shell" as *; @use "quark:list" as list; #release-notice { /* opens once: sheet re-runs restart a delay, so gate it on the fact it writes */ &:not([data-did-open]) { @delay 7000 { is-open: ""; data-did-open: ""; } } /* prevent clicks from bubbling to the sheet */ @on mouseup, click (stop-propagation); } provider-fetch[api-url*="package-metas/index.json"][is-success] { $package-indices: prop("provision").body; /* `excom.navGroup` moves a package out of its type's list, into that group */ $ungrouped: list.reject($package-indices.packages, "navGroup"); .package-links { [bind-elements] ul:not(ul ul) { content: iterate(getPackagesByType($ungrouped, "kit-element")); } [bind-element-bases] ul:not(ul ul) { content: iterate(getPackagesByType($ungrouped, "element-base")); } [bind-tools] ul:not(ul ul) { content: iterate(getPackagesByType($ungrouped, "tool")); } /* the standalone libraries: same rows, rendered flat (shell.css) */ [bind-libraries] ul:not(ul ul) { content: iterate(getPackagesByType($ungrouped, "library")); } [bind-library-group] ul:not(ul ul) { content: iterate(list.filter($package-indices.packages, "navGroup", "libraries")); } ul:not(ul ul) > li { $pkg: item.shortName; > spa-a { route-href: "/packages/#{item.shortName}"; } } /* one `content` rule per link: a second one would rewrite the first's text on every pass */ ul:not(ul ul, [bind-libraries] ul) > li > spa-a { content: item.shortName; } [bind-libraries] ul:not(ul ul) > li > spa-a { content: displayName(item.shortName); } /* packages whose docs span several pages: one collapsible group per section */ [bind-sections] { content: iterate(item.docSections); summary { content: item.title; } details > ul { content: iterate(item.docs); spa-a { route-href: "/packages/#{$pkg}/#{item.name}"; content: item.title; } } details:not(:has(spa-a[is-active])) { open: none; } } } /* page titles: the page, then the site. The docs home keeps the title in the document head; the other routes carry theirs in markup */ spa-route[is-active] { /* a site guide */ &[route-href$=":name"] { $title-guide: list.find($package-indices.docs, "name", $route.params.name); document-title: "#{$title-guide.title or $route.params.name} · Nucleus · docs"; } /* a package README */ &[route-href$=":packageName"] { document-title: "#{displayName($route.params.packageName)} · Nucleus · docs"; } /* one page of a package's docs */ &[route-href$=":docName"] { $title-package: list.find($package-indices.packages, "shortName", $route.params.packageName); document-title: "#{docTitle($title-package, $route.params.docName)} · #{displayName($route.params.packageName)} · Nucleus · docs"; } } /* the page's markdown file, for the footer link: the docs home and each guide the index lists, and a package's page whose index entry says `markdown` (build-docs-index sets it; the dev server's index has none, so no link there). siteDocHref and the path check say which URL is the page's own: the 404 and a package's doc pages have none */ > spa-manager[active-url] { $page-path: attr("active-url").split("#").at(0).split("?").at(0); $page-name: if($page-path == SITE_HOME: SITE_HOME_DOC; else: $page-path.split("/").at(-1)); $page-guide: list.find($package-indices.docs, "name", $page-name); $page-package: list.find($package-indices.packages, "shortName", $page-name); $page-markdown: if($page-guide and siteDocHref($page-guide.name) == $page-path: "/docs/#{$page-guide.name}.md"; $page-package.markdown and "#{SITE_BASE}/packages/#{$page-package.shortName}" == $page-path: "/#{$page-package.shortName}.md"); footer [bind-page-markdown] { href: $page-markdown; content: ternary($page-markdown, "Markdown version of this page"); } } } spa-route { $route: prop("provision"); /* paramless routes name their guide in markup (the docs home, `/`); /docs/:name gets it from params */ $route-doc-name: attr("data-doc-name"); } /* the desktop aside and the mobile sheet stamp the same nav template */ [data-site-nav] { details:has(spa-a[is-active]) { /* not kosher */ open: ""; } spa-a[is-active] { aria-current: ""; } spa-a:not([is-active]) { aria-current: none; } } /* mobile sheet: Escape / back gesture while open; a tapped link closes it */ #site-menu { &[is-open] dismiss-watcher { is-active: ""; } &:not([is-open]) dismiss-watcher { is-active: none; } @on click (target: "spa-a") { is-open: none; } } #site-menu-gesture { &:has(> #site-menu[is-open]) { progress-offset: 1; } &:not(:has(> #site-menu[is-open])) { progress-offset: 0; } @on gesture-handler-start { #site-menu { is-scrubbing: ""; } } @on gesture-handler-end { #site-menu { is-open: event.detail.snap == 1; is-scrubbing: none; } } } main { @on copy-source (handle: copySource); } #search-dialog[open] { > include-content { is-active: ""; /* for some reason this is necessary on first render */ @on include-content-did-render (handle: focusInput); } input[type="search"] { /* works on all subsequent opens */ autofocus: ""; } } #search-dialog:not([open]) input[type="search"] { autofocus: none; }

Contributing - help wanted!

How to get the monorepo running, what the conventions are, and what a reviewable pull request looks like.

The stack lives in one repository: excom-dev/nucleus. Every published package — the NucleusKit elements, Neutron, Quark, Valence.css — is a folder under packages/, managed by Rush on top of pnpm.

Getting set up

Node 24.13 or newer. Rush is invoked through the checked-in bootstrap script, so there is nothing to install globally.

git clone https://github.com/excom-dev/nucleus.git
cd nucleus
node common/scripts/install-run-rush.js install
node common/scripts/install-run-rush.js build

If you have Rush on your PATH, rush install and rush build are the same thing. rush build is incremental and cached: after the first run it only rebuilds what changed.

Working in one package

Rush's per-package selection is the fast path. From inside packages/<name>, the package's own scripts do everything:

Command What it does
pnpm run build Build that package's entry points
pnpm run test Vitest, in happy-dom
pnpm run coverage Tests plus a coverage report — fails below 90% on any metric. Only the package's own files count; a file no test loads counts as 0%
pnpm run format Prettier over the package
pnpm run dev Dev server, where the package has one

Repo-wide equivalents exist as Rush commands (rush test, rush coverage, rush format), and --to / --from scope them to a dependency graph. Prefer the package-level script while you are iterating — it is the tightest loop.

Tests run in happy-dom, never a real browser. Everything in the stack is DOM work, so a test is usually "build a fragment, register a sheet or an element, assert on attributes".

Package layout

packages/my-element/
  index.ts              # entry: defines and globally types every element here
  index.css             # entry: imports each element's CSS
  my-element.ts         # one source file per element
  my-element.css
  src/                  # helpers; not built as entry points
  support/
    docs/               # README.md and any further pages
    demos/              # one .html per demo, no JS
    tests/              # one <name>.test.ts per source file, one <demo>.view.test.ts per demo
  package.json          # needs to contain config common to other packages, such as `excom` object

Files in the package root become build entry points; files under src/ do not. dist/, coverage/, CHANGELOG.md and the generated docs metadata are all produced by the tooling and are not committed. CHANGELOG.json is written by Rush on publish; never edit it by hand.

The checked-in .vscode/settings.json hides the per-package boilerplate (config/, tsconfig.json, CHANGELOG.json, generated metas) from the VS Code explorer and search.

Conventions

These are the rules reviewers apply. Best Practices covers the reasoning; this is the short version for contributors.

Elements

  • Single responsibility. One job, configurable, observable, generic. content-drawer is good; add-to-cart is not — business behaviour belongs to app authors' Quark sheets, not to the element catalog.
  • Never render or mutate children. Composition is the design. The exception is an element whose entire purpose is logicless rendering of author-supplied markup, like include-content, and that has to be stated in its docs.
  • Dashed attributes. Every custom attribute contains a dash (listen-for, data-duration, is-open), so it can never collide with a native attribute now or later. That applies to reflected state props too.
  • Tag-prefixed events. my-element-change, never change.
  • Imperatives are commands. Accept "do this" as a native command event with a short --verb, so a plain <button command="--open" commandfor="id"> can drive it. Never a bubbling my-element-trigger event.
  • Underscore private members, and keep the imperative surface small.
  • No Shadow DOM unless isolation is genuinely the point. It walls off the very rules that make the stack composable.
  • Own your region. An element writes its own attributes and those of the other elements in its family. Nothing else.

Neutron lifecycles

Keep them functional. Destructure the element argument, and return an effect rather than mutating:

onConnected: ({ isOpen }) => ({ ariaExpanded: String(isOpen) });

Not (el) => { el.ariaExpanded = …; }. Async work that needs current state belongs in a defineMethods method, which receives the live element. Never hold a hard reference to another element; use WeakRef and clear it in onDisconnected.

CSS

Modern CSS — nesting, @scope, :has(), container queries. Classes carry static flavour only (details.accordion); dynamic state is an attribute. Expose --v-* tokens so themes reach the element.

Docs and demos

Every published package documents itself under support/docs/. The API reference is generated from code comments, so the prose you write is the intro, the features list and the usage:

  • README.md — title, a one-sentence pitch, a demo, Features, Usage. Keep it terse; the features list is what a prospective app author reads first, so use the words they would search for.
  • Further pages when the reference outgrows one screen. A support/docs-sections.json groups them into sidebar sections.
  • Links between pages are plain relative markdown ([Props](./PROPS.md), [Styling](/docs/styling)). They work on GitHub, and the pipeline rewrites them for the site. Never hand-write <spa-a> in markdown.
  • INTERNAL.md is a contributor file. It is never rendered or published.

How these pages become package metas, the custom elements manifest, dist-docs/ and llms.txt: the docs pipeline. All of it is generated and none of it is committed.

Demos live in support/demos/<name>.html: one root element, no embedded JavaScript, the smallest markup that shows the point. Anything a demo needs but a reader does not (layout padding, colours) goes in the docs site's demo-utils.css. Four demos is plenty for a small package, eight for a large one.

Proposing an element

Open a discussion before writing code. A proposal is easier to accept when it answers:

  1. What one protocol does it bridge? A network, a store, a sensor, the clock, or a person's interaction. If the answer has an "and" in it, it may be two elements. See Adapter, State, Orchestrator.
  2. What is its state, as attributes? Writing those attributes by hand should reproduce what the protocol would have done.
  3. What does it announce, as events? And which imperatives does it accept, as commands?
  4. Why can existing elements plus a Quark rule not already do this? Composition of what exists always beats a new tag.

Elements stay generic. If the idea only makes sense for one application, it is a view, and views belong to their authors.

Change files

Every publishable package touched by a pull request needs a Rush change file. From the repo root, after committing:

node common/scripts/install-run-rush.js change

Rush compares your branch against main, prompts once per affected package, and writes JSON into common/changes/@excom/<package>/. Commit those files too — CI runs rush change --verify and only counts what is committed.

Pick the bump honestly:

Type When
major A public attribute, event or export was removed or renamed; existing markup or listeners break
minor New public capability — an attribute, an event field, an export
patch A fix or internal improvement with the same public contract
none Docs, demos or tests only

The comment is published documentation — every type but none appears in the package's release notes, on the site and in the LLM docs — so write it for an app author who has never seen the implementation. Ask what it means to them: does it break them, fix something that annoyed them, give them something new to try?

  • Imperative mood, starting with a verb — Add, Remove, Fix an issue where, Improve, Update, Upgrade, Initial release of.
  • The outcome ("Searching now supports wildcards"), not the diff ("Add regex support to SearchHelper").
  • Backticks around public names; name() for functions.
  • Upgrades name both versions: Upgrade happy-dom from 15 to 20.
  • "Issue", never "bug"; link the GitHub issue in parentheses when there is one.
  • No acronyms or shorthand beyond the widely known (HTTP, CSS), and nothing private.
  • No trailing period on a single sentence.

Removals are removals. The stack does not currently ship deprecation windows or compatibility shims; a dropped feature has its code, grammar, tests and docs deleted, and the API rejects it with a clear error. Record it as major and that is the migration story.

Pull requests

Target main. CI installs the workspace and then, for every package your branch touches, runs the build and the coverage suite, verifies change files, and comments bundle sizes and coverage on the pull request.

Before you open one:

  • Tests for the behaviour you added, next to the source file they cover
  • Coverage still at or above 90% for the package
  • pnpm run format run in each package you touched
  • Docs updated — attribute and event comments, the features list if the pitch changed, a demo if it is a headline capability
  • A committed change file per publishable package
  • Custom attributes dashed, events tag-prefixed, imperatives as commands

Keep the pull request to one story. A rename and a new option are two change-file entries and, usually, two pull requests.

Reporting issues

Bugs and feature requests go to the issue tracker. A reduced case beats a description: the smallest HTML, CSS and Quark that reproduces it, plus what you expected. Check Troubleshooting and Limitations first — several surprising behaviours are documented trade-offs with a stated workaround.

Code of conduct

Be decent to each other. Assume good faith, critique the code rather than the person, and accept that a maintainer may say no to a feature that would cost composability. Conduct concerns can be raised privately with the maintainers through the issue tracker.

Beta. The Nucleus Stack is in beta for a few weeks until features are stabilized and optimized.

Thanks — we'll email you when it ships.

Something went wrong. Please try again.