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

@view-transition

@view-transition [(options)] { … } commits the writes inside it inside document.startViewTransition(), so CSS can animate what changed — rows that leave included, which plain CSS transitions cannot reach.

A paint policy

The block decides how its declarations and its nested rules' writes land, never when rules run.

provider-fetch[is-success] {
  $todos: prop("provision").body;
  @view-transition (types: "todo-change") {
    ul { content: iterate($todos, none, "id"); }
    [bind-count] { content: $todos.length; }
  }
}
ul { view-transition-name: todos; }
li { view-transition-name: match-element; view-transition-class: todo; }
::view-transition-new(.todo):only-child { animation: todo-in 250ms; }
::view-transition-old(.todo):only-child { animation: todo-out 200ms; }
:root:active-view-transition-type(todo-change) ::view-transition-old(root) { animation: none; }
  • One tick, one cut. Every Quark write of the same tick lands in the same cut, and writes that react to those writes land in it too as long as Quark settles within timeout. Writes more than a tick later are a separate fact and a separate transition.
  • Put the writes of one cut inside the block (the list, its counter, the empty state). Only a write inside a block starts a transition; writes outside it join the cut when they happen meanwhile. $variables do not paint, so a block holding only $variable writes never starts one.
  • Keyed iterate() rows persist, so named rows move instead of leaving and entering again.
  • Styling is CSS's job. Name elements (view-transition-name, match-element, view-transition-class) and target ::view-transition-group / -old / -new and :active-view-transition-type(). While the animation runs, captured elements are drawn from those pseudo-elements, so real-DOM styles only show on elements that are not captured. A name that comes from State is one CSS variable away: li { --vt-name: "row-#{item.id}"; } in the sheet, li { view-transition-name: var(--vt-name); } in the CSS.
  • Options belong to the block. They are evaluated per write, on the block's element — what the rule it sits in matches, or the host for a sheet-level block — so attr(), prop() and item read that element.

Options

Option Effect
types: "a b" Names for :active-view-transition-type(): a string (space-separated) or a list; "todo-#{$op}" interpolates. The types of every write in one transition are combined. A write that a later one replaces in the same tick (the same attribute, class string or text content of one element) is not written: it adds no types and starts no transition.
timeout: <ms> How long the transition waits for Quark to settle before the new state is captured. Default 300, or 1000 with until. On expiry it captures what is there and warns once per block.
delay: <ms> Hold these writes back first, then commit them in their own transition. Other writes of the same tick are not held and land first.
first-render Also animate the sheet's first render (off by default, like spa-manager's transition-first-render).
if-active: skip / replace While another view transition runs (a route change, or Quark's own still animating): commit unanimated (skip, the default) or start anyway, which ends the running one (replace, the browser's own behavior).
until: "<selector>" / until: <promise> Keep the transition open until the block's element matches the selector ("[is-success], [is-error]", ":not([is-loading])", ":has(li)") or the promise settles, capped by timeout.

until

until is for short waits. The page stays frozen on the old state while the transition is open, so wait for a template or view that is about to render, never for a data fetch — animate a fetch as two cuts ([is-loading], then [is-success]) instead. Put the block on the element that owns the fact and nest the writes; a block on a descendant waiting for an ancestor's attribute only times out:

provider-fetch[is-loading] {
  @view-transition (until: "[is-success], [is-error]", timeout: 800) {
    ul { content: none; }
  }
}

When no transition runs

Committed without a transition: writes that change nothing, the sheet's first render, prefers-reduced-motion: reduce, a hidden document, browsers without the API (the writes land as usual) and, under if-active: skip, writes while another transition is active. A document runs one view transition at a time; scoped (per-element) transitions are not available yet.

Costs

Each transition snapshots the page once, its writes land one rendering opportunity later than without the block, and pointer input goes to the transition overlay while it animates — keep animations short. It is the one place Quark stops being "write and forget": a State write waits for a rendering opportunity.

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.