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

Business Logic

Pricing, eligibility, entitlements, approvals — the proprietary behaviour nobody can ship for you. In this stack it is not a layer of code. It is facts written into the document, and rules that select on them.

The shape never changes:

  1. Facts. Everything the app knows lands in the document as an attribute, on an element named after the record it belongs to. Derived values are computed once and written next to the values they came from.
  2. Rules. A business rule is a Quark selector over those facts, plus what follows from it.
  3. Consequences. CSS selects on the same facts to render them: what shows, what it says, what it totals.

The payoff is not brevity. It is that a rule and its evidence sit in the document together. Open devtools on a disabled button and the attributes that disabled it are on its ancestors, spelled out. Nothing to step through, nothing to reproduce.

Facts first

Stamp the server's answer onto the document before deciding anything with it. A record gets an element named after it — custom data- attributes and/or a custom tag starting with data- — and every field lands on its own line as a dashed attribute:

<provider-fetch api-url="/api/session">
  <ul><li></li></ul>
</provider-fetch>
provider-fetch[api-url="/api/session"][is-success] {
  $record: prop("provision").body;
  li { dataset: $record; }
}

Or:

<!-- the session's facts live here; rules and CSS select on them -->
<data-session></data-session>
provider-fetch[api-url="/api/session"] {
  $session: prop("provision").body;
  data-session {
    account-id: $session.accountId;
    plan-name: $session.plan;
    region-code: $session.region;
    seat-count: $session.seatCount;
    seats-included: $session.seatsIncluded;
  }
}

Writing the fields out one at a time is the point, not a chore. The list is the schema: open the file and you know exactly what the API sends, and open devtools and you see the same names on the element. (dataset: $session would unpack the object in one declaration, but only onto data-* attributes, and it hides which fields exist — keep it for the case where a genuinely open-ended bag of data-* is what you want.)

Give the element the display it needs in CSS — an undefined custom element is display: inline until you say otherwise. Where the record is also presented, hand it the ARIA role the native tag would have carried (<data-item role="listitem">, <data-return role="article">): assistive technology reads it as before, and the Valence.css role aliases style it as before.

Then derive. A boolean declaration writes "" when truthy and removes the attribute when falsy, so a derived fact ends up indistinguishable from a fact the API sent:

data-session {
  is-guest: $session.role == "guest";
  is-over-seats: $session.seatCount > $session.seatsIncluded;
}

Three rules keep this honest.

  • Derive once, select many times. The same condition repeated across several if() calls means a fact is missing from the document. Write it as an attribute and every later rule — and every stylesheet — can see it.
  • Write the inverse. Quark rules do not revert. Every state you leave needs its counter-rule, or a stale fact outlives the thing it described.
  • Dash every custom attribute. is-guest or seat-count, never guest or seats. A bare word can shadow, or later collide with, a native attribute. Dropping the data- prefix does not buy you a dash-less name; it just moves the prefix to the tag, where it says something.

Rules as selectors

A rule is a condition and its consequence. The condition is a selector over facts; the consequence is more facts.

One fact per decision, its value the reason

When a decision has more than two outcomes, do not reach for a second boolean. Give the fact a value, and let that value be the reason it took:

:scope {
  /* the free plan carries one active project; a read-only seat carries none */
  &[is-read-only] {
    new-project-block: "read-only";
  }
  &[plan-name="free"]:not([is-read-only]):has([is-active]) {
    new-project-block: "plan-limit";
  }
  &:not([is-read-only]):not([plan-name="free"]),
  &[plan-name="free"]:not([is-read-only]):not(:has([is-active])) {
    new-project-block: none;
  }
​
  &[new-project-block] [bind-new-project] { disabled: ""; }
  &:not([new-project-block]) [bind-new-project] { disabled: none; }
}

Read the middle rule aloud and it is the requirement: on the free plan, not read-only, already holding an active project. The last selector is the inverse — every combination that leaves the blocked state. :has() turns a question about descendants into a fact on the ancestor, which is how an aggregate becomes selectable.

The same predicate almost certainly exists on your server, guarding the endpoint. That is fine and correct: it is stated once on each side of the wire, in the vocabulary of that side, and neither is hidden inside a call stack.

Default-deny

Where the safe answer is "no", render every option, tag each with its identity, and let one rule open the ones that qualify. An option you forgot to account for stays closed:

provider-fetch[api-url="/api/carriers"][is-success] {
  $carriers: prop("provision").body;
  [bind-carriers] {
    content: iterate($carriers);
    > data-carrier { carrier-name: item; }
  }
}
/* nothing ships anywhere until a region says so */
data-carrier { display: none; }
[region-code="uk"] :is([carrier-name="northwind"], [carrier-name="fenwick"]),
[region-code="de"] [carrier-name="brandt"] { display: block; }

In the business case above, opening a new market is just a selector, rather than a code change. It should be noted that you still must have backend validation for all business logic, as with all UI frameworks.

Facts derived from two places at once

A rule may compare a route against a session, a row against its parent, a line against the method chosen for the whole order. The comparison happens once; the result is a fact on the row:

spa-route[is-active] { $route: prop("provision"); }
​
data-project {
  is-active: attr("project-id") == $session.activeProjectId;
  is-locked: $route.params.mode == "audit" and attr("owner-id") != $session.accountId;
}

Now data-project[is-active] and data-project:not([is-locked]) are available to every other rule and to the stylesheet, and neither has to know where the answer came from.

Aggregates

:scope:has() sums a list up to its host without a counter, an observer, or a line of script:

:scope:has(data-line[is-selected])       { has-selection: ""; }
:scope:not(:has(data-line[is-selected])) { has-selection: none; }
:scope:has(data-line[is-selected][is-expedited])       { needs-approval: ""; }
:scope:not(:has(data-line[is-selected][is-expedited])) { needs-approval: none; }

Keep :has() arguments shallow. Each one costs a subtree scan per candidate element.

Gating the submit

The last rule in most flows is the one that decides whether the user may proceed, and why not:

:scope {
  &:not([has-selection]) [bind-summary] {
    disabled-reason: "Pick at least one line";
    button { disabled: ""; }
  }
  &[has-selection][needs-approval]:not([approver-id]) [bind-summary] {
    disabled-reason: "Expedited lines need an approver";
    button { disabled: ""; }
  }
  &[has-selection]:not([needs-approval]) [bind-summary],
  &[has-selection][approver-id] [bind-summary] {
    disabled-reason: none;
    button { disabled: none; }
  }
}

The button's state and the sentence explaining it are written by the same rule, so they cannot disagree.

The same rules, in CSS

Quark decides; CSS renders. Both select on the identical facts, which is what stops the two from drifting.

Quark owns anything that is state: attributes, content, listeners. disabled is state, so disabled: "" is a Quark declaration. CSS owns anything that is appearance — visibility, emphasis, the wording of a reason, a running total:

[new-project-block] [bind-new-project] { cursor: not-allowed; opacity: 0.5; }
[new-project-block="plan-limit"] [bind-new-project-note]::before {
  content: "One project on the free plan — upgrade for more";
}
[new-project-block="read-only"] [bind-new-project-note]::before {
  content: "Your seat is read only";
}
​
/* a tooltip straight out of the fact that caused it */
[disabled-reason]:is(:hover, :focus-within)::after {
  content: attr(disabled-reason);
}

Counters handle totals, so the sum lives where the rows are rather than in a variable somewhere:

[bind-lines] { counter-reset: picked subtotal; }
data-line[is-selected] {
  counter-increment: picked 1 subtotal attr(line-amount type(<integer>), 0);
}
[bind-summary] .totals::before {
  content: counter(picked) " selected · $" counter(subtotal);
}

Emptiness is a fact too. A value below a threshold that resolves to "" disappears on its own:

[bind-margin] { content: if($line.marginPct > 1: "#{$line.marginPct}% margin"; else: ""); }
[bind-margin]:empty { display: none; }

When a calculation outgrows selectors

Selectors compare and combine; they do not iterate and accumulate. A weighted total, a tax table, an amortisation schedule — those are arithmetic, and arithmetic belongs in a function.

Check the built-in modules first: quark:list counts, filters, groups and sums, quark:math clamps and rounds, quark:string pluralises, quark:date does calendar arithmetic. Only write your own when they fall short.

When you do, @use it and let it return a value for Quark to write:

@use "/views/quote/pricing.js" as pricing;
​
provider-fetch[is-success] {
  $quote: prop("provision").body;
  [bind-quote] {
    contract-total: pricing.contractTotal($quote.lines, $quote.termMonths);
    is-above-approval-limit: pricing.contractTotal($quote.lines, $quote.termMonths) > 50000;
  }
}
/* values in, value out. no DOM, no state, no attributes. */
export const contractTotal = (lines = [], termMonths = 12) =>
  lines
    .filter(({ isWaived }) => !isWaived)
    .reduce(
      (total, { unitPrice, seats, discountPct = 0 }) =>
        total + unitPrice * seats * termMonths * (1 - discountPct / 100),
      0,
    )
    .toFixed(2);

The function is a calculator. It takes values, returns a value, and has no idea a document exists. Quark writes the result into the DOM, where it becomes another fact that rules and CSS can select on — [is-above-approval-limit] now gates the submit exactly like any other fact.

The counter-example

Here is the same feature written the way many stacks would write it:

/* anti-pattern: goes shopping in the DOM, then writes to it */
export const updateTotal = () => {
  const rows = document.querySelectorAll("#quote data-line[is-selected]");
  const total = [...rows].reduce(
    (sum, row) => sum + Number(row.getAttribute("line-amount")),
    0,
  );
  document.querySelector("#quote-total").textContent = `$${total}`;
  document.querySelector("#submit").disabled = total === 0;
};

It works, and it costs you four things.

  • It is a second copy of a rule you already wrote. data-line[is-selected] is now a requirement stated in two languages. One of them will be updated and the other will not.
  • It has to be called. Every future path that changes a selection must remember to call it. That obligation is invisible until something forgets.
  • Its conclusion never reaches the document. total === 0 is the decisive fact, and it dies as a local variable. No CSS rule and no other Quark rule can see it, so the next feature that needs it computes it again.
  • It is untestable without a DOM, and uninspectable with one — the selection is on screen, the reasoning is not.

The test is simple: if a function reads the DOM or writes the DOM, it is doing the Orchestrator's job. Give it arguments and let it return a value. A narrow allowance is a node the function builds itself: a chart is [bind-chart] { content: createChart($series); }, where createChart creates its own element, renders into it and returns it, and still never reads or writes the document around it (Asynchronous work covers a render that finishes later). Talking to a server, a store or a payment provider is not a module's job: that is an Adapter's protocol, and its outcome reaches the document as attributes and a provision.

Checklist

  • Every value a rule needs is an attribute on an element, not a variable in a closure
  • Every record is an element named after it (data-<noun>), with every field written out on its own line; it also can a generic element with data- attributes.
  • Every derived boolean is written once, and has an inverse rule
  • Every custom attribute contains a dash
  • Decisions with several outcomes carry a value that names the reason, not a stack of booleans
  • Aggregates come from :scope:has(), not from a counting loop
  • The default is the denying case; qualifying opens it
  • Quark writes state (disabled, content, the record's attributes); CSS renders appearance from the same facts
  • Built-in modules checked before writing a helper
  • Every module function takes values and returns a value — no state, no attributes, no DOM beyond a node it builds and returns
  • The reason a control is disabled is a fact, and the message the user reads comes from it

Related: Orchestrating for the language itself, Best Practices for the short rules, Building Views for where these files live.

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.