Skip to content

Make the default theme a default: ship it in a cascade layer - #50

Merged
mgoldsborough merged 11 commits into
mainfrom
fix/default-theme-layer
Jul 28, 2026
Merged

Make the default theme a default: ship it in a cascade layer#50
mgoldsborough merged 11 commits into
mainfrom
fix/default-theme-layer

Conversation

@mgoldsborough

@mgoldsborough mgoldsborough commented Jul 27, 2026

Copy link
Copy Markdown
Contributor

Closes #49
Closes #48

The bug

applyThemeVariables wrote DEFAULT_THEME_VARS to document.documentElement.style — the inline style attribute — before layering the host's variables on top. An inline style outranks every author stylesheet, so the neutral map beat the declarations it exists to back up.

Its own docblock described the intended behaviour correctly: "the host's values win for the keys it provides, and any var it omits still resolves to a theme-correct neutral default." That only held for vars the host delivered over the protocol.

And a host cannot always use the protocol. hostContext.styles.variables is a closed enum, so a host whose design system is larger than the enum has to deliver the remainder as a stylesheet injected into the app document — that is the only channel available to it. Those are ordinary unlayered author rules, and inline beat all of them. Every such var the SDK also defaulted was silently replaced by the SDK's value, permanently, with no self-heal path.

A host sending its brand accent that way rendered in our neutral blue.

The fix

The defaults install as @layer synapse-defaults instead — one managed <style> element, replaced in place, so a mode flip swaps the whole map atomically and repeat calls are cheap. Unlayered rules outrank layered ones, so:

declares the var wins why
host, over the protocol host still written inline, which outranks every stylesheet
host by injected stylesheet, and the app's own :root whichever is later in the document both are unlayered :root at equal specificity — see below
only one of those two that one unlayered beats layered
nobody the neutral default the layer is the only declaration

The last row is the point of the map and is unchanged. The middle rows are new: overriding a token from an app previously needed !important, or was impossible.

The host-stylesheet and app-:root cases are deliberately one row rather than a ranked pair. Nothing in the cascade separates them, so document order decides — and against the NimbleBrain runtime the order is fixed, because injectThemeStyles (NimbleBrainInc/nimblebrain, web/src/bridge/iframe.ts:170) injects the host theme block by replacing the <head> opening tag, putting it ahead of the app's bundled styles. So the app wins. A host token delivered by stylesheet is the app's to override; one delivered over the protocol is not.

docs/concepts/theming.mdx is rewritten to say exactly that. It previously listed the two as a ranked ladder in the host's favour — the mirror image of the bug this PR fixes, documented as impossible, on the one page an app author reads to reason about this. Its sibling docs/components/tokens.mdx carried the same pre-0.10.2 fallback claim and is corrected alongside it.

Note that the injection order is a fact about one host, not a guarantee this repo can make, and nothing currently pins it in either repo — see Downstream.

Removal is keyed off what the SDK wrote

A layer cannot mask a stale inline pin the way inline rewrites did, so a key the host stops sending has to be actively removed or it stays pinned forever, where the layer, the host's stylesheet and the app's rules all cannot reach it. That removal iterates a module-level set of the keys last written inline, not DEFAULT_THEME_VARS.

They are different sets. A host may send any spec-enum var, and roughly 25 theme-sensitive ones (--color-text-danger, --color-background-inverse, --color-ring-warning, …) have no neutral default — keying off the default map left exactly those pinned at the previous mode's value while the layer flipped, which is the half-light/half-dark state the removal exists to prevent, reachable by a different door. Tracking what was written also means the SDK never removes an inline property it did not set, so an app driving documentElement.style itself is left alone.

Verification

npm run ci green — 36 files, 496 tests. The docs site builds with all internal links valid.

Ten tests in src/__tests__/theme-default-layer.test.ts assert the property rather than the mechanism, so they hold as the map grows. Mutation-checked, both ways:

  • put the defaults back inline → 6 of 10 fail
  • key the removal off DEFAULT_THEME_VARS again → 2 of 10 fail (the two that cover the case above)

The file says out loud (lines 19–26) that happy-dom does not compute cascade layers, so a green suite is not evidence of the cascade outcome — the tests pin the mechanism a future edit could silently reverse, and the browser settles the outcome.

Confirmed in Chrome 148 against a real larger-than-enum host — its full token set as an injected :root rule, plus the spec-filtered subset over the protocol:

key delivery host intends before this PR after
--color-text-accent stylesheet #0055FF #2563eb #0055FF
--nb-color-danger stylesheet #c02a24 #dc2626 #c02a24
--nb-color-success stylesheet #0f7a4f #059669 #0f7a4f
--nb-color-warning stylesheet #8a5f0a #f59e0b #8a5f0a
--color-text-primary protocol #09090b #09090b #09090b
--font-sans protocol host stack host stack ✅ host stack ✅

And the controls, so the fix isn't just "defaults stopped applying":

  • No host at all: --color-text-accent is unset before, then resolves to #2563eb in light and #818cf8 in dark. The fallback still works, in both modes.
  • App override: an app-authored :root { --color-text-accent: #ff00ff } now wins.
  • Injection order: with the host block first in <head> (as injectThemeStyles places it) and the app's <style> after, the app's value is what computes — the finding the doc rewrite records.
  • App-declared layers: an app that declares @layer app before the SDK inserts its sheet still wins (#ff6600). The SDK prepends at runtime, after the app's layer names are already registered, and the browser re-sorts layer order on insertion — so synapse-defaults sorts first and the app's layer after it. This is the one claim the docs make that the cascade does not make for us, so it is now measured rather than reasoned.

Downstream — this needs a host-side counterpart before it reaches users

NimbleBrainInc/nimblebrain#804 holds a red guard (web/src/__tests__/host-token-contract.test.ts) pinning exactly this contract, and this is the fix it is waiting for. But shipping the SDK half alone would regress that host.

The host delivers its non-spec tokens — --color-text-accent and the --nb-color-* set — as a :root block baked into the iframe's srcdoc at mount (buildThemeStyleBlock(mode), injectThemeStyles at iframe.ts:259). That block is mode-specific and frozen for the frame's life: the sandbox deliberately withholds allow-same-origin, so the host cannot reach back in, and SlotRenderer keeps iframes mounted across a theme change on purpose (placementKey is resourceUri only). Theme changes arrive over host-context-changed, spec-filtered — so those keys never travel that path.

Eight keys are in this SDK's default map, emitted by the host's block, and off the protocol: --color-text-accent, --nb-color-{accent-foreground,danger,success,warning,processing,processing-light,info-light}. Today the SDK's inline defaults mask the staleness by overriding the host's block entirely — which is bug #49. Move them into a layer and the frozen block wins, so after a light→dark toggle they hold the light palette on a dark page. Reproduced on Chrome 148: background flips to #000000 and text to #fafafa, while --color-text-accent stays pinned at #0055FF — 3.74:1, under AA, where main gives #818cf8 at 7.04:1. #804's guard cannot catch it: it reads the inline style attribute, which is precisely what this PR stops writing.

Two bugs, one masking the other. This PR fixes one and exposes the other; the exposed one is host-side.

The root fix belongs in the host, and the shape is already established here. A token that varies with mode has to travel on a channel that can be updated when the mode varies. srcdoc is write-once. The synapse/* host-context extension is not — synapse/fontFaces (0.13.0) already carries non-spec data over it, filterHostContextForSpec already exists to keep such keys off the spec field, and they would land inline like every other host token and track the mode for free. That retires the frozen block as a token channel rather than working around it.

Sequencing. The exact pin in #804 is 0.13.0, so merging and publishing 0.14.0 reaches no one — but it also means #804's guard stays red until it moves to 0.14.0. That bump is the gate, and it should not land before the host carries these tokens on an updatable channel. Tracked as NimbleBrainInc/nimblebrain#817, which carries the reproduction and the proposed channel fix.

Also in this diff

Restores the CHANGELOG's missing ## [0.9.0] and ## [0.8.0] headings (#48). Their absence filed both releases' entries — including a ### Breaking — under ## [0.10.0], which misdates the FileResult.base64DataFileResult.id break by two minor versions and cost a downstream consumer a round of tag-diffing to disprove. Two lines, in a file this change already edits, and separable if you'd rather it went alone.

Version 0.14.0, not a patch: the change adds a capability the docs now advertise (an app overrides an SDK default with a plain :root rule) and moves cascade precedence for every existing consumer that declares one of these vars. The Python IIFE is re-vendored from this tree's build with __client_version__ moved to match, which CI's freshness diff and pin gate both require.

`applyThemeVariables` wrote DEFAULT_THEME_VARS to `documentElement.style`. An
inline style outranks every author stylesheet, so the neutral map beat the very
declarations it exists to back up — a default that wins the cascade is not a
default.

This is not a corner case. `hostContext.styles.variables` is a closed enum, so a
host whose design system is larger than that enum has to deliver the remainder as
a stylesheet injected into the app document; that is the only channel it has.
Every such var the SDK also defaulted was replaced by the SDK's value
permanently, with no self-heal path — a host sending its brand accent that way
rendered in our neutral blue.

The defaults now install as `@layer synapse-defaults`. Unlayered beats layered,
so a host's or an app's `:root` rule wins, while a var nobody declares still
resolves to a theme-correct neutral default. Host variables that do arrive over
the protocol still go inline and still win over both. An app overriding a token
now needs a plain `:root` rule rather than `!important`.

One managed `<style>` element, replaced in place, so a mode flip swaps the map
atomically and repeat calls are cheap. Falls back to `documentElement` where
`head` is absent, and still no-ops without a `document`.

Verified in Chrome 148 against a real host: the four tokens a larger-than-enum
host loses (`--color-text-accent`, `--nb-color-danger/success/warning`) now
compute to the host's values, protocol-delivered tokens are unchanged, an
undeclared var still gets the neutral default in both modes, and an app's own
`:root` rule overrides it. Four of the six new tests fail if the defaults go back
inline.

Also restores the CHANGELOG's missing `## [0.9.0]` and `## [0.8.0]` headings
(#48), whose absence filed their entries — including a `### Breaking` — under
0.10.0, misdating the `FileResult` break by two minors. Two lines in a file this
change already edits; separable on request.
The `@layer` change alters the built IIFE, and `python/nimblebrain_synapse/
_assets/synapse-ui.iife.js` is a checked-in copy of it, so CI's freshness diff
failed — `npm run ci` doesn't cover that step. Rebuilt and re-copied, and bumped
`__client_version__` to 0.13.1 to match `package.json`, which is the other gate
a version bump trips.

Worth noting what this proves: the vendored IIFE now carries the layer, so the
fix reaches the cross-host `connectUI` consumers too, not only the ext-apps
paths. Both CI gates replicated locally before pushing this time.
…e guard

Writing the defaults inline had a load-bearing side effect I removed without
replacing: it rewrote every defaulted key on every call, which healed any stale
host value pinned inline. A layer cannot do that — it can't outrank an inline
property. So an ext-apps `host-context-changed` that flips mode while omitting
`styles` (the spec's normal partial shape, which `extractTheme` renders as an
empty var set) left the host's light values pinned inline while the layer flipped
to dark. Reproduced against origin/main as control: `--color-background-primary`
stayed `#ffffff` here where main showed `#18181b`, and
`--color-text-secondary` on that retained white computes to 2.56:1, under AA.

A defaulted key the incoming set doesn't carry is now removed rather than
overwritten, which is strictly less destructive than the old behaviour and keeps
this change's whole point: the cascade resolves it against the host's stylesheet,
the app's rule, then the layer. Verified in Chrome both ways — with a host
stylesheet the host's own declaration wins (it is the host's to update), and with
no stylesheet the layer takes over to a coherent dark. A non-defaulted host var
is left alone, since the SDK has no opinion on it and absent means unchanged.

`document.head ?? document.documentElement` is gone. `ensureStyle` in this same
package calls `document.head.appendChild` unguarded, browsers always have a head,
and so does happy-dom — the only thing that reached the fallback was a test
manufacturing the condition with `Object.defineProperty`. Both removed.

Also: the module docblock described the mechanism this branch deletes; `prepend`
is load-bearing for layer order and now says so; the template literal is safe
only because its input is module-local, which matters because
`applyThemeFontFaces` 90 lines below cites non-concatenation as deliberate, so a
later change folding host vars in would make it an injection sink; and the
idempotence test counted every `<style>` in the document rather than ours.

CHANGELOG now warns that an injected `<style>` is subject to `style-src` where a
CSSOM write was not, so a strict-CSP host loses the map — with constructable
stylesheets named as the escape hatch if that becomes real.
The clear loop tested `k in incoming`; the write loop tested that plus
`typeof v === "string"`. A defaulted key arriving with a non-string value fell in
the gap — not removed, not written — so the previous theme's value stayed pinned
inline, where the layer, the host's stylesheet and the app's `:root` all cannot
reach it. That is the un-self-healing pin this change exists to remove, re-entered
through the malformed-value door instead of the omitted-key one, and it is a
regression against main rather than a pre-existing gap: same input,
`--color-text-primary` after a dark flip read `#111827` here where main gave
`#fafafa`. Wire data is untyped, which is why `normalizeFontFaces` exists too.

Normalized once into a filtered map that both loops read, which also deletes the
per-entry re-checks. I had written in the downstream PR that two spellings of one
rule are what drift, then shipped two here.

The clear loop's docblock justified itself with #46's symptom. Its durable reason
is a host legitimately narrowing its key set; #46 (core.ts replacing the context
wholesale, which connect.ts already defends against) is the accidental source of
an empty var set today, and fixing it is the better outcome — it keeps the host's
brand across a mode flip rather than collapsing to our neutral — without making
the loop unnecessary. Both now stated, with the issue cited so the rationale can't
outlive the bug.

The suite header claimed to pin the property; happy-dom computes no cascade
layers, so it pins the mechanism and the cascade is verified by hand in Chrome.
Says so now, and says why the mechanism is still the right thing to assert. The
`--font-sans` test reads as a known limit rather than as intent.

`@layer synapse-defaults` is a new public contract, so theming.mdx now documents
the resolution order and that overriding a default needs no `!important`. That
page also still claimed every token falls back to its `var()` second argument
standalone, which stopped being true for colors in 0.10.2. The CSP note gains the
other way the block gets dropped wholesale — a browser without `@layer`.
The theming page ranked the host's injected stylesheet above the app's own
`:root` as though the cascade decided between them. It does not: both are
unlayered `:root` rules of identical specificity with no `!important`, so
document order is the only thing separating them. And the order is fixed
against the host — the runtime injects its theme block by replacing the
`<head>` opening tag, putting it ahead of the app's bundled styles — so an
app that declares `--color-text-accent` overrides the host's brand accent.

That is the mirror image of the bug this branch fixes, documented as
impossible, on the one page an app author or third-party host integrator
reads to reason about exactly this question. Reproduced in a browser against
the real injection order before rewriting it.

The tier collapses into one and the page states the rule rather than an
order, then says plainly that a host token delivered by stylesheet is the
app's to override, since that is both true and a reasonable policy.
The clear loop iterated `DEFAULT_THEME_VARS[mode]`, but that is not the set
this module writes inline — the host writes it. `styles.variables` carries
any spec-enum key, and roughly 25 theme-sensitive ones
(`--color-text-danger`, `--color-background-inverse`, `--color-ring-warning`,
…) have no neutral default. A host that sends one and then hits `core.ts`'s
wholesale-replace path kept it pinned at the previous mode's value while the
layer flipped: the half-light/half-dark state the clear loop exists to
prevent, still reachable by a different door.

Keying removal off a module-level set of the keys last written closes it,
and costs three lines rather than the key history the old test comment
imagined it would need. It also narrows the blast radius in the other
direction — the SDK now never removes an inline property it did not set, so
an app driving `documentElement.style` itself is left alone, which the
neutral-map loop did not guarantee.

The docblock also drops the claim that a host wins through either channel.
It wins over this module's default through either, but only the protocol
channel outranks the app; between a host stylesheet and the app's own rules
document order decides, and not by anything this module does.
A patch understates this. The change adds a capability the docs now
advertise — an app overrides an SDK default with a plain `:root` rule, no
`!important` and no layer of its own — and it moves cascade precedence for
every existing consumer that declares one of these vars. `0.14.0` is the
honest signal for a consumer deciding whether a bump can move pixels.

Re-vendors the Python IIFE from this tree's build and moves
`__client_version__` with it, which the CI freshness diff and pin gate both
require.
The standalone-fallback claim was rewritten on `concepts/theming.mdx` when the
defaults moved into a layer, and the identical sentence survived on its sibling
`components/tokens.mdx` — same heading, same list, still promising "a generic
blue" for colors.

It is wrong in the direction that matters. Colors now resolve through the
`synapse-defaults` layer, and a `var()` second argument is a static literal, so
the sentence describes light-mode literals rendering in dark. The two pages are
one page's worth of contract split across two files; a reader who lands on the
components page gets the pre-0.10.2 answer.

Points at the concepts page for the full order rather than restating it, so the
next change to the resolution rules has one place to land.
`applyDefaultThemeLayer` reused whatever `getElementById` returned for its own
id. An app that happens to own `synapse-theme-defaults` had that element's text
content overwritten, and since a non-style element carries no rules, the whole
default map silently stopped applying — a failure with no error and no visible
cause short of reading the DOM.

An `instanceof` check makes the mismatch fall through to creating the element
this module owns, which is the behaviour the id was standing in for.

Also syncs `package-lock.json`, which `cb8c5da` left at `0.13.1` while
`package.json` and `__client_version__` moved to `0.14.0`. `npm ci` does not
fail on a root-version mismatch and the CI pin gate only covers the Python
side, so a clean checkout rewrote it on first install — a third source of
truth for the version, in the commit that sets it.
Three things this round, all narrowing the gap between what the change does
and what a consumer reading it is told.

**The prerequisite ships.** The analysis of what a host must do to adopt this
lived only in the PR description, which no consumer sees. The CHANGELOG is in
the npm tarball and read two `### Fixed` bullets as an unqualified
improvement. It now states the rule — anything varying with light/dark has to
reach the app on a channel the host can update — names the NimbleBrain
release that satisfies it, and says not to bump ahead of it. `theming.mdx`
carries the same caution for anyone integrating a new host.

**The `instanceof HTMLStyleElement` guard is removed.** It was added on a
suggestion and it was the wrong call: no test covers it (the suite is 496/496
with it gone), the threat it names is an app squatting a namespaced id, and
its fall-through creates a *second* element with the same id — breaking the
"one element, replaced in place" invariant the docblock above it asserts.
A real fix here would hold a module reference rather than round-tripping
through `getElementById`, and would come with a test; neither belongs in a
PR about cascade precedence.

**Three call sites lose a description of the old mechanism.** `core.ts`,
`connect.ts` and `provider.tsx` each said the defaults "back any var the host
omits, then host values win," which describes the inline write this change
replaced. The module docblock was corrected in this same branch and these
were missed. Also retitles theming.mdx's standalone section, whose heading
promised the `var()` fallbacks win directly above a sentence explaining that
colors resolve through the layer instead.
Both token pages told an app author that with no host, color tokens resolve
through `synapse-defaults` and stay theme-correct in dark — naming "a static
render, a Storybook, a unit test" as the cases. Only one nearby case is true.

`applyTheme` has five call sites and every one is inside a connection path
(`connect`, `createSynapse`, `connectUI`, `SynapseProvider`). Nothing under
`ui/` installs the layer, so rendering a component directly installs none:
verified by rendering `<Button>` with no host and no provider and finding no
`#synapse-theme-defaults` element. Those renders fall back to the static
`var()` literals, light-flavoured in both modes — the exact failure the
default map exists to prevent, promised as solved on the page an author reads
to reason about it.

The split is now stated as one: a connection with no host behind it (the
`inline` adapter, or a host that ships no tokens) gets the layer; a render
that never connects does not. The dark-mode note is deliberate rather than
prescriptive — `extractTheme` resolves a missing context to `light`, so
wrapping a story in `SynapseProvider` installs the layer but installs the
light one, and saying "wrap it" would trade one wrong promise for another.
@mgoldsborough mgoldsborough added the qa-reviewed QA review completed with no critical issues label Jul 28, 2026
@mgoldsborough
mgoldsborough merged commit b464d79 into main Jul 28, 2026
7 checks passed
mgoldsborough added a commit that referenced this pull request Jul 28, 2026
Three releases landed on main after this branch's last push: the cascade
layer (#50) and its framing (#52), cut as npm v0.14.0, and the Python
package's catch-up release (#53), cut as nimblebrain-synapse-v0.4.0.

npm 0.14.0 is published, so the warm removal stays under `[Unreleased]`
and releases as 0.15.0. The migration note's unaffected range widens to
`^0.11.0`-`^0.14.0` to match what consumers can now be pinned to.

The vendored Python IIFE is rebuilt from the merged source rather than
resolved to either side, since #50 changed the build output. Both
nimblebrain-synapse-v0.4.0 and v0.14.0 are tagged at commits that
predate this merge, so both shipped artifacts still carry the warm
channel their changelogs describe.

theme-defaults.ts auto-merged: #50's cascade-layer docblock and this
branch's correction to the neutrality claim are different hunks.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

qa-reviewed QA review completed with no critical issues

Projects

None yet

1 participant