@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; }

Lifecycles

Chain .on* handlers that return effects; Neutron applies them, tracks listeners, and routes errors.

Handlers

Each handler receives the element first, then any lifecycle-specific argument, and returns an effect. Prefer effects over mutating the element directly. Every on* has an off* twin that unregisters the same function.

Neutron({ tag: "panel-host", props: { isReady: Boolean } })
  .onConstructed(() => ({ /* runs in the constructor, before connect */ }))
  .onConnected(() => ({
    // every connect, including reconnects
    isReady: true,
    emit: ["panel-host-ready"],
  }))
  .onDisconnected((el) => ({
    // tear down; `el.isMoving` is true when a disconnect is followed by a connect in the same tick
    isReady: false,
  }))
  .onAdopted(() => ({}))
  .onError((_el, err) => {
    console.error(err);
  });

Disconnect is settled one microtask after disconnectedCallback. If the element is re-inserted before then (a DOM move), onDisconnected and onConnected both still run, with isMoving set. An error thrown by any handler is routed to onError(el, error); without an onError, it is rethrown.

Reactions to prop changes, events, commands and promises are their own pages: Prop reactions, Events, Commands, Promise props.

Destructure the element argument

Prefer ({ prop }) => … over (el) => …. A handler that only ever sees the values it names cannot reach for el.setAttribute, el.querySelector(…).value = … or any other imperative mutation — reading the signature is enough to know the handler is pure, and the effect it returns is the whole story.

Neutron({ tag: "price-tag", props: { amount: Number, currency: String } })
  .onPropChanged(["amount", "currency"], ({ amount, currency }) => ({
    ariaLabel: `${amount} ${currency}`,
  }));

Pitfall: stale values in async callbacks

Destructuring copies the values at call time. If the handler starts asynchronous work and the callback reads those copies, it sees the element as it was when the work started, not when it finished:

// ✗ `amount` here is whatever it was when the fetch began
.onConnected(({ apiUrl, amount }) => ({
  addListener: ["price-tag-refresh", () => fetch(apiUrl).then(() => console.log(amount))],
}))

Make the callback a defineMethods method instead. Methods are effectors too — Neutron calls them with the live element first — so the callback destructures fresh values when it actually runs, and its return value is applied as an effect. The lifecycle keeps el only to reach the method (methods are bound to the element; call them as el.method(…)):

Neutron({ tag: "price-tag", props: { apiUrl: String, amount: Number } })
  .defineMethods({
    // runs later, with the element as it is *then*
    applyQuote: ({ amount }, quote: { rate: number }) => ({
      amount: amount * quote.rate,
    }),
  })
  .onConnected((el) => ({
    addListener: [
      "price-tag-refresh",
      () => fetch(el.apiUrl).then((r) => r.json()).then((quote) => el.applyQuote(quote)),
    ],
  }));

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.