Skip to content

Back unbacked ui tokens (bgSubtle/fgFaint/borderStrong) with a theme-aware default layer#18

Merged
mgoldsborough merged 2 commits into
mainfrom
fix/back-unbacked-theme-tokens
Jun 23, 2026
Merged

Back unbacked ui tokens (bgSubtle/fgFaint/borderStrong) with a theme-aware default layer#18
mgoldsborough merged 2 commits into
mainfrom
fix/back-unbacked-theme-tokens

Conversation

@mgoldsborough

Copy link
Copy Markdown
Contributor

Closes #17.

Problem

tokens.bgSubtle, tokens.fgFaint, and tokens.borderStrong reference CSS vars — --color-background-tertiary, --color-text-tertiary, --color-border-secondary — that no host injects (not the preview harness, not the NimbleBrain host). They always resolve to their hardcoded light fallbacks, so they're theme-blind: pair one with a theme-aware token and you get white-on-white in dark mode while light looks fine and tsc/build pass.

This isn't only a downstream-app footgun — the SDK's own components use these three tokens: Card, ListRow, Prose, Table, Badge, Button, Avatar, SearchField, SegmentedControl, EmptyState, StatusDot, and typography. So the shipped component library itself renders broken in dark mode.

Why not fix the fallback: a CSS var(--x, fallback) fallback is a static literal that fires exactly when --x is unset — the moment there's no theme signal to branch on. So it can't be light-in-light / dark-in-dark. The fix must add a definition layer, not a smarter fallback.

Why not a [data-theme] stylesheet (the issue's first sketch): the host applies variables as inline styles on document.documentElement and switches themes by re-sending a different value map — it never toggles a data-theme attribute, and a :root {} rule loses specificity to the host's inline styles anyway. So a static dark block would never activate. The fix has to live in the same inline-application path the SDK already owns, keyed on hostContext.theme.

Fix

  • theme-defaults.ts — a neutral default theme backing every color var the token contract references, in both light and dark. Applied to :root beneath the host's variables, keyed on the active mode, so:
    • the host's (brand) values still win for the keys it provides, and
    • any var the host omits resolves to a theme-correct neutral default.
    • Works against an incomplete host, a standalone connect() widget, or a third-party host. Values stay neutral — brand still arrives only by host injection, preserving the library's host-agnostic design.
  • Single injection pathapplyThemeVariables(mode, hostVars) replaces three near-duplicate inline injectors in core.ts, connect.ts, and the React <SynapseProvider>. No public API change.
  • Preview harnesses (vite plugin host + preview server) now inject the three vars in both themes, so synapse dev/preview matches a complete host. Also removed a duplicate --color-text-primary key in those maps.
  • Regression guard in tokens.test.ts: every color var a token references must be defined in both light and dark, with no dead defaults, and light defaults equal to the token fallbacks (no light-mode regression). This fails the instant someone adds a color token nothing backs.

Verification

npm run ci green — lint, typecheck, build, and 385 tests (incl. 4 new guard tests) pass. The default theme is bundled into all shipped artifacts including the IIFE widget builds.

Follow-ups (separate)

  • Make the NimbleBrain host theme exhaustive (define the three vars in both themes) — defense in depth, in the host repo.
  • Remove the "avoid bgSubtle/fgFaint/borderStrong" gotcha from the public synapse skill once this releases.

tokens.bgSubtle / fgFaint / borderStrong referenced CSS vars
(--color-background-tertiary, --color-text-tertiary,
--color-border-secondary) that no host injected, so they always
resolved to their hardcoded light fallbacks — white-on-white surfaces,
invisible borders, and illegible faint text whenever a host signaled
dark. A var() fallback is a static literal that can't branch on theme,
so the fix is a definition layer, not a smarter fallback.

- Add theme-defaults.ts: a neutral default theme backing every color var
  the token contract references, in both light and dark. Applied to :root
  beneath the host's variables via a single shared applyThemeVariables()
  path, so host (brand) values still win and any omitted var resolves to a
  theme-correct neutral default. Works against an incomplete host, a
  standalone connect() widget, or a third-party host.
- Replace the three near-duplicate inline injectors in core.ts, connect.ts,
  and <SynapseProvider> with applyThemeVariables(mode, hostVars).
- Make the preview harnesses (vite plugin host + preview server) inject the
  three vars in both themes and drop a duplicate --color-text-primary key.
- Regression guard in tokens.test.ts: every color var a token references
  must be backed in both light and dark, with no dead defaults and light
  defaults equal to the token fallbacks.
@mgoldsborough mgoldsborough added the qa-reviewed QA review completed with no critical issues label Jun 23, 2026
Address QA feedback on the token-backing test. The guard split
theme-sensitive vs theme-invariant vars by the substring "color" in the
var name, so a future theme-sensitive var whose name lacks "color" would
silently slip the "must be backed in both modes" check.

Replace the name heuristic with an explicit THEME_INVARIANT_VARS
allowlist (the radii/type-scale/shadow/font vars) and make the partition
total: every referenced var must be either declared invariant or backed
in both light and dark — anything else fails. Add a test that the
invariant allowlist carries no stale (unreferenced) entries. Also clears
the one new biome useOptionalChain warning by dropping the name-substring
branch.

No production change; test-only hardening.
@mgoldsborough mgoldsborough merged commit 4674083 into main Jun 23, 2026
5 checks passed
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

Development

Successfully merging this pull request may close these issues.

tokens.bgSubtle / fgFaint / borderStrong are unbacked — resolve to light fallbacks in every theme (dark-mode white-on-white)

1 participant