Skip to content

Feature: Theming shapes, roles, & accesibility checks - #127

Merged
jhweir merged 87 commits into
devfrom
feat/theme-shape-roles
Aug 23, 2026
Merged

Feature: Theming shapes, roles, & accesibility checks#127
jhweir merged 87 commits into
devfrom
feat/theme-shape-roles

Conversation

@jhweir

@jhweir jhweir commented Aug 23, 2026

Copy link
Copy Markdown
Collaborator

Theming: the shape vocabulary, roles that actually paint the app, and contrast the system checks for you

Summary

One problem, worked through in eleven rounds. The first closed gaps in the theme vocabulary — avatars
that ignored the shape presets, media no theme could round, two density slots wired at the component
end and dead at the other, one font family for a scale that ships three. The second made roles
actually paint things. The third fixed what the second under-shot and replaced the picker that made
role editing opt out of the theme system by default. The fourth renamed the roles that were named
wrongly, and gave themes a schema version so it could. The fifth made the system check its own work:
contrast is now a test, and a foreground the author did not pin is derived rather than guessed. The
sixth is what the first real session of using the editor turned up — a whole class of surfaces
migrated to the wrong role, and, underneath it, three shipped dark themes whose elevation had been
inverted since the day they were written. The seventh replaced the fix for that with a rule, so the
stack is derived rather than stated. The eighth moved the ramp itself to OKLCH, which is what makes
one step mean one lightness at every hue. The ninth was a full architectural audit of the control
surface, which turned up four gaps and three bad conventions — and, once contrast was measured with
something that works in the dark, two shipped bugs. The tenth stopped reasoning about the colours and
measured them against the build this replaces, which found that the measure-and-correct layer the
fifth round introduced had never once run. The eleventh came from asking why a feature that had been
built was never seen, and found the answer was enforcement: the vocabulary had been right since round
two, and 197 nodes had quietly drifted back to scale positions anyway, almost all of them from four
shared defaults — one of which was a line of guidance that every template written from it had duly
copied. Pulling on the same thread found the chroma taper had been two models all along, and that the
browser had only ever run one of them.

Where it started: templates used scale positions over roles 88 to 2, and the ~50 role references
that existed were inside picker and modal internals. Changing "Surface" in the editor moved a date
picker's popup and left the page it sat on alone.

Where it ends: zero scale positions remain in templates, chrome, modules, primitives or
components except where a colour is genuinely a palette — and a test now says so on every commit,
by importing each schema and walking the real composed tree rather than grepping source, which is the
difference between finding 42 and finding all 197. Every built-in theme now renders within
2.2 lightness points of what it rendered before this branch, measured step by step in a browser
on both builds. On the default template, pinning every
role repaints 85–92% of painting backgrounds and 84–93% of text route by route — the remainder
being Cesium's globe attribution, CodeMirror's syntax tokens, and data-driven colours that should not
follow a theme. Every built-in theme clears WCAG AA on the nine pairs the vocabulary declares, and a
test says so on every commit.

Part one — the shape vocabulary

avatarRadius is its own group. Avatars are the only components guaranteed square, and that is
what makes a percentage radius safe: 50% resolves per-axis, so it is a circle on a square box and
an ellipse on a 16:9 video. Circle is offered on that row and nowhere else, for the same reason.
Three hardcoded radii would have defeated it — avatar's [part='img'] pinned a second 50% (winning
for exactly the avatars that have a picture, the reported symptom), we-video painted over
[part='base'], and we-iframe had no base at all, so every visual prop it accepted was discarded.

Media joins surfaceRadius rather than taking a fifth group. r: 'avatar' / r: 'media' resolve
to the group variables for layer-4 components, where there is no cascade entry to inherit from.

Two dead density slots. inputSpacing had five components declaring it and no theme key able to
set it. Tabs got the we-button treatment — own vertical, control-group horizontal — because
controlPaddingX means "breathing room either side of a label" and buttons apply it with a hard 0
vertical precisely because they have a fixed height to sit in.

A heading font and a mono font, separate from body, resolved at the use site so a scoped theme
changing its body face brings its headings with it.

Part two — roles

Seven roles were missing, each found by asking why something reached past the vocabulary.
accentStrong (the accent at text contrast — 28 sites wrote primary-600/700 because accent is
sized for a fill), dangerText/successText/warningText (status as a foreground; the status roles
covered a tinted panel only, and danger text appeared as danger-400, -500 and -600 in neighbouring
files), accentHover/accentActive, and surfaceInverse.

The migration covers templates, kit, app-shell and editor chrome, the modules, all primitives,
layer-4 components and the block renderer. Three parts needed judgement: a background inside
hoverProps is a state, not an elevation; a graph's node colours are a categorical palette (a node
painted warning-100 because it is a note is not a warning); and neutral-50 means the page at a
root, a floating panel where a shadow says it floats, and a recessed well where a radius says it is a
box — classified by enclosing object, not by line.

The inherited text colour was the single biggest lever, and index.scss already knew it: the
line carried a comment saying the role "belongs to the role migration proper".

Part three — what round two got wrong, and the picker

The value-identical rule was the wrong rule. Migrating primitives only where the role's default
matched the value exactly protected appearance and systematically missed foregrounds, because the
text roles sit at neutral-900/500/400 while primitives use 600/700/800/1000. Nav labels are bare
we-text inside a ghost button, so they inherited the button's unmigrated colour and pinning text
moved 25 of 52 text elements. It also left components half-migrated — alert.neutral read
{ bg: 'page', color: 'neutral-800' }, a pair that is no longer a pair. The semantic pass takes the
shade shifts deliberately and documents the two non-migrations: status fills (the danger button)
and palettes.

The secondary button needed a three-rung ladder the vocabulary has one rung of. Rather than
invent roles nobody would pin, its hover and pressed states mix the rest state toward text — which
reproduces the old neutral-300/400 to within a point and inverts correctly in a dark theme because
text does. isRawCSSValue learned about color-mix() to allow it.

roles now merges with a preset's instead of replacing it. Pinning one role on channels
discarded the twelve measured pins that make it that theme; it came apart from a single click.

The picker. The roles editor offered a Google-Docs swatch list of raw hexes, so every pick opted
a role out of the parametric system — the thing the conventions file tells theme authors not to do.
we-color-picker now has a token grid (emits var(--we-color-…), which still follows hue,
saturation and polarity), a saturation/value area with hue and alpha sliders, and a text field
with hex/rgb/hsl/oklch. Both new props default off, so existing callers keep what they had. The
colour maths lives in @we/design-utils because the contrast checking in part five needs the same
conversions.

The picker's own first outing found five of its bugs, two sharing a root: part="swatch token"
does not match [part='swatch'], so the token grid had no styling and fell back to the user agent's
button; and background: <colour>, <image> is invalid CSS, so the trigger swatch painted nothing.
A test now guards the selector class, since it fails invisibly by construction. The popover closes
on an outside click and on Escape, the format control is a segmented button group rather than a
native <select> (whose popup takes the platform's colours, not the theme's), and there is an
eyedropper on the platforms that have EyeDropper.

Part four — the names, and a version that lets them change

Three roles were named against the grain of the rest of the table, and one pair was actively
misleading: accentText meant the foreground you put on an accent fill, while accentStrong meant
the accent used as text. Somebody reading the table would pick exactly the wrong one. They are now
onAccent and accentText, with textInverse becoming onInverse to match — a bare noun is a fill
or a foreground in its own right, on<Fill> is a foreground that exists to contrast with a specific
fill. controlSurface was added for the filled neutral of a control (a switch track, a progress
trough, a count chip), which is neither a surface nor a state and was reaching for neutral-200.

A rename is only safe because themes are portable user data — published to a marketplace, stored
in a space, sent as a link. An unknown role key is silently ignored, so without migration a theme
published last week keeps looking almost right while one relationship quietly stops being honoured;
nobody reports that, they conclude the theme was never very good. schemaVersion and
parseOverrides() now sit on every read path. The rename map is applied in one pass because it
is a permutation — applying it sequentially would move accentText's value into its own new slot and
lose one of the two.

Three preferences the OS was already expressing are now honoured: prefers-color-scheme (a
"System" entry in the theme picker that re-resolves when the OS flips, rather than a light theme
somebody has to notice and change), prefers-reduced-motion (the transition tokens collapse to
0ms, so every component that animates through a token stops at once), and forced-colors.

Part five — contrast, and the pins that survive a change

Contrast is now a test. Nothing checked it before, so a theme could ship with body text a shade
off its own page and the only signal was somebody squinting. It is checkable at all because the
roles name their pairings
onAccent is defined as the thing that sits on accent — so both sides
of each pair are known rather than inferred by crawling a rendered page. And a theme is four numbers
and a lightness ramp, so the same arithmetic the CSS does gives the colour without a browser: it runs
as a unit test on every commit rather than as a screenshot somebody remembers to take.

It found eleven failures across the shipped built-in themes on its first run — muted text at 4.2:1,
success text at 3.6:1, warning text at 2.9:1 — all now fixed by moving the role defaults, not by
loosening the check.

A foreground the author never pinned is derived rather than guessed. applyAutoContrast picks
between the light and dark candidates by measuring, and it measures against every fill the
foreground will sit on — a label chosen against a button's rest state alone can go unreadable halfway
through a click, when the fill darkens under the pointer. So the weakest pairing decides.

A relative pincolor-mix(in srgb, var(--we-role-surface) 88%, var(--we-role-text)) — is the
one form that expresses a relationship rather than a value. It survives a change to the role it
references, and because it mixes toward something that inverts with the theme, "a step darker" in a
light theme becomes "a step lighter" in a dark one without being told which it is. This was possible
before and undocumented; the editor now recognises the form and names it, so it takes its place on
the ladder between a lightness pin and a literal.

oklch() is accepted and offered as a fourth format. Worth having even while the ramps are still
HSL, and not because it is newer: OKLCH lightness is perceptual, so equal L reads as equal brightness
across hues where HSL's 50% is far brighter for yellow than for blue. A front door, not a new
internal representation — everything downstream still speaks sRGB.

The roles editor names the rung each role is on (auto → a token by name → a theme tint → relative
to another role → a literal), warns when a pinned pair falls below AA, and renders a preview strip
— a small gallery painted under the theme being edited, so the roles that only appear in a state or
on one screen are visible while they are being chosen. The visual inspector now offers roles first
and the scale second, labelled for what each is ("follow the theme" vs "a fixed colour, for a
palette"), with a menu item that jumps from a role in the inspector to that role in the theme editor.

Part six — what the first real session with it found

Three things, from using the editor rather than reasoning about it.

Cards and page headers followed "Sunken". They should follow "Surface", and the cause was the
value-faithful migration again in a new guise: the pre-role templates painted a card and a well with
the same neutral-100, because against a neutral-50 page that grey looked right either way. So
the migration preserved every appearance and got a good share of the meanings wrong, and nothing
showed it until somebody dragged a slider and watched the page header move.

The discriminator is what a thing sits on, which the source cannot answer — cardShell and
sectionCard are contributed from another package. So pnpm --filter @we/schema-shared surface-audit
imports each schema, walks the real composed tree, and reports the nearest painting ancestor of
every sunken node: a node whose parent is page is an object on the page, and therefore a surface.
It found 389, fixed at the fragments and named containers rather than one node at a time. The Tasks
column deliberately stays sunken — a trough holding surfaces is exactly what a kanban column is, and
its comment complaining that the colour was "nearly invisible against the page" was only true while
the cards in it were the same colour.

Which exposed that the dark themes had been upside down all along. page is neutral-50 and
surface is neutral-0, so the inversion flips their order: in dark a card lands below the page
and a sunken well lands above both. It never showed because the cards were painted with the well,
which inverts to lighter and looked right by accident. The ordering is now an invariant in
contrast.test.ts — sunken ≤ surface ≤ raised, and a card never below its page — which failed on
dark, black and cyberpunk; all three now pin their stacks, and the editor's Light/Dark buttons
do the same so a hand-flipped theme does not inherit the bug the presets just escaped. Equal is
allowed: channels shares one colour between page and card and separates with borders, which is a
design.

The preview moved to the top of the panel, sticky and collapsible, instead of living inside the
Roles section. A hue slider, a radius slider and a shadow toggle all land in that same small gallery,
and a preview you have scrolled past is not a preview.

The inspector now says which roles paint the selected node — in the header, including inherited
ones. That was the missing half of the bridge: the swatch pickers could only answer for a node that
names a colour in its own props, which is the minority, since text almost never sets color. It
walks the schema up, marks what was inherited and from where, and jumps to that role in the theme
editor.

And @we/editor's tests were not running. Its vitest config included tests/** only, so
ThemePanel.test.ts — beside the module it covers, this repo's usual placement — had never once
executed, while reporting as a passing suite. Fixing the glob revealed the panels are not importable
under vitest at all (a .tsx pulling in @we/components/solid runs a client-only API at module
scope), so the pure logic moved into themeRoles.ts and paintedRoles.ts. 41 editor tests now run
where 16 did.

Part seven — the stack stops being a table and becomes a rule

Three dark themes were each stating four literal lightnesses to keep their cards above their pages.
Two of those stacks were written by hand in part six, and the numbers came out within a point of
each other every time — which is the tell that it wanted to be a rule.

surface: oklch(from var(--we-role-page) calc(l + 0.045) c h);
surfaceraised: oklch(from var(--we-role-page) calc(l + 0.1) c h);
surfacesunken: oklch(from var(--we-role-page) calc(l - 0.035) c h);

Checked against every shipped theme before it was adopted: light 100/100/90.4 against 100/100/90
pinned, dark 14.1/19.5/6.9 against 14/19/8, cyberpunk 15.2/20.6/7.9 against 15/21/8. So
dark and cyberpunk drop their stacks, and a theme built from scratch is correct with no pins
at all
— the bug class is retired rather than wired around.

Why OKLCH here and not in the ramp. HSL lightness is a coordinate, not a brightness: the same
five points is 3.6 L* near black and 6.3 L* in the mid-dark, so no constant serves themes sitting
at different places on the curve. color-mix cannot do this job either — it interpolates between
two colours
, so a fixed percentage moves by a share of the distance remaining, which is less even
than the HSL it would replace. calc(l + n) in OKLCH moves by a fixed perceptual amount. It also
carries c and h through, so a tinted theme gets a tinted stack for free — verified in Chrome:
page rgb(38,20,46) → surface rgb(49,31,58), still purple, which the scale positions never did.

Two themes still pin, both with a reason. black sits at the sRGB floor, where a +0.045 step
and the page round to the same 8-bit value — no formula survives that. channels wants its page and
cards identical, separated by borders. The invariant permits equality precisely so it can say so.

An @supports block returns the scale positions where relative colour syntax is missing, and
applyThemeVars repeats the feature test because an inline custom property outranks the stylesheet
that would otherwise supply it.

Part eight — the ramp itself

The last quirk in the foundation, and the one most likely to bite a theme author. HSL lightness is a
coordinate, not a brightness: the same nominal 500 measured L* 46 for blue and L* 69 for green,
and the hue slider spanned 39 L* points end to end. Changing a hue silently changed how heavy
the accent read, with no way to know but to look.

The evidence was already in the file. dangerText, warningText and successText sat at -600,
-700 and -800 — three steps for one concept, because green and amber are lighter than red at the
same number. They share -700 now. That is what the change buys, and it is the same shape as every
other fix on this branch: a hand-tuned constant replaced by a property that holds.

The scale is anchored, not invented. Each step is the OKLCH lightness of the sRGB colour the old
HSL step produced, so greys, text and surfaces land exactly where they were and only the chromatic
families move. chromaTaper reproduces what HSL's saturation × min(l, 1−l) did implicitly, since
OKLCH chroma is absolute and pale steps would otherwise sit outside sRGB. The
CHROMA_PER_SATURATION constant is calibrated: at saturation 50 it puts step 500 at chroma 0.140
where the old ramp measured 0.141.

Two role resteps fell out of it. accent moves 500 → 600, because on a perceptual scale the step
that carries a near-white label is a specific lightness and 500 is not it (a primary button measured
3.34:1). And the three status foregrounds collapse to one step.

Two stored values changed meaning and are converted rather than renamed: a hue is an angle in a
particular space and the two disagree by up to 45° in the warm end (220 → 263, 45 → 90), and
saturation stopped being a percentage string because calc() cannot divide a percentage into the
unitless number a chroma has to be. migrate.ts v3 handles both.

Four silent bugs surfaced on the way, each worth recording:

  • A chroma ceiling was needed. channels carries saturation: 85 — ordinary under HSL, where
    saturation is relative. Multiplied out it asked for chroma 0.30, and a blue at 0.30 gamut-maps to
    the magenta edge. The entire accent turned pink.
  • The presets never declared their schema version, so parseOverrides read them as v1 and ran
    every migration over them — converting an already-converted hue a second time, 266 → 299.
  • themeToStyle re-declares the palette formulas for scoped themes and was still emitting HSL,
    quietly overriding the generated OKLCH at runtime.
  • Hand-measured lightness pins in channels, timeline and the fixed roles were HSL percentages
    being read as OKLCH ones, and far darker as a result. All 27 converted numerically.

Part nine — the audit, and what it found

A zoom-out over the whole system rather than the colour layer alone. Seven findings, all of them
ThemeOverrides changes and therefore cheapest before anyone has authored a theme.

The parametric model was the weakest part. multiplier was 1 or -1 in every preset — a
boolean typed as a number — and subtractor: '112%' meant "reflect the ramp and offset it", which
is not a thing anybody can picture. It is a floor at 12%, and the only way to find that out was to
drag it and look, which is exactly what part six did. A theme now states polarity,
lightnessFloor and lightnessCeiling. Same arithmetic, exact conversion, and the two bounds turn
out to be the contrast control the system had by accident and could not reach.

Status colours had no fill. accent had six slots; danger had two. A theme could completely
restyle the primary button and could say nothing about the destructive one, which was hardcoded to
danger-500. There are danger/success/warning fills now, with one derived onStatus between
them rather than three on* roles.

Which found a live bug: the danger button was a near-white label on a scale step, and a scale
step inverts — so in a dark theme that fill lands light and the label sat on it at about 2:1.
Nothing caught it because no contrast pair named the button.

And a second one. Modelling the derivation in the test rather than the declared default — which
is what actually renders — put black and channels at 3.83:1 and 4.10:1 on the primary button.
Their accent sat near 50% lightness, where no label reads at either end.

Four smaller gaps closed: stroke weight is themeable (radius had four groups, stroke none);
spacing scales independently of type (fontScale moved both, since the steps are in rem);
disabledOpacity replaces a hardcoded 0.5 — as a control, not the role I first proposed, because
one colour cannot serve a disabled ghost button and a disabled danger one; and ringColor is gone,
having been a second way to say the focus role with no stated precedence.

Saturation now means the same thing at every hue. The flat chroma ceiling from part eight was
the same bug as the HSL ramp, one axis over: sRGB holds 0.259 of chroma for a violet at mid
lightness and 0.103 for a teal, so saturation: 100 gave the violet 70% of its range and stopped
affecting the teal at about 29. applyThemeVars measures the sRGB boundary per hue and publishes a
ceiling the ramp scales against.

Fills are derived too, not only foregrounds. A filled control near the middle of its theme's
ramp has no readable label — both candidates are equidistant from it — and where the middle falls
depends on the range. Two things had to be true for the derivation to work, neither obvious: the
hover and pressed states had to become steps from the fill rather than independent scale
positions (the label is chosen against the worst of the three, and black's pressed state at 78.5%
was dragging the whole choice to Lc 35), and chroma has to be clamped as the fill moves, because
carrying it toward white clips and a clipped colour's luminance barely moves.

A final sweep regularised what was left: every fill family has exactly five roles (danger had
grown a hover and an active that success and warning lacked), the focus ring's width became
themeable now that a border's is, and three controls moved to the heading they belong under.

Modules can theme their own components. COMPONENT_CASCADE was a closed literal, so a feature
module could read theme variables and not contribute one — it could borrow a core group whose
meaning did not fit, or hardcode. That cut against the premise that modules raise the ceiling on
what templates can express. registerComponentCascade opens it.

Contrast is measured twice, and the second measure changed the design. WCAG 2 adds a flat 0.05
to both sides of its ratio, which dominates the denominator against a near-black background — so
dark themes score far better than they read. This branch had already worked around that three times
without noticing. Checked with APCA, 24 pairs failed, every one in a dark theme; light themes
passed both. The fix was not more hand-picked steps but derivation: six foregrounds keep their hue
and walk their lightness away from the background until they clear, which also retired the pins
black and channels had accumulated.

Part ten — measuring against the build this replaces

The dark theme was reported as looking wrong, and the useful decision was to stop trading hints and
build a harness: capture the rendered palette and roles on both branches through the real theme
picker, and diff them. Templates are removed from the question that way — it asks whether the theme
changed, not whether something asks for a different role.

That harness misled me twice before it was trustworthy, and both are worth recording because a
harness you believe is worse than none. It selected the theme before the template, and a template
re-applies its own recommended theme — so two branches were photographed wearing the wrong theme,
agreeing perfectly, which is indistinguishable from success. And it clicked a row's label rather than
its button, which reaches the handler for most rows and silently does nothing for others. It now
verifies what it measured before recording it.

The chroma taper read the wrong end of the ramp. It was computed from a step's base lightness
rather than its rendered one, so in a dark theme the colour was inverted end for end: neutral-0
came out at 0% chroma where it should carry ~40%, and the light end at 209%. This is what made the
whole theme read as "washed out and slightly wrong" without any single element looking broken.

neutral-0 means the dark end in a dark theme. A scale position inverts, so it is the lightest
colour a light theme has and the darkest a dark theme has. Templates never had to disambiguate —
against dev's page the same grey read correctly either way — so migrating by name preserved the
light-theme reading and inverted every dark one. 55 sites, found by pairing removed and added lines
in the migration diff rather than by grepping.

The derivations had never run. This is the important one. Every one of them read a role with
getComputedStyle(root).getPropertyValue(…), which returns a custom property's token stream — for
a parametric role, a literal string beginning oklch(calc(…. parseColor returns null for it, and
each derivation hit its continue. So applyLegibleFills, applyAutoContrast,
applyLegibleForegrounds and the state direction did nothing at all, except where a role happened to
be a literal pin — which is exactly the case anybody spot-checking would have looked at.

Measured in the browser: muted text was Lc 20 on black, 27 on channels, 33 on cyberpunk,
45 on dark, against a threshold of 60. All four read 60–61 now. The contrast suite resolves those
expressions arithmetically itself, so it modelled derivations that were not running and stayed green
over every one of them. The fix is a probe element: assign the role to a real background-color and
read it back, because only a browser evaluates a var()/calc() chain and only for a typed property.

Fills were on the wrong axis. The surface stack is defined relative to the page and must invert
with the theme; a fill must not — a red is red in both. Fills defaulted to step 700, which lands at
L 42 light and L 78.6 dark, so every dark theme's accent was a pale lavender and its destructive
button pale pink, and three presets had pinned their way out of it. Step 500 is the ramp's polarity
fixed point (L 60.0 against 60.4), but one lightness for every hue is what OKLCH will not let you
fake: violet at L 0.60 is a colour and gold at L 0.60 is a stone. Each fill now states its own
lightness — where the pre-OKLCH palette already had them — with an untapered ceiling measured there.
The ramp's taper exists to converge the neutral scale on white and black, which is the one thing a
warning fill must not do.

A label was chosen by one metric and marked by two. pickReadableForeground scored with WCAG 2
while the suite grades with WCAG 2 and APCA. On a mid-tone fill they disagree outright — a red at
L 0.62 is Lc 72 under white and Lc 38 under near-black, while WCAG hands the win to near-black — so
the derivation picked the label that failed the other standard and reported a failure it had caused.
Each measurement is now normalised by its own threshold and the worse governs. Two things fell out:
onStatus split into onDanger/onSuccess/onWarning, because one label across fills at L 0.62,
0.75 and 0.76 is a compromise rather than a choice; and AUTO_CONTRAST dropped the hover and pressed
variants, which are redundant now that a state moves away from its label.

The state direction stopped being a proxy. A hover must move a fill away from the text on it.
Under scale positions that held by accident; restated as signed deltas it became "dark themes
lighten", wrong for a dark theme with a white label, then "everything deepens", wrong in the mirror.
It is now derived per fill family from the label that actually landed — which was documented as
needing a fixed point and does not: the fill settles, the label is chosen, the direction follows.

themeToStyle is now themeParametersToStyle. It returns a theme's parameters and role defaults
and nothing measured, and its name read as "turn this theme into a style you can apply". Six places
followed that reading — the document root, the scoped template wrapper, the theme editor's preview
strip, its role swatches, its colour picker, and both of the schema renderer's themed-node wrappers.
Custom properties inherit, so none of them lost the derived half; they took the ambient theme's.
A green theme inside a violet app was drawn with a violet's chroma ceilings, looking plausible and
being wrong, and only ever wrong when the two themes differed — which is precisely what a theme
editor and a scoped space theme arrange.

Three themes were still converted arithmetically. subtractor: 110% became floor 10% / ceiling
110% by arithmetic, which reads an HSL lightness as though it were an OKLCH one. dark was refitted
by hand at the time and the others were not. Mean |ΔL| across the neutral ramp against the old build:

before after
Light / Dark / Retro / Timeline 1.3 / 1.1 / 1.0 / 0.2 unchanged
Black 12.7 2.1
Cyberpunk 13.7 2.2
Channels 14.2 1.8

Fitted by least squares over the neutral steps, weighted by how much of a screen each paints, and
rejecting any ramp that clips a step to white. The script reproduces dark's hand-fitted 20% / 121%
from its measurements, which is what says the method is sound rather than merely fitting something.

black lost four pins in the process, with a convincing note attached: "its page is pure black, and
a +0.045 OKLCH step from there rounds to the same 8-bit sRGB value." True, and only true because the
floor had been mistranslated to 0%. Dev pinned nothing there. A pin written to work around a defect
is indistinguishable from a pin written to express a design
— and a unit test had grown around this
one, asserting that black pins a lightness, which was asserting the defect.

Performance, which the working derivations exposed

A write to root.style invalidates the document, so the next getComputedStyle forces a synchronous
recalculation of it. The four derivations each read a role, computed, wrote it, and read the next.

19 interleaved recalcs (as first shipped) 45.2 ms
4 recalcs, batched within each derivation 10.9 ms
1 read phase, then all writes 0.6 ms
the entire colour derivation, in JS 0.24 ms

Four fill searches, six foreground searches and nine gamut bisections cost a quarter of a millisecond
between them; everything else was the browser recomputing a 583-node document between steps. The
browser is asked exactly once now, and deriveRoleVars is pure arithmetic over what it answered.

Separately, the primary button lagged behind the text beside it during a drag —
--we-theme-switch-duration was raised on every application, so each frame re-armed a 250 ms
cross-fade and anything with an animated background spent the drag chasing. A switch and an edit are
now distinguished by the caller, which is the only place that can tell them apart.

The editor, from actually using it

  • A dock whose store arrived late could never be found again. A dock names its edge as a string
    key into hostDockStores, a plain object; if the store is not there yet the resolving memo never
    touches the accessor, registers no dependency, and cannot recover. ShellStoreProvider wraps
    EditorStoreProvider, so this was guaranteed rather than racy — the theme panel could not be
    opened at all, and opening any other panel was the only escape. Registration is observable now.
  • The colour picker opened hundreds of pixels from its trigger. Its popover is absolutely
    positioned and nothing in the component was positioned, so its containing block escaped to outside
    the scroll area: scrolled 400px, the trigger moved and the popover did not. Reproduced only after
    putting a we-scroll-area in the test, which is what the real panel uses.
  • The audit moved the controls it was reporting on. Rendered above the role rows, gaining an issue
    pushed every picker down — so dragging inside a picker's square moved it out from under the pointer,
    changing the colour, changing the list. It is on demand now, in a fixed-height box in the preview
    section, cleared on any change: a result computed against colours since edited is not stale
    information, it is wrong information.
  • The editor's panels repainted a surface the dock frame had already given them, so they read as a
    different material from every module panel at the same edge.
  • A pinned accent froze its own hue. Three presets stated their accent as a fully literal colour,
    so moving the primary-hue slider repainted the ramp, the focus ring and every accent icon and left
    the primary button exactly where it was.
  • The accent had no lightness control at all, because fills sit off the neutral ramp — right for
    polarity, and it silently froze every theme's accent at one lightness. Forking dark and dragging the
    hue to green gave #3a862d with nothing able to brighten it; accentLightness is that third axis.
  • "Follow system" stopped being listed as a built-in theme. It was the first entry of
    builtInThemes, so on a fresh account it was the most prominent row in the picker, above every
    actual theme, under a heading calling it a built-in one. It carries no parameters — it is a
    question answered at the point of use, resolving to one of the themes listed under it. Its own
    automaticThemes section now, rendered last in both the picker and the settings list. Kept in the
    picker rather than moved to settings because it is mutually exclusive with choosing a theme:
    whatever sets it has to be somewhere the picker can render as selected.
  • Derived roles now say what they follow. The elevation stack is a fixed distance from page, so
    moving the page moves all three — which reads as a limitation when the panel says only "auto". They
    read "follows Page" now, parsed out of the role defaults so it cannot drift.

Part eleven — the enforcement half, found by asking why a feature felt absent

The vocabulary was right from part two and the picker leads with roles from part six, but nothing
made the wrong choice hard: bg="neutral-100" typechecks, validates, renders, and looks correct in
whichever theme it was written in. Discipline was the only thing between the migration and its own
slow reversal.

It had already started reversing. 197 nodes across the composed templates painted with a scale
position where a role was meant
— a number the obvious search does not find, because it reports 42.
The difference is that a statChip() contributed by @we/schema-kit is invisible to any grep over
the route that renders it, so the audit imports each schema and walks the real composed tree, the
way surface-audit does.

They were not 197 decisions. Four defaults:

Source Was Now Count
statChip's icon neutral-600 text-muted 126
attributeRow's icon primary-600 accent-text 24
the rail's selected row neutral-100 / primary-600 accent-muted / accent-text
guidance for a gate prompt's icon neutral-300 text-faint 47

The last one is the instructive one: a line in the authoring docs recommended a scale position, and
every gate prompt in the repo had duly copied it. Fixing the templates without fixing the sentence
would have left the defect generating itself.

statChip is the one worth looking at twice — its icon was neutral-600 while the label an inch to
its right was already text-muted, so the two disagreed in every theme, and nobody noticed because
in the theme it was written in they happen to land close together.

The audit runs in ~2s, so unlike surface-audit it is a test rather than a script somebody
remembers to run.

What a scale position actually is, since the editor was saying it wrong

The inspector's role readout told you a scale position "does not follow the theme", and the first
person to read it caught that as false. It follows the theme perfectly well — it is computed from the
neutral hue, the saturation, the floor and ceiling and the polarity, so it moves when any of those
move and it inverts with the ramp.

What it does not follow is what the theme decides. A theme pins roles, not steps. channels sets
its surface equal to its page; timeline sets its to pure white; a node naming a step hears neither:

              neutral-100        role surface       role page
Light        [230,230,234]      [255,255,255]     [241,241,244]
Channels     [  7,  8, 11]      [ 26, 28, 33]     [ 26, 28, 33]
Black        [  1,  1,  3]      [ 12, 11, 22]     [  0,  0,  0]
Timeline     [228,230,233]      [255,255,255]     [255,255,255]

In Channels a card painted neutral-100 is a dark hole punched in a design whose premise is that the
card and the page are one sheet.

The quieter half was not said at all, and it is the half that bites text: the measure-and-correct
pass operates on roles, so a label coloured neutral-600 is never measured against what is behind
it, never walked toward legibility, and never appears in the theme editor's audit either.

The chroma taper was two models, and only one of them rendered

chromaTaper took a step name, read the raw lightness table, and carried a comment saying the taper
"must not follow the theme's inversion". The generated CSS has never agreed — it computes
2 * max(0, min(L, 1 - L)) from --we-color-lightness-<step>, the post-ramp value, so it
follows the ramp exactly.

They agree wherever the ramp is the identity, which is light and retro, and that is why it
survived. On dark's step 200 they are 2.3× apart:

Chrome                          rgb(59, 57, 82)
post-ramp taper (the CSS)       rgb(59, 57, 82)   ← exact
raw-step taper (chromaTaper)    rgb(59, 59, 70)   ← 12 out on blue

So every scale position the contrast suite resolved in a dark theme was measured against a colour
the browser does not paint, for the whole of this branch, with 397 tests green. It never flipped a
verdict — chroma barely moves either metric, both being lightness-dominated — which is exactly why
it lasted, and is no reason for the suite that grades themes to model the wrong colour.

chromaTaper now takes a resolved lightness rather than a step name, which makes the mistake
unspellable: a caller has to have applied the ramp before it can ask. The primary gradient built its
taper in JS too and now writes the same CSS expression as the ramp does — at step 500 the difference
is 0.800 against 0.792, and it is changed anyway, because a number that happens to be nearly right is
how this went unnoticed at 200.

Fifteen recorded browser measurements now pin the resolver — three steps across five themes,
sampled from Chrome. Everything else in that suite grades from resolve(), so nothing internal
disagrees with a resolver that is wrong; these are the only assertions an internal change cannot
satisfy by moving both sides at once. Mutation-checked: a broken taper fails all fifteen.

One process note worth keeping. The first attempt at those measurements used numbers captured earlier
in the branch and reported black, cyberpunk and channels as badly wrong. They were not — those
three were refit since, and the data was stale. Ground truth has to be re-read, not remembered.

A text element that reported no text colour

Selecting the author name on a card showed background: surface and nothing else. The background was
right — it comes from the Card, and it is what the text's contrast is measured against — but the
foreground, the one thing being looked for, was missing.

index.scss sets body { color: var(--we-role-text) }, and most text in the app says nothing about
its own colour, so it is painted by the document. The readout walks the schema and only the schema,
found no color in the chain, and reported nothing — indistinguishable from "this element has no
text colour". The timestamp beside it looked correct only because its node happens to set text-muted
explicitly.

Text now always reports, falling back to the document's role and marked as inherited. A background
still reports nothing when nothing above paints one, and that asymmetry is deliberate: there is no
document-level background rule to fall back to, and claiming page would be a guess about a root the
fragment cannot see. The caveat the readout cannot see is recorded on the field — a component between
the node and the document may paint its own colour in its shadow root, as a ghost button does — so
it says where the template leaves it, not the final answer.

The chips also carry which of the three they are. They were a swatch and a role name, so two of them
read as two colours with no way to tell the background from the foreground without hovering each,
which is most of what made a background on a text element look like a mistake rather than context.

And four editor bugs the same investigation turned up

Chasing why the role readout felt absent — it had been built, and never seen — found that it was
fine and its surroundings were not:

  • A fresh template was inert to every click. node.id is what the renderer stamps as
    data-we-node-id, and it is the whole handle the visual editor has. Six setters wrote the live
    template and three had forgotten to call ensureNodeIds; saveTemplateAs was the one that
    mattered, being how both "Start fresh" and "Fork" arrive. A tree with no ids renders identically
    and cannot be selected at all, so it did not read as a missing id, it read as the visual editor
    being broken. All six now route through one commitTemplate.
  • The inspector painted its own background, surface over the dock frame's surface-sunken, so
    it sat lighter than every neighbouring panel. The code and theme panels had each been corrected
    before, separately — three reports of one drift.
  • Three spacing props that did nothing, all in the readout strip itself: gap="150" twice and
    px="150" once resolve to var(--we-space-150), which does not exist, so the chips sat flush with
    no padding. They were the only three in the repo.
  • The role chip's jump lost the role. Its tooltip promises "click to edit it for the whole theme"
    and then landed you at the top of a panel holding forty roles across five collapsed groups. It now
    opens the group, scrolls the row to centre and rings it — opening the group being part of the fix,
    since a collapsed section has no row to scroll to.

Each of the first three is invisible by construction, and each is now guarded by a test that fails on
a reintroduction.


Verification, the bugs found along the way, and the follow-ups are in the first comment on this
PR
— they run to another 20k characters and GitHub caps a description at 65,536.

jhweir and others added 30 commits August 21, 2026 23:23
Toggling Sharp / Default / Rounded / Pill moved every control, surface and
input and left avatars perfectly circular, because `we-avatar` was registered
with a `radiusDefault` and no `radiusGroup` — so the emitted CSS was
`var(--we-avatar-radius, 50%)` and no theme variable existed to reach it.

Avatars get their own group rather than joining an existing one. They are the
only components guaranteed square (width and height both come from
`--we-avatar-size`), and that guarantee is what makes a *percentage* radius
safe: 50% resolves per-axis, so it is a circle on a square box and an ellipse
on anything else. Sharing a value with surfaces would mean a theme rounding
its avatars also turned every 16:9 video into an ellipse. For the same reason
Circle is offered on the avatar row only, not in the shared radius list.

`[part='img']` hardcoded a second 50%, which would have won for exactly the
avatars that have a picture — a theme asking for rounded squares would have
got them everywhere except where it was most visible. It inherits now.

Only the Sharp preset sets the new key: Rounded and Pill describe a corner
treatment and an avatar is already as round as a corner gets, so squaring one
off there would surprise. AvatarStack's "+N" chip follows the same variable —
it sits in the row as one of the faces, and a lone circle at the end of a row
of squircles reads as a bug.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
we-image, we-video and we-iframe declared no radius at all, so they were
square in every theme and no theme could say otherwise — "soft corners on the
images" meant editing each template by hand.

They join the *surface* group rather than getting one of their own. A theme
that rounds its panels to 16px wants its photos at 16px; they are one visual
language, and a fifth group would put a row in every theme editor for a
distinction ("sharp panels, soft photos") nobody has asked for. Explicit '0'
defaults because these three have no radius in DEFAULT_PROPS to auto-derive
from, and a cascade group without a fallback resets border-radius outright.

Two of them could not have shown a radius even so. we-video paints its <video>
over [part='base'], and we-iframe rendered a bare <iframe> with no base at all
— the DS stylesheet only ever targets :host and [part='base'], so every visual
prop the element accepted was silently discarded. Both now inherit the radius
onto the inner element, which is the fix we-video's own comment already
described for background and border.

For the layer-4 components there is no cascade entry to inherit from, so the
call site has to say which kind of thing it is: `r: 'avatar'` and `r: 'media'`
resolve to the group variables, mirroring how a colour prop may name a role
instead of a scale position. EditableImage is why they exist — one component
serving both square profile pictures and wide cover images, where only the
caller knows which. The six profile pictures across the shell and default
templates now say `avatar` instead of pinning full/pill/300, so they follow
the theme and match `we-avatar` besides; the profile page's picture changes
from a 6px squircle to a circle, which is what every other avatar in the app
already looked like.

Cover images stay square except the one inset inside the create-space modal.
Rounding a page-width banner is a template's decision to make, not a theme's.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Two cascade slots were wired at the component end and dead at the other: no
theme key mapped to --we-theme-input-spacing or --we-theme-tab-spacing, so
nothing could ever set them.

inputSpacing is the one worth having. Five components declare it as their
padding group and textarea hand-writes the same fallback, so inputs had a
themeable radius and un-themeable padding while controls and surfaces had
both — which is what made the Spacing & Density section read as arbitrary. It
takes a full shorthand rather than an x-only value like controlPaddingX,
because textarea is in this group and has no fixed height to supply the
vertical from; the panel offers pairs for the same reason.

Tabs get the we-button treatment instead of a key of their own: vertical from
their own default, horizontal from the control group. Pointing their whole
padding at --we-theme-control-padding-x would have been settable and wrong —
that key means "breathing room either side of a control's label", and buttons
apply it with a hard 0 vertical precisely because they have a fixed height to
sit in. A tab has none, so a spacious theme would have tripled its height.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
The token scale ships three families and a theme had one slot for them, so
"DM Sans for reading, Boldonse for headlines" — the most ordinary typographic
move there is — was reachable only by hand-writing a theme's CSS.

The four heading variants of we-text now read --we-theme-heading-font-family,
falling back to the body face at the *use site* rather than by defaulting the
variable at :root. Those look equivalent and are not: a custom property
containing var() is substituted where it is declared, so a :root default would
bake in the document's font and a space-scoped theme changing --we-font-family
would move its body text while its headings stayed behind.

Heading variants only. `subheading` sits inline among body text at 18px, where
a second face reads as a mistake rather than a decision.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
ThemeOverrides has offered 21 semantic role slots for a while and the theme
editor referenced them zero times. Roles are the difference between a theme
that recolours and a theme that is designed — the lightness scale steps
evenly, so no combination of hue, saturation, multiplier and subtractor can
say "raised surfaces are lighter than the page by 6 but the rail is darker by
3.5". Both reference themes reach for roles to say exactly that, and until now
the only way for anyone else to do it was hand-writing the theme's CSS.

The Color section gains a Roles group: 21 swatches in six clusters, each
showing the colour actually in effect and marked "auto" until pinned, with a
reset back to the parametric default.

Two things it has to get right that are not obvious:

Sampling. The panel is editor chrome and the theme being edited is usually not
applied to it — a space theme is scoped to the space's own content by default
— so reading --we-role-* from the panel or from documentElement would report
the *personal* theme's colours and show a swatch from the wrong theme. It
samples a hidden element carrying the edited overrides instead.

Alpha. The scrim's default is 60% transparent and <input type="color"> cannot
express alpha, so picking a colour for it would have returned an opaque one —
not a slightly-wrong scrim but a solid sheet over the app. Whatever alpha the
role already had is preserved.

Sampling also exposed a real defect in scoped theming: a custom property
containing var() is substituted where it is *declared*, and the tokens CSS
declares the role defaults at :root. A scoped theme could redeclare every
colour token on its wrapper and its unpinned roles would still paint from the
personal theme's scale — the same hazard TemplateLayout already patches by
hand for `color`. themeToStyle now re-declares the role defaults alongside the
colour formulas it already re-declares, which fixes that and is what makes the
editor's swatches correct for free.

That puts role defaults into inline styles, which outrank an attribute
selector — so a theme declaring a role only in its CSS file would now be
overwritten by its own default. Dark is the live case and it already mirrors
the declaration in presets.ts; a test now enforces that nothing drifts.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Templates are authored against this reference, so a value the schema accepts
and the docs do not mention is a value nobody will use. Includes the trap
worth stating once: "full" is 50%, which is a circle on a square box and an
ellipse on everything else.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
The panel sits behind an editor-host context and a dock, so mounting it for a
test costs more than it proves. These two are worth pinning down because being
wrong is invisible until it is embarrassing: a scrim that has quietly stopped
being transparent, and a theme that reports itself as pinning roles forever
after the last one was reset.

Both were inline in a JSX handler; pulling them out is what makes them
reachable, and reads better at the call site besides.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Four roles the vocabulary was missing, each found by asking why a template had
reached past it for a scale position.

`accentStrong` is the accent at *text* contrast. `accent` is sized for a fill,
where the text sits on top and `accentText` supplies the contrast; used as a
foreground on an ordinary surface that value is often too light to read, which
is why 28 sites across the shell and default templates wrote primary-600 or
-700 instead. A theme with a pale accent must move this further than `accent`
to stay legible, and one with a dark accent may set them equal — only a second
role can say either.

`dangerText` / `successText` / `warningText` are status as a foreground. The
status roles covered a tinted panel and nothing else, so every status message
in the repo used a scale position, and they disagreed with each other: danger
text appears as danger-400, -500 and -600 in neighbouring files today. The
split also lets a theme keep a status legible against its own tint, where the
two have to move in opposite directions as it darkens.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
The roles editor did nothing visible because almost nothing painted with
roles. Templates used scale positions 88 backgrounds to 2 — and the ~50 role
references that existed were inside picker and modal internals, where nobody
looks. Changing "Surface" in the editor moved a date picker's popup and left
the page it sat on alone.

This migrates 77 files across the shell, default and showcase templates, the
template kit, the app shell and editor chrome, and the call, notes and
transcribe modules: ~690 call sites, from scale positions to the role that
says what the colour is *for*.

The mapping is mostly mechanical — neutral-0 is a surface, neutral-50 the
page, neutral-500 muted text — but three parts of it are not, and a
find-and-replace would have got each one wrong:

**A background inside hoverProps is a state, not an elevation.** neutral-100
there means surfaceHover; the same value on a static panel means
surfaceSunken.

**A graph's node and edge colours are a categorical palette, not the UI's
semantics.** A node painted warning-100 because it is a note is not a warning,
so nodeStyle/edgeStyle rules keep their scale positions. That is every colour
left un-migrated in the repo, deliberately.

**neutral-50 means two different things.** It is the page at a route's root
and a recessed well when it is a box inside a card — same pixel value, three
different roles once you ask what it is doing. Those were classified by their
enclosing object: a shadow means it floats (surfaceRaised — the chrome rail,
the editing bar, the call bar), a radius means it is a box (surfaceSunken),
neither means it really is the page. The shell sidebar stays `page`, which is
what its own comment already said it was for.

Also corrected along the way: text on an accent fill was reaching for
neutral-0, which maps to textInverse — right for a tooltip, wrong here, where
accentText is the role that exists for exactly it.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
The reference told authors the opposite of what the repo now does — "always
set bg: 'neutral-50' on root nodes", ColorValue described as a scale position,
and every copy-paste pattern painted with neutral-400. Templates are authored
against this file, by people and by the assistant, so it was the thing
reproducing the problem.

Adds the role table to the Design System Props reference, with what each role
is *for* rather than which grey it resolves to, and the two traps worth
naming: textInverse is for a tooltip and accentText for text on an accent
fill; accentStrong is the accent when it has to be read on an ordinary
surface.

The conventions files get the reasoning rather than the list. Tokens: why a
role exists at all — some relationships invert between light and dark and a
scale position cannot express that, because the ramp flips together — and the
test for whether a proposed role is one ("if the answer is only 'a slightly
different grey', it is a scale position"). Kit: a scale position in a fragment
is a theme-independence bug reproduced at every call site at once. Themes:
pin roles in the preset rather than the CSS file, and pin lightness rather
than a hex so hue and saturation stay parametric.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
The migration was silently inert. `tokenVar` recognises roles kebab-cased —
that is the CSS custom property's name — while `ThemeRole` keys are camelCase,
and I wrote the camelCase everywhere. `surfaceSunken` emits
`var(--we-color-surfaceSunken)`, a variable that does not exist, so the
browser drops the declaration and the element paints nothing at all. Typecheck
passed, validation passed, the screen looked plausible, and roughly 690 call
sites did nothing.

Two spellings for one thing is not a mistake to fix once. So besides
correcting them, the validator now rejects a role in its TypeScript spelling
and names the right one — on `bg`, `color`, border shorthands, and behind
`$if`, where a colour just as often lives. Only when the spellings differ:
`page` and `surface` read the same either way and must not be flagged.

Verified in a browser rather than by reading: pinning `--we-role-*` at the
document root now repaints the templates, where before only the seven
single-word roles moved anything.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Templates painting with roles moved 3% of the pixels on screen, because most
of what you look at is drawn by primitives and by inheritance, and both were
still on the scale.

**Primitives**, on a strict rule: migrate only where the role's default is the
*same value* as the scale position being replaced, so appearance is provably
unchanged and the only thing gained is that a theme can reach it. That covers
40 call sites across button, badge, tag, alert, tab, input, menu-item, code,
skeleton and progress-bar. Everything needing a shade shift — the neutral-600
/700/800 foregrounds, the secondary button's 200/300/400 ladder, the danger
button's fill — is deliberately left alone rather than restyled in passing.

`accentHover` / `accentActive` are new, and are the same argument
`surfaceHover` / `surfaceActive` already won: a theme that pins `accent` and
leaves its states on the scale gets a button that jumps to an unrelated colour
under the pointer.

**The inherited text colour** was the single biggest lever and the file
already knew it — `html, body, #root` named a scale position with a comment
saying the role "belongs to the role migration proper". This is that. Most
text in the app sets no colour of its own, so `text` was a role a theme could
pin and watch do nothing. It does move: body text softens one step off pure
black, which is the conventional choice for body copy and the cost of the role
reaching anything. TemplateLayout's scoped-wrapper re-declaration follows it,
or a space theme pinning `text` would lose to the scale on every element that
inherits.

Two holdouts found by measuring rather than reading: the avatar disc, and the
tooltip, which hardcoded #222 and white — invisible in a dark theme, and
unreachable by any of them. Both are now the text/inverse pair those roles
exist to name.

Measured in the browser, pinning every role at the document root: backgrounds
went 41% → 97% role-driven and text 11% → 71%, with the rendered screen
unchanged.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Blocks are where a space's actual content is rendered, and their stylesheet
named scale positions — so a theme could restyle every frame around a post and
not the post. Same rule as the primitives: only where the role's default is
the value being replaced, so nothing moves and the theme simply gains reach.
The greys with no matching role (neutral-75, neutral-300) are left as they are
rather than nudged onto a nearby one.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
… editing

Two regressions from the roles work, and one of them was mine to begin with.

**Tooltips went white in dark mode.** I painted them `text` on `textInverse`,
and both are scale positions — so the pair flips with the theme, and the dark
tooltip of light mode became a white one in dark. No parametric expression
over the scale can be dark in both polarities, because the whole ramp inverts
together; that is exactly why `overlay` and `shadowColor` pin a *lightness*
rather than name a scale step.

So `surfaceInverse` joins them, and `textInverse` becomes a fixed lightness
too. That second one is a latent bug this surfaced: its doc has always said
"text on inverted surfaces (e.g. tooltips)", and as `neutral-0` it turned dark
in a dark theme — inverting along with the surface it exists to contrast
against. Hue and saturation stay parametric, so both still follow a theme's
neutral tint, and a theme can now move the pair together. The dark theme's
`we-tooltip::part(tooltip)` override existed only to undo the hardcoded #222
and is gone with it — which also fixes every *other* dark theme, since that
rule only ever fired for the one named `dark`.

**Switching theme while editing did nothing.** Not intentional. The picker
persists a pin and lets the recompute land in `replaceTheme`, which drops any
change while a theme is being edited — right for walking into a space
mid-edit, wrong for someone choosing a theme. `currentThemeId` never moved, so
EditorStore's effect on it never fired, the editing theme kept masking
`documentTheme`, and the picker looked stuck. `replaceTheme` is now told which
kind of change it is; an explicit one ends the session and closes the panel,
flushing any debounced edit on the way out.

Verified in a browser both ways: the tooltip is dark-on-light-text in both
polarities, and picking Cyberpunk mid-edit now repaints (with the fix reverted
it stays on the edited theme, which is the reported bug).

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Pinning one role on a theme whose preset pins its own threw the rest away —
`{...preset, ...overrides}` is a shallow spread, so a user `roles` object
replaced the preset's wholesale. Editing the accent on `channels` dropped the
twelve measured surface and text pins that make it that theme, and it came
apart from a single click in the colour picker. `roles` now merges key by key;
the pin still wins where it is set, and the roles it does not mention keep the
preset's value, which is what "override" means for every other key here.

Also teaches `isRawCSSValue` about `color-mix()`, which the primitives are
about to need. A neutral filled control has three steps and the vocabulary has
a role for one of them; mixing the rest state toward `text` supplies the other
two without inventing roles nobody would pin — and because `text` inverts with
the theme, the same expression darkens in a light theme and lightens in a dark
one. Without it the value fell through to the token branch and came back as
`var(--we-color-color-mix(…))`, a variable name built out of an expression.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
The first pass migrated primitives only where the role's default was the exact
value being replaced. That protected appearance and systematically missed
**foregrounds**, because the text roles sit at neutral-900/500/400 while
primitives use 600/700/800/1000 — so every control's label stayed on the scale
while its background moved. The visible result: nav labels are bare `we-text`
inside a ghost button, inheriting the button's colour, so pinning `text` moved
25 of the 52 text elements on the default template and left the rest. It also
left components half-migrated, which is worse than untouched — `alert.neutral`
read `{ bg: 'page', color: 'neutral-800' }`, a pair that is no longer a pair,
and `page` had won that slot only because neutral-50 happened to be the value.

This is the semantic pass, with the shade shifts taken deliberately: ghost and
outline labels 700 → `text`, input and textarea 1000 → `text`, menu-item 600 →
`text-muted` with its selected row on `text`, blockquote 600 → `text-muted`,
link and badge 600 → `accent-strong`, the tag/alert 700/800 ladders → the
matching `*-text`, and the `*-100`/`*-50` tints → the matching `*-surface`.
Input, textarea and their hover/focus states leave neutral-75 for
`surface-hover`, which is what made a hovered input drop out of the theme.
Raw `var(--we-color-*)` inside shadow CSS is migrated by the property it sits
on, so a neutral-500 that was text becomes `text-muted` and one that was a
border becomes `border-strong`.

Two deliberate non-migrations, both documented where they live. Status *fills*
— the danger button — stay on the scale: `dangerText` is the foreground of an
error message, and pinning it should not repaint the delete button. And
palettes stay palettes: CodeMirror's syntax tokens, AvatarStack's presence
tones, AudioVisualiser's bars. Neither is a hardcode; both still follow the hue
and polarity parameters.

The secondary button needed a ladder the vocabulary has one rung of, so its
hover and pressed states mix the rest state toward `text` — reproducing the
neutral-300 and neutral-400 that were there to within a point, and inverting
correctly in a dark theme because `text` does.

Measured in a browser: text on the default template goes 25/52 → 43/52
following a foreground role, the nine left being Cesium's own globe
attribution. On the channels template, backgrounds hold at 97% and text rises
to 84%. A pinned `surface-hover` now reaches a hovered input, which was the
reported symptom.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Measuring each route of the default template rather than one screen found the
last few things a theme could not reach, none of which were visible by reading:

`we-menu` and `we-number-input` were painted `white` — a base token, not a
role, so a theme pinning its surfaces left every dropdown behind. A menu floats
(`surfaceRaised`); a number input is a field like the rest (`page`).

`EditableImage` and `ImageCrop` hardcoded their scrims as `rgba(0,0,0,0.5)`
and `rgba(0,0,0,0.55)`. `overlay` is the role for exactly that and carries its
own alpha, so the pair now moves with the theme instead of staying black over
a pale one.

The media grid's caption was a literal `white` over its scrim, which is the
`textInverse` half of a pair the vocabulary already names.

Coverage on the default template, backgrounds then text: About 78/86%, Cards
84/90%, Tasks 80/84%, Calendar 85/91%, Settings 83/93%. What remains is
data-driven or third-party — a signal type's own colour, a user-picked swatch,
CodeMirror's syntax tokens, Cesium's globe attribution — and none of it should
follow a theme's roles.

The `bg: 'surface'` on the cards search is deliberate after all, not a leftover:
the same treatment appears on the marketplace search, and it reads as arbitrary
only because so little else was painting with `surface` at the time.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
…ys which kind

The roles editor offered a Google-Docs swatch list of raw hexes, so every pick
opted a role out of the parametric system — the one thing the conventions file
tells theme authors not to do. Neither picker in the repo could reach an
arbitrary colour either: one had a fixed palette, the other a token grid, and
no wheel or area anywhere.

`we-color-picker` now has three ways in, in the order that keeps a theme
working. A **token grid** (5 hues × 14 shades) emits `var(--we-color-…)`, which
still follows the theme's hue, saturation and light/dark polarity — that is the
whole point of offering tokens, and why the picker opens there when they are
enabled. A **saturation/value area** with hue and alpha sliders reaches any
colour, dragged with pointer capture so a drag that leaves the swatch still
belongs to it, and works on a touchscreen. A **text field** with a format
selector reads and writes hex, rgb() and hsl(), in both the comma and the
modern slash notations.

Both new props default off, so every existing caller keeps the swatch list it
had.

The colour maths lives in `@we/design-utils` rather than inside the picker: a
contrast check and any future palette generation need the same conversions, and
two implementations of `hexToRgb` in one repo is how they drift. `parseColor`
deliberately refuses a `var()` — a token is an indirection, not a colour, and
the only honest way to resolve one is to ask the browser.

The roles editor uses it with tokens and alpha on, and now names the rung each
role sits on — auto, a token by name, "theme tint" for the lightness form the
presets use, or custom. Only the last really leaves the parametric system, and
it should be something somebody chose rather than the silent consequence of
having used a colour picker. Sampling an unpinned role keeps its alpha now, so
opening the scrim's picker starts on a translucent colour instead of an opaque
guess.

Verified in a browser: the token grid renders 70 swatches and emits
`var(--we-color-success-500)`; the area emits `#bf2626`; both sliders and the
format selector are live.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
The reference documents every role but said nothing about the *kind* of value
you give one — which is the part that decides whether the rest of the theme
still reaches it. A token pin follows hue, saturation and polarity; the
lightness form the presets use follows hue and saturation but holds against a
polarity flip; a hex follows nothing. Only the last really opts out, and until
this week it was the only thing the colour picker could produce.

Also records the two roles deliberately written the "opted out" way in
`role.ts` — `surfaceInverse` and `textInverse` — and why: a tooltip must stay
opposite to the page in both polarities, and no expression over a ramp that
inverts as a whole can say that.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
**The swatch painted nothing** because `background: <colour>, <image>` is
invalid CSS — a colour is only legal in the final background layer, so the
whole declaration was dropped. It is written as two image layers now, the
colour via `linear-gradient(c, c)`, which is the standard way to spell "this
colour, as a layer". It was also a tall rectangle: the preview had a fixed
48px and the caller was squeezing the host to 28px around it. It takes
`--we-color-picker-swatch` now and stays square.

**The token grid had no styling at all.** `part="swatch token"` does not match
`[part='swatch']` — attribute selectors are exact-match — so neither rule
fired and every swatch fell back to the user agent's own button: a small
grey-bordered box four pixels tall. Both rules use `~=` now. A test guards the
class of bug, since it is invisible by construction: any element carrying two
part names must not have its parts styled with `=`. Reintroducing the bug
fails it.

**Nothing closed the popover.** A click anywhere else now closes it, and
Escape closes it and returns focus to the trigger. The listener uses
`composedPath` rather than `contains`, because the popover is inside a shadow
root and a plain target check only ever sees the host.

**The format `<select>` was unreadable in a dark theme** — a native select
renders its list with the user agent's colours, which follow the platform
scheme rather than the theme, and nothing inside that popup is reachable from
CSS. It is a segmented HEX/RGB/HSL control now, which is themeable because it
is ours.

**And an eyedropper**, which is the platform `EyeDropper` API: it opens the
OS-level magnifier and returns the pixel clicked, from any window — the colour
somebody wants is usually in a screenshot or another app rather than already
in this document. Chromium-only, so the button is absent rather than
present-and-broken elsewhere. Whatever alpha the role had is kept, since a
screen sample is always opaque.

Verified in a browser: preview 28×28 and painted, tokens 18×18 with their
radius and no UA border, outside-click and Escape both close, HEX/RGB/HSL
render as buttons with no `<select>` in the tree, and the dropper appears
where the API exists.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
`@we/schema-kit` is a live package — `@we/template-kit` and `@we/module-call`
both depend on it — and none of the earlier passes reached it, because they
globbed `packages/templates`, the chrome, the modules and the design system by
name. So `cardShell`, `kanbanBoard`, `pickerRow`, `emptyState`, `rail`,
`statChip`, `sectionCard` and `attributeRow` all still painted with scale
positions. That is why no amount of moving `surface` touched a post card: the
card is `cardShell`, and `cardShell` said `neutral-100`.

`block-system/frameworks/solid` was half-done for the same reason — its
stylesheets were migrated and its 16 block components were not, so a theme
reached the frame around a post and not the file, link, task, event, image,
audio, video or location block inside it.

86 sites across 29 files, same rules as before: property-keyed, states read as
states, palettes left alone. Nothing else in the repo now paints a UI colour
from the scale.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
…sing

**A code face nobody could set.** `we-markdown` and `we-html` both read
`var(--we-font-mono, monospace)` for a variable nothing declared, and `we-code`
hardcoded `monospace` — so the one face a theme could not change was the one
code is set in. `mono` joins the family palette as a system stack (no webfont
to ship, native everywhere), `--we-font-mono` is declared beside
`--we-font-family`, and `monoFontFamily` is a theme key. Separate from the body
face deliberately: a code face is chosen for fixed advance widths and a legible
0/O, not for the theme's voice, and setting `fontFamily` should not silently
change what a code block looks like.

**`prefers-reduced-motion` was never read**, though the reference implied
otherwise. Somebody who has asked their operating system for less motion has
asked everything, including a theme they installed — which is why this is the
one place `!important` is correct here, and the reason is mechanical: a theme's
`animationSpeed` lands as an *inline* style on the document element, and
nothing but `!important` outranks one. Zeroing the duration tokens covers every
transition in the system since they all read one; keyframes are stopped
separately, because a theme's own animations do not go through the tokens.

**`prefers-color-scheme` was never read either.** "Follow system" is now a row
in the theme picker like any other, and it is resolved at the point of use
rather than remembered — so the app tracks the OS while it is open instead of
copying the setting once, and the id travels through a preference, a space pin
or a share link without any of them knowing it is special.

**`forced-colors` had no handling at all.** Somebody in that mode has asked the
OS to replace every colour with a guaranteed-legible set, usually because the
alternative is unreadable for them; the right response is to stop competing and
fix what flattening breaks. What breaks is anything distinguished by background
alone — a filled button, a selected row and a plain surface all collapse to one
colour — so those get a border in a system colour, and the focus ring becomes
an outline because a box-shadow is not painted in that mode.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Themes here are data. Authored in the browser, saved to a space, published to
a marketplace, installed by somebody else, carried in a share link — they
outlive the build that made them and they are not ours to edit. That makes a
rename a compatibility problem rather than a refactor, and the failure is the
worst kind: an unknown role key is *ignored*, so a theme keeps looking almost
right while one relationship quietly stops being honoured. Nobody reports that;
they conclude the theme was never very good.

So the vocabulary carries a version, every read migrates forward from whatever
version a theme was written against, and what is stored is left alone until an
author saves — an installed theme nobody touches is never rewritten under them.
`parseOverrides` is the one door themes come in through, so none can enter the
running system at an old version via a path somebody forgot.

With that in place, the renames worth making:

`accentText` meant "text *on* the accent" and read as "accent-coloured text";
`accentStrong` meant the second of those and read as "a stronger accent". The
theme editor had to label them "On accent" and "Accent text" to be usable,
which is the tell. `on<Fill>` says which is which without a label — `onAccent`,
`onInverse` — and frees `accentText` to mean the obvious thing. The migration
applies the map in a single pass, because it is a permutation: applied in
sequence, `accentText → onAccent` followed by `accentStrong → accentText`
would move one value into the other's slot and lose it. That is the case the
tests are really about.

**`controlSurface`** is new and fills the gap that made the list confusing.
`surfaceActive` is a *pressed state*, and of its dozen uses only two were ever
a press — the rest were a slider track, a switch track, a progress trough, a
scrollbar thumb, a skeleton's shimmer, a count chip and the secondary button.
They now have a role of their own, so darkening your pressed state no longer
darkens every slider on the page. Its default is the value they were all
borrowing, so nothing moves.

**The Roles list is regrouped by what the roles answer**, which is what made it
read as arbitrary: "Surfaces" held an elevation ladder, two interaction states
and a polarity inversion at once. It is now Elevation (ordered the way it
stacks: page → sunken → surface → raised), Interaction, Text, Accent, Lines,
Status, and Depth & inversion. Every group says what it is for, and every role
carries a sentence on hover — on the label rather than an ⓘ beside it, since
every row would need one and a column of forty glyphs is noise standing in for
an explanation.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Nothing checked whether a theme was any good. You could pin `text` to the same
colour as `surface` and the editor would smile at you. This is the check —
possible at all only because the roles name their pairings, so `onAccent` is
*defined* as the thing sitting on `accent` and both sides of each test are
known rather than inferred by crawling a rendered page.

It runs in two places. In the editor, a report at the top of the Roles section
measures what the theme actually resolves to — so a failure caused by the hue
sliders reads the same as one caused by a pin, and it appears where the
decisions are being made rather than on an audit screen nobody opens. And as a
unit test over every built-in theme, resolved arithmetically: a theme is four
numbers and a lightness ramp, so the same `(base - subtractor) * multiplier`
the CSS does gives the colour without a DOM.

It failed eleven times on the first run, all of them real:

- `textMuted` measured 4.24:1 on a card. neutral-600 is the smallest step that
  clears AA in every theme. Muted text is now a shade darker everywhere.
- `successText` and `warningText` measured 3.0 and 3.7 against their own tints.
  Green and yellow carry far more luminance than red at the same step, which is
  why danger-600 was comfortable and the other two were not.
- `onAccent` was `neutral-0`, so it *inverted with the theme* — a primary button
  in a dark theme had near-black text on a mid-blue fill. Same bug the tooltip
  had, same fix: a fixed lightness. `dark`, `cyberpunk` and `timeline` pin the
  dark end, because white measures 3.5–3.6:1 on their accents.
- `timeline` shipped `accentText: '#ffffff'` on a bright blue — 2.7:1. Its
  primary button label has been below AA since the theme was written, and near
  black measures 6.2.

WCAG 2 rather than APCA, because that is still what accessibility requirements
are written against; a check that disagrees with the standard people are held
to would be worse than one that is occasionally pessimistic. A translucent
foreground is composited over its background before measuring — scoring it as
solid gets the check exactly backwards, since transparency is the thing most
likely to make text unreadable.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
…re chosen

Two changes, from the same conclusion: the way to understand a role is to
change it and look, so the job is to make looking reliable and to put roles
where people already are.

**A preview strip** at the top of the Roles section — a card on a page, the
three button variants, an input, the status tints, a raised panel, a well and a
tooltip — rendered in the theme being edited. Every role needs somewhere
visible to land, and no real screen contains one of everything: the roles that
seem inert are usually the ones the current route happens not to use. This is
what a highlight mode was trying to substitute for, and it is the thing the
better theme editors actually ship.

**Roles in the visual inspector's colour picker**, which is the more important
half. That picker offered *only* scale positions, so every colour set through
the visual editor came out unthemeable — quietly undoing the migration one node
at a time. Roles now come first and are labelled as the ones that follow the
theme; the scale is still there, labelled as a fixed colour for a palette. Only
the roles a *template* legitimately reaches for are offered: states and
internals like `control-surface` are the design system's business, not a node's.

And a bridge, because somebody looking at a wrong colour is thinking "that
panel", not "surfaceRaised": when a node's colour is a role, the picker offers
to edit that role for the whole theme. Having arrived by clicking the thing,
the useful second option is to fix it everywhere rather than patch one node.

The trigger swatches had to learn the difference too — a role and a scale
position are different variables, and a single guess paints one of them nothing.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Pick a pale accent and the button label goes dark on its own; pick a deep one
and it goes light. That is the difference between "choose an accent" being safe
and being a decision that can silently produce an unreadable button — and the
evidence it matters is that three of the seven *built-in* themes needed exactly
this correction by hand an hour ago. A theme author in a browser will not do
that, and should not have to.

`onAccent` and `onInverse` are derived after the variables are applied, because
the derivation has to *measure*: the fill is a `var()` chain over a parametric
ramp and only the browser knows what it resolves to. The weakest pairing
decides — a primary button's label has to work at rest, on hover *and* while
pressed, and one chosen against the rest state alone goes unreadable halfway
through a click.

Skipped wherever the theme pins the foreground itself. An author who has said
what they want is not overruled, even when they are wrong; the contrast test is
where being wrong gets reported, and this is only for the roles nobody has
spoken for. The derived values are tracked with the rest, so switching theme
clears them — a derived value outliving the theme it was derived from would be
worse than never deriving one.

The decision is a pure function so it can be tested without a browser, which is
where the pressed-state case is pinned down. `applyAutoContrast` is only the
part that measures and writes, and it stands down anywhere there is no real
element to measure — a test stub, a server render — leaving the declared
defaults, which is the right answer when nothing can be measured.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Two things that were possible and undocumented, now named.

**A relative pin.** `color-mix(in srgb, var(--we-role-surface) 88%, var(--we-role-text))` says "a
step darker than the surface", and it is the only form that expresses a *relationship* rather than
a value: it survives a change to the role it references, and because it mixes toward something that
inverts with the theme, "a step darker" in a light theme becomes "a step lighter" in a dark one
without being told which it is. The secondary button already uses it, which is why it needs no
roles of its own. The theme editor now recognises the form and labels it, so it takes its place on
the ladder between a lightness pin and a literal rather than being reported as "custom".

Deliberately not applied to `channels` and `timeline`, whose surface pins were measured off real
screenshots — their comments say the lightnesses *are* the design, and rewriting them as mixes
would trade a recorded decision for a derived approximation.

**oklch() is accepted and offered.** Parsed and converted to sRGB like any other notation, with a
fourth button in the picker's format control. Worth having even while the ramps are still HSL, and
not because it is newer: OKLCH lightness is perceptual, so equal L reads as equal brightness across
hues where HSL's 50% is far brighter for yellow than for blue — the test pins that difference down.
An author pasting a value from a modern palette tool should not be told it is unparseable.

This is a front door, not a new internal representation: everything downstream speaks sRGB. Moving
the *ramps* to OKLCH is a separate decision, and one that changes how every theme looks.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
The role table told a schema author to write `text-inverse` for a tooltip's text — a name the
rename removed, and one that now fails silently: the value resolves to a variable that does not
exist and the element paints nothing at all. The row also pointed at `accent-text` for text on an
accent fill, which is now the accent used *as* text on an ordinary surface, so following it gives
you a colour chosen to read on a card, on a saturated button.

Both are `on<Fill>` now, which the paragraph two lines above already explains. Also cross-checked
the table against the vocabulary itself: 29 roles, 29 rows, nothing documented that does not exist.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
jhweir added 24 commits August 23, 2026 14:19
… gave them

The theme panel read as a different material from the module panels docked at the same edge, and it
was not alone — all three editor panels did the same thing.

Every dock is wrapped in a frame that paints the panel's surface, with a comment saying why: "a
module's node fills it and need not paint a background, a border or a radius of its own — which is
what stops two docked modules from looking like two different applications." Module panels honour
that. The editor's three painted `surface-raised` over the top, which sits ten lightness points above
the page where the frame's `surface-sunken` sits eleven below it — a gap of rgb(18,16,24) against
rgb(55,54,71), so they read as a different application docked beside the others.

Removing the override rather than restating the frame's value in three more places: two copies of a
colour is how the frame and its contents drift apart in the first place, and the frame is the one
that knows.

The wells inside them move with it. A block painted `surface-sunken` on a body that is now also
`surface-sunken` is not a recess, it is invisible — so they become `surface`, which is what a block
sitting *on* a sunken panel actually is. Four of them, in the AI panel and the theme panel.

Two things stay `surface-raised` and both are right: the editing bar and the theme panel's hint
popover are genuinely floating over the app rather than docked into it.
It was `surface`, a step above the panel body, so the preview read as a card floating in the panel
rather than as the top of it. Now `surface-sunken`, the same material the dock frame paints.

Not simply transparent, which would be the tidier answer: the header is `position: sticky` and the
sections scroll *under* it, so anything see-through shows them sliding past behind the preview. It
has to paint something; the something just has to be what the panel is.

The preview strip's own swatches keep their roles — those are demonstrations of `surface`,
`surface-raised` and `surface-inverse`, so they are supposed to differ from their surroundings.
A full `pnpm build` takes minutes and belongs at the end of a piece of work, where the point is to
prove the packages still agree — not after every small edit.

Written down because the opposite habit is easy to fall into and hard to shake: a stale `dist` is
invisible, so one instance of it teaches "always rebuild everything", which then costs minutes on
every iteration. Both failure modes are recorded with the symptom that identifies them, including
the detail that made the stale-dist case confusing — `@we/template-shell` has no `dist` and is
consumed as source, while `@we/module-call` does, which is how the chrome rail and the call bar,
which are meant to match, ended up different colours from a single edit.

Includes the one-liner that reports which packages are actually stale, so the question can be
answered rather than guessed at, and the note that Vite caches on top of all this.
The white flash between the boot screen and a pinned personal theme, and the reason it was
intermittent.

Boot caches the *id* of the theme to wear so the first paint does not wait for AD4M. That is enough
for a built-in — the registry holds its parameters — and not enough for a custom theme, where an id
without its record means nothing. So there is a window on every load where the theme being asked for
cannot be answered, and `resolveThemeData` had to invent something.

Two things made the invention maximally visible. It reached `light`, via a chain that ends
`isValidThemeKey(id) ? id : 'light'` — and a custom id is *never* a registry key, so for an agent
whose default is a custom theme that chain lands on light every time. And the token CSS's `:root`
defaults are themselves the light theme, so a wrong answer here is not a neutral placeholder, it is
a white page in the middle of a dark one. Intermittent because it is a race with the records
arriving.

The previous attempt at this changed what the fallback *guesses at*, which was the wrong shape of
fix — it narrowed the window without closing it, and could not close it for exactly the agent
affected. This removes the guess: while the records might still arrive, both the document and the
template hold what is already on screen rather than painting a fallback over it. Once loaded, an
unknown id really is unknown and the fallback is honest again.

Not reproducible locally — the preview app forces its own seed theme, so this is reasoned from the
resolution path and defended by tests that pin the two facts behind it: a custom id is never a
registry key, and the fallback chain therefore terminates at light. If it recurs, the complete fix
is to cache the theme's *overrides* rather than its id, which would remove the window instead of
holding across it.
## The colour picker showed the wrong theme's colours

Its Tokens tab paints swatches from `var(--we-color-<family>-<step>)`, and a `var()` resolves
wherever it is *used* — the picker is editor chrome, so in scoped mode it resolved against the
personal theme. Editing a green space theme from a purple personal one offered a grid of purples and
then applied green. The swatch you clicked was not the colour you got, which is the one thing a
colour picker must never do.

The edited theme's `--we-color-*` variables now go on the picker. Only that half: the roles are left
alone so the picker's own surfaces stay part of the editor, rather than a light space theme turning
its popover white inside a dark app.

## The editing bar joins the rest of the floating chrome

Undo/redo and save/close were `surface-raised` — the tonal-ladder rung sized for a popover clearing
a card — while the chrome rail and the call bar are `page`. They cover nothing and separate by edge,
so they are `page` too now. The call module's own note names these bars as the thing it was matching
against, so they were always meant to agree.

## A derived role now says what it follows

Every surface in the elevation stack is a fixed distance from `page`, so moving the page moves all
three. That is the design, and with the panel showing only "auto" underneath them it reads as being
unable to change the page without also changing the cards — which is what prompted the question.

They now read "follows Page", and the twelve relative roles do the same. Setting a role explicitly is
what detaches it, and the reset button beside it already offers the way back, so the toggle being
asked for exists — it just was not visible. Parsed out of the role defaults rather than listed, so a
role that starts or stops being relative changes this by changing itself. `role` is re-exported
through @we/themes and @we/schema-shared to make that possible.

## One repair

An earlier blanket rename in this file caught the preview strip's *demonstration* of
`surface-sunken` — the swatch labelled "Sunken" was painting `surface`. Restored.
…easurements

Toggling the theme scope changed the preview of a theme that had not changed, which meant the
preview was not one.

`themeToStyle` writes a theme's parameters and its role defaults and stops there. Everything that has
to be *measured* — the per-hue chroma ceilings, the fills moved until a label fits, the label chosen
against where they landed, the corrected foregrounds, which way a hover travels — happens at apply
time and needs a real element. Left out, those variables do not go missing: custom properties
inherit, so the preview quietly picked up the **document's**.

Measured, with the same green theme on two elements while the document publishes different ceilings:

    app ceiling 0.2322   declared-only rgb(19,156,46)   applied rgb(59,152,67)
    app ceiling 0.1000   declared-only rgb(99,142,100)  applied rgb(59,152,67)

The theme is identical in both rows. The declared-only element washes out to a grey-green purely
because the app around it published a narrower ceiling for that family — a green being clipped to a
violet's limit.

Three surfaces in this panel were doing that, and all three now run the pipeline:

- **The preview strip**, which is what was reported.
- **The role swatches.** Sampled from a probe carrying only the declared roles, they showed muted
  text before it was corrected and a label before it was chosen — a swatch beside each role for a
  colour the theme never renders.
- **The colour picker's Tokens grid.** Fixed a commit ago to use the edited theme's palette, which
  was only half of it: the palette was right and the ceilings it was drawn through were still the
  app's. The measured ceilings are read back off the probe rather than recomputed, because a second
  implementation of that measurement is a second thing to drift.

This is the fourth surface to need `applyThemeVars` rather than `themeToStyle` — after the document
root, the scoped template wrapper, and now these. The distinction is not discoverable from the two
function names, and that is worth fixing rather than remembering.
Cyberpunk looked darker than it used to, and it was not alone.

The migration turned `subtractor: 110%` into `floor 10% / ceiling 110%` by arithmetic, which reads
an HSL lightness as though it were an OKLCH one. They are not the same quantity — the same
conversion error put a 13% pin at half the lightness it should have had — so every theme carried
across that way renders darker than it did. `dark` was refitted by hand at the time; the others were
not, and nobody looked.

Measured across the neutral ramp against dev, mean |ΔL|:

    Light      1.3      Dark       1.1      Retro    1.0      Timeline  0.2
    Black     12.7  ->  2.1
    Cyberpunk 13.7  ->  2.2
    Channels  14.2  ->  1.8

The four that were already right are the light-polarity themes, where floor 0 / ceiling 100 happens
to map cleanly, plus the one that had been fitted. The three that were wrong are the dark ones.

Fitted by least squares over the neutral steps, weighted by how much of a screen each paints, and
rejecting any ramp that clips a step to white. The script reproduces `dark`'s hand-fitted 20% / 121%
from its measurements, which is what says the method is sound rather than merely fitting something.

## Black loses four pins it should never have had

It pinned page, surface, sunken and raised, with a convincing note: "its page is pure black, and a
+0.045 OKLCH step from there rounds to the same 8-bit sRGB value." True — and only true because the
floor had been mistranslated to 0%. Dev pinned nothing here. With the floor back at 10.5% there is
room for the relationship again, so the derived stack works and the four pins go.

That is the part worth remembering: a pin written to work around a defect is indistinguishable from
a pin written to express a design, and the comment justifying this one was entirely persuasive. A
unit test had grown around it too, asserting that `black` pins a lightness — asserting the defect.
It now uses `channels`, which pins because its surfaces are uneven in a way no lightness range can
express, which is what a pin is actually for.
…rong

`themeToStyle` returns a theme's *parameters* — its inputs and its role defaults — and nothing that
has to be measured. The name read as "turn this theme into a style you can apply", and six places
followed that reading.

Two of them were still live and are fixed here, both in the schema renderer: a node carrying a
`theme` had its parameters spread into a wrapper's style, so a themed node rendered through the
**ambient** theme's measurements. Custom properties inherit, so the derived half did not go missing
— it came from whatever was around it. A green theme inside a violet app was drawn with a violet's
chroma ceilings.

The full list, for the record: the document root, the scoped template wrapper, the theme editor's
preview strip, its role swatches, its colour picker, and both renderer wrappers. Every one looked
correct and was wrong only when the ambient theme differed from the one being shown — which is
exactly the case a theme editor and a scoped space theme put it in.

So: `themeParametersToStyle`, and a doc comment that says what it is not for and lists what "derived"
actually covers. Mechanical rename across eleven files; typecheck confirms the surface.

Its two remaining callers are legitimate and want precisely the parametric half — reading slider
values back out of a theme, and the palette a picker offers.
relsyntax.mjs is a throwaway Playwright probe from checking whether relative colour syntax resolves
through a chain of custom properties. It belonged in the scratchpad and was never meant to be
tracked.
Two failures in `@we/tokens`, in a package the scoped test runs had not been touching — which is the
argument for the full pass at the end rather than an argument against scoping during the work.

**A role-shape assertion rejected the right answer.** It accepts a scale position, a pinned lightness
with parametric chroma and hue, or a step from another role — and the accent's lightness became a
theme parameter, so it now reads `oklch(calc(var(--we-accent-lightness, 55) * 1%) …)`. That is *more*
parametric than the form the pattern allowed, not less. Widened, with the four legal shapes named:
the failure being guarded against is a role that stops following the theme, so naming the legal
shapes is what catches a new way of doing that.

**The generated-CSS snapshot** now records the fills leaving the ramp — `--we-role-danger` moving
from `var(--we-color-danger-700)` to its own lightness with a per-family chroma ceiling. Reviewed
rather than blind-accepted; it says what it should.

Also `pnpm lint` clean: a `STATE_STEPS` import left behind when the state maths moved into the shared
`stateDelta`, and one `flex-shrink` escape hatch written as the `flex` design-system prop it has.
…d as one

It was the first entry of `builtInThemes`, which put it at the head of the only section a fresh agent
sees: the most prominent row in the picker, above every actual theme, under a heading saying it is a
built-in one. The first question anyone asked about it was what it was — which is the labelling
answering for itself.

It carries no parameters. It is a question, answered at the point of use by asking the operating
system, and it resolves to one of the built-ins listed above it. So it goes in its own section,
`automaticThemes`, rendered last under an "Automatic" heading in both the picker and the settings
list. Measured in the browser: Built-in with its seven themes, then Automatic with the one row.

Kept in the picker rather than moved to app settings, though that was the other suggestion. The
deciding property is that it is *mutually exclusive* with choosing a theme — whatever sets it has to
be somewhere the picker can render as selected, or two surfaces end up disagreeing about what is on
screen. Bottom-of-list under its own heading gets the prominence right without splitting the state.

An array rather than a single value so the existing section helper renders it unchanged, and so a
second automatic mode later — following a schedule, say — needs no new machinery.
…ting it

The suite modelled the pipeline: it found a fill no label could sit on and moved it, chose a label
against where it landed, walked a foreground until it cleared. Each was a second copy of a decision
the runtime also makes, and the copies drifted — repeatedly, and once catastrophically.
`applyThemeVars` spent a whole round doing nothing at all, because every derivation in it read a role
through `getPropertyValue` and got back an unevaluated token stream; this suite modelled the
derivations it believed were running and reported green throughout. Two smaller drifts followed, each
caught only because a recorded value happened to move.

The split is now along the one line that is inherent. **Resolving a declared value to a colour** is
done differently by necessity — the runtime asks a browser, the suite does the arithmetic. Everything
**decided from those colours** is one call to `deriveRoleVars`. Three passes, mirroring
`applyThemeVars` in the same order: declared values, then the pipeline, then the interaction states,
which cannot come earlier because which way a hover travels depends on the label the pipeline picks.

Verified by mutation rather than by the suite staying green, which proves nothing on its own:
disabling the foreground correction in `deriveRoleVars` now fails ten rows with
"dark: muted text on a card is Lc 45, needs Lc 60" — the exact value measured in a browser when the
derivations were genuinely not running. The same break passed silently before this change.

`deriveFill`, `deriveLegible`, `labelCandidates`, `fillStateDeltas` and `AUTO_CONTRAST` are no longer
imported here. Nothing in the suite reaches into the pipeline's steps any more; it supplies colours
and reads the result.
Two follow-ups recorded on this branch, and the second found a live bug in
the first.

`focus` was `var(--we-color-primary-500)` — a scale position, so a theme that
pinned its accent got a ring in a colour it never chose. `dark` measured
rgb(127,114,206) against its own rgb(112,94,201). It is now `var(--we-role-accent)`,
which is what a focus ring means. The role-shape test gains a fifth legal form
(`var(--we-role-…)`) to allow a role expressed over another role.

The aliases — `--we-color-focus`, `--we-ring-color`, `--we-border-color(-strong)`,
`--we-focus-outline` — are declared at `:root`, so they resolve there and inherit
downward as finished colours. A scoped theme moves the role beneath one and paints
the ring in the document theme's colour anyway. The fix was already in place
(restate them wherever a theme applies) but it left two lists that had to agree,
and they did not. ROLE_ALIASES in @we/tokens is now the single list; generate-css
emits it and themeParametersToStyle re-declares it, so a new alias reaches both by
existing. A test asserts the re-statement end.

Two drifts fell out of that:
- `--we-color-focus` was being re-pinned to `primary-500` by a conditional block
  further down themeParametersToStyle, so any theme with a primaryHue defeated the
  role change above. Removed — the unconditional list covers it.
- `--we-focus-outline` said `2px` in the re-statement and `var(--we-focus-ring-width)`
  in the generator, so a theme widening its ring got the wrong ring on every scoped
  element.

`--we-scrollbar-thumb-background` is deliberately not in the list: it already has one
source in component.ts, and listing it would put its ownership in two places.
…ent had

The accent gained a themeable lightness earlier on this branch, and the other
three fills did not. The gap was not cosmetic: a theme that brightened its brand
ended up with a delete button that read as the one muted control on the screen,
and the only way to move it was to pin the `danger` role — which discards the
label and interaction-state derivations that keep a fill usable, so brightening
a button was also how you got an unreadable label on it.

Each of the three now takes its lightness from a variable with its FILL_LIGHTNESS
figure as the fallback, exactly as `accent` does; the per-family chroma ceiling
follows it, so brightening a fill gets the colour actually available up there
rather than the figure measured where it used to sit. The read site that picked
`accentLightness` for `primary` and nothing for the rest is now a map over all
four.

The contrast suite captures the variable name out of the role instead of spelling
`--we-accent-lightness`, so a fifth fill is measured at the right lightness by
existing rather than falling silently through to the literal branch.

Also records, on `accentText` and the three status text roles, why *those* stay
scale positions where the fills did not: a fill names a thing and must not invert,
a foreground names a relationship to a surface that does invert and so must invert
with it. Step 700 is L 42% light and L 58% dark, which is the accent reading dark
on white and light on black. Listed as an inconsistency to fix; it is neither.
The last two presets carrying a long pin list. Both were authored by measuring a
rendered screen and writing the numbers down, and seven of the twenty-five turned
out never to have been decisions: `channels` pinned its page, sunken surface,
hover tint and text within half a point of what its own floor and ceiling already
produce, `timeline` its sunken surface within 1.2. Each cost the theme the ability
to follow its own parameters — move the ramp and a pinned role stays put — and
none of them changed a rendered colour. Every role of every built-in measures
byte-identical in Chrome before and after.

Three more were saying the right thing in the wrong way. `channels` wanted its
channel list and its page to be one sheet and said so as a second copy of 22.7;
`timeline` has no elevation at all and said so as two more `#ffffff`. Both are now
`var(--we-role-page)`, so the relationship survives the page moving. Timeline's
worked by accident besides: the parametric default is `page + 10%`, which at a
white page is L 110% and clamps back to white, so a theme that later darkened its
page would have kept two pure-white surfaces floating over it.

The pins that stay are annotated with what they are for — a border and a pressed
tint at one lightness, an emphasised rule 22 points below neutral-500 so a dense
channel list does not turn into a grid.

A test now asks of every pin whether removing it changes the colour, so the next
theme cannot accumulate the same thing quietly. A pin stated over another role is
exempt, and deliberately: that is a relationship rather than a frozen measurement,
and judging it on today's value is what would have deleted timeline's two.
`system` is not a theme, it is a question — and until now it was a question with
only two possible answers, because it resolved straight to the strings 'light'
and 'dark', which happen to be the ids of two built-ins. So an agent who had built
their own light and dark pair could follow their machine or wear their own themes,
never both, and the setting that reads as "match my machine" quietly meant "match
my machine, using somebody else's palette".

AgentSettings gains `systemLightThemeId` and `systemDarkThemeId`, and the resolver
reads whichever side the OS is asking for. Empty means the built-in of that
polarity, so every existing agent gets exactly what this did before, and the
resolver refuses `system` itself on both sides — a pair naming the thing being
resolved is the one input that cannot be answered.

Both halves are mirrored into localStorage alongside the current theme, for the
same reason that one is: the boot screen has to answer `system` before AgentSettings
exists, and answering it with the built-in and then swapping is the light flash
wearing a different hat.

The control sits beside the "Follow system" row in both places it appears — the
shell's picker and the settings route — rather than in a settings page of its own,
because the row is meaningless without it. `systemThemes` reports the ids as
*chosen*, empty for a side left at the built-in, so "Built-in light or dark" shows
as selected and picking it visibly does something; the fallback lives in one place,
which is the resolver.
The template-surface fit test caught this: the pair pickers had gone into the
default template's settings route, which is a space template, and the section
they landed in is the *space's* default theme picker — `setSpaceDefaultTheme`,
not a personal preference.

The two rows look alike and mean different things. In the shell's picker "Follow
system" is my choice about my window; in a space's settings it is the community
saying "this space's default is: follow each member's own system". A control
repointing what "Follow system" means *for me*, reachable from a template a
community wrote, would let a space quietly restyle every session I opened
afterwards — which is what the agent tier exists to refuse.

So `systemThemes`/`systemThemeOptions` sit at `agent` alongside `themeScope` and
`useTemplateTheme`, and the control lives only in the shell's DesignControls,
beside the row it configures. The space settings heading says which of the two
questions it is asking.
The contrast suite had two dead constants left over from the rewrite that made it
call `deriveRoleVars` instead of modelling it — its own copies of the runtime's
fill and foreground tables, no longer read by anything. Deleting them would have
left a real question unasked: `PAIRS` is hand-written, so a role the runtime
corrects at apply time is asserted by nothing, and the correction could stop
working with every test still green.

So the tables are inverted into a coverage check, read from the runtime's own
`LEGIBLE_FOREGROUNDS` and `DERIVED_FILLS` rather than restated. It found two
foregrounds corrected at apply time and measured by nothing: `textFaint` and
`accentText`. `accentText` passes everywhere. `textFaint` did not, in three themes,
for two different reasons:

- `light` measured 2.84:1 and `retro` 2.83 — past APCA's Lc 45, a hair short of
  WCAG's 3:1. The correction stops at the first metric it clears while the suite
  grades on both, so it declared itself finished and left two built-in themes below
  a floor the suite enforces. `deriveLegible` now requires both. The cost is about
  two points of lightness on a placeholder; in Chrome both themes go 2.84 → 3.06,
  and nothing else moves.
- `channels` measured Lc 36 against a floor of 45, because it *pinned* `textFaint`
  and a pin is deliberately skipped — the author overruling the derivation, which
  they are entitled to do. This was not an author overruling anything: it was a
  value hand-picked off a screenshot that happened to land short, 11 points off the
  ramp and still illegible. Unpinned, it gets the same correction every other theme
  gets and measures 5.91.

Also fixes a store-ordering bug the browser found and no test could: the three new
`system*` memos read `systemScheme` and `allThemes` from above the lines defining
them, and `createMemo` runs its computation on creation — so the whole ThemeStore
threw `Cannot access before initialization` and the app rendered blank. It
typechecks, because TDZ is a runtime property.
The audit printed `f.lc.toFixed(0)`, and every number it prints is printed
*because it fell short* — so an Lc of 59.6 rounded up to "60, needs Lc 60", which
reads as a complaint about a pair that is fine. Both branches now truncate, so a
reported failure can never display at or above the threshold it missed. The same
rounding was in the contrast suite's own failure messages.

The warning underneath it was real. `dangerText` and friends were corrected against
their *tint* only, on the reading that a status colour belongs on its own panel. It
does not: an error under a form field, a "connected" tick in a settings row, a
warning icon beside a label all sit on an ordinary card, and that is the commoner of
the two placements. In a dark theme the card is also the harder background, so the
commoner case was both uncorrected and the one that fell short — `dark` measured
Lc 58.5 on a card against 60.4 on its tint, `cyberpunk` 58.6 against 61.1, `black`
59.9 and 59.5 for danger and success. All near-misses, which is how they survived:
a shortfall of half a point renders as a rounding artefact, and the rounding bug
above made it look like exactly that.

`LEGIBLE_FOREGROUNDS` entries now carry a list of backgrounds and the correction
takes the worst, the same rule the fills already use. Which one is worst has to be
measured rather than declared — in a light theme it is the tint, in a dark one the
card. In Chrome all seven built-ins now clear Lc 60 on a card: dark 61.2,
cyberpunk 60.7, black 60.2/60.5.

The coverage check gains the other half of its question. It asked whether every
corrected foreground is measured somewhere; it now also asks whether every
*background* it is corrected against is measured, since a surface the theme is held
to with nothing checking it is exactly how this pair stayed unnoticed — it existed
in the editor's audit and in no test.
…e drifts

Four fixes, found by pulling on why the theme-role readout felt absent.

**A fresh template was inert to every click.** `node.id` is what the renderer
stamps as `data-we-node-id`, and it is the whole handle the visual editor has —
selection, the inspector, the role readout and every ancestry walk go through
`findNodeById`. A tree that skipped `ensureNodeIds` renders identically and
cannot be clicked at all, so the failure did not read as a missing id, it read as
the visual editor being broken.

There were six setters and three had forgotten. `saveTemplateAs` was the one that
mattered: it is how both "Start fresh" and "Fork" arrive, and `starterTemplate` is
hand-written with no ids, so a brand-new template was unselectable from the first
click. `removeTemplate` dropped to a raw `emptyTemplate` the same way, and the
boot template was never ensured at all. All six now route through one
`commitTemplate`, and `setCurrentTemplate` is called nowhere else. A test asserts
exactly that, because the defect is a *missing call* and no test of behaviour
catches one — a store holding a template nobody can select still returns the right
template.

**The inspector painted its own background.** The dock frame already sets
`surface-sunken` precisely so a panel need not decide what it is made of; the
inspector painted `surface` over it and sat lighter than every neighbour. The code
and theme panels were corrected before, separately, months apart — three reports
of the same drift — so all three are now guarded.

**Three spacing props that did nothing**, all of them in the role readout strip:
`gap="150"` twice and `px="150"` once. `tokenVar` turns those into
`var(--we-space-150)`, which does not exist, so the declaration is dropped and the
chips sat flush with no padding. They were the only three in the repo. Guarded
across the editor, because the mistake is a plausible-looking number and the next
one will be 250.

**The role jump lost the role.** The chip's tooltip promises "click to edit it for
the whole theme" and then landed you at the top of a panel holding forty roles
across five collapsed groups. `focusRole` now carries it: the Roles section opens,
the row scrolls to centre, and it is ringed for two seconds. A collapsed section
has no row to scroll to, so opening it is part of the fix rather than a nicety.
It read "is a fixed scale position, so it does not follow the theme", and the
first person to read it caught it. A scale position follows the theme perfectly
well — it is computed from the neutral hue, the saturation, the floor and ceiling
and the polarity, so it moves when any of those move and it inverts with the ramp.

What it does not follow is what the theme *decides*. A theme pins roles, not
steps: channels sets its surface equal to its page, timeline sets its to pure
white, and a node naming a step cannot hear either. In channels a card painted
neutral-100 measures [7,8,11] against a surface of [26,28,33] — a dark hole
punched in a design whose premise is that the card and the page are one sheet.

The other half was not said at all, and it is the half that bites text: the
measure-and-correct pass operates on roles, so a label coloured neutral-600 is
never measured against what is behind it, never walked toward legibility, and
never appears in the audit either. The old wording was both wrong and quieter
than the truth.
197 nodes across the composed templates painted with a scale position where a
role was meant. Not 197 decisions, though — four defaults:

- `statChip`'s icon was `neutral-600` while the label an inch to its right was
  already `text-muted`, so the two disagreed in every theme. 126 of the 197.
- `attributeRow`'s icon was `primary-600` — the accent *fill*'s step used as a
  foreground, which is what `accent-text` is for. 24.
- The rail's selected row: `neutral-100` behind `primary-600`, now `accent-muted`
  behind `accent-text`. Worth naming because it is the most-looked-at chrome in
  the app; `accent-muted` is a chroma shift from what it replaces rather than a
  redesign, same lightness with the theme's primary saturation instead of its
  neutral one.
- A line of *guidance* recommending `neutral-300` for a gate prompt's icon, which
  every gate prompt in the repo had duly copied. Fixed in the fragment, so the
  next template written from the docs does not reproduce it.

A scale position is not frozen — it follows the theme's hue, saturation, floor,
ceiling and polarity, and inverts with the ramp. What it cannot follow is what a
theme *decides*: `channels` sets its surface equal to its page and `timeline` sets
its to pure white, and a node naming a step hears neither. The quieter half is
that the measure-and-correct pass at apply time operates on roles, so a label
coloured `neutral-600` is never measured against what is behind it, never walked
toward legibility, and never appears in the theme editor's audit.

`role-audit` finds them by importing each schema and walking the real composed
tree, the way `surface-audit` does — a fragment contributed by another package is
invisible to any grep over the route that renders it, which is why the count was
197 and not the 42 the obvious search reported. It runs in ~2s, so unlike
`surface-audit` it is a test rather than a script somebody remembers: the
vocabulary being right was never the weak part, and discipline alone had already
let the migration start reversing.
`chromaTaper` took a step name, read the raw table entry, and carried a comment
saying the taper "must not follow the theme's inversion". The generated CSS has
never agreed: it computes `2 * max(0, min(L, 1 - L))` from
`--we-color-lightness-<step>`, the post-ramp value, so it follows the ramp exactly.

They agree wherever the ramp is the identity — floor 0, ceiling 100, which is
`light` and `retro` — and that is why it survived. On `dark`'s step 200 they are
2.3× apart: the step lands at L 0.357, giving a taper of 0.713 where the raw entry
gives 0.310. Chrome paints rgb(59,57,82); the post-ramp model resolves that
exactly and the raw one is 12 units out on blue.

So every scale position the contrast suite resolved in a dark theme was measured
against a colour the browser does not paint — for the whole of this branch, with
397 tests green. It never flipped a verdict, because chroma barely moves either
metric, and that is not a reason for the suite that grades themes to be modelling
the wrong colour.

The CSS is authoritative because the CSS is what renders. `chromaTaper` now takes a
resolved lightness rather than a step name, which makes the mistake unspellable — a
caller has to have applied the ramp before it can ask. The primary gradient built
its taper in JS too and now writes the same CSS expression as everything else; at
step 500 the difference is 0.800 against 0.792, and it is changed anyway, because a
number that happens to be nearly right is how this went unnoticed at step 200.

Fifteen recorded browser measurements now pin the resolver — three steps across
five themes, sampled from Chrome. Everything else in that suite grades *from*
`resolve()`, so nothing internal disagrees with a resolver that is wrong; these are
the only assertions an internal change cannot satisfy by moving both sides at once.
Mutation-checked: a broken taper fails all fifteen.

Worth recording that the first attempt at these used measurements captured earlier
in the branch and reported black, cyberpunk and channels as badly wrong. They were
not — those three were refit since, and the *data* was stale. Ground truth has to
be re-read, not remembered.
Selecting the author name on a card showed `background: surface` and nothing else.
The background was right — it comes from the Card and it is what the text's
contrast is measured against — but the foreground, the one thing you were looking
for, was missing.

`index.scss` sets `body { color: var(--we-role-text) }` and most text in the app
says nothing about its own colour, so it is painted by the *document*. The readout
walks the schema and only the schema, found no `color` anywhere in the chain, and
reported nothing — which is indistinguishable from "this element has no text
colour". The timestamp beside it looked fine only because its node happens to set
`text-muted` explicitly.

Text now always reports, falling back to the document's role and marked as
inherited. The caveat it cannot see is recorded on the field: a component between
the node and the document may paint its own colour in its shadow root — a `ghost`
button does — so this says where the *template* leaves it, not the final answer.

A background still reports nothing when nothing above paints one. That asymmetry is
deliberate: there is no document-level background rule to fall back to, and claiming
`page` would be a guess about a root the fragment cannot see.

The chips also carry which of the three they are. They were a swatch and a role
name, so two of them read as two colours with no way to tell the background from
the foreground without hovering each — which is most of what made a background on a
text element look like a mistake rather than context.
@netlify

netlify Bot commented Aug 23, 2026

Copy link
Copy Markdown

Deploy Preview for coasys-we ready!

Name Link
🔨 Latest commit 0dbb774
🔍 Latest deploy log https://app.netlify.com/projects/coasys-we/deploys/6a8b2f131eecab0008816c7f
😎 Deploy Preview https://deploy-preview-127--coasys-we.netlify.app
📱 Preview on mobile
Toggle QR Code...

QR Code

Use your smartphone camera to open QR code link.

To edit notification comments on pull requests, go to your Netlify project configuration.

@jhweir

jhweir commented Aug 23, 2026

Copy link
Copy Markdown
Collaborator Author

Theming — verification, bugs found along the way, and follow-ups

The second half of the description for this PR. Split off because GitHub caps a body at 65,536
characters and the two together are past it; the cap applies per comment as well, so this is room
rather than a summary of what was cut. Nothing is abridged.

Bugs found and fixed along the way

  • The whole first migration was silently inert. tokenVar matches roles kebab-cased while
    ThemeRole keys are camelCase; surfaceSunken emits a variable that does not exist, so the
    browser drops the declaration and the element paints nothing. Typecheck, validation and the
    rendered screen all looked fine. The validator now rejects the TypeScript spelling and names the
    right one — only where the two differ, since page and surface read the same either way.

  • Tooltips went white in dark mode — my own regression. text/textInverse are scale positions,
    so the pair flipped with the theme. No expression over the scale can be dark in both polarities,
    which is why overlay and shadowColor pin a lightness; surfaceInverse joins them and
    onInverse becomes fixed too. The dark theme's ::part(tooltip) override existed only to undo a
    hardcoded #222 and is gone — which also fixes every other dark theme, since that rule only ever
    fired for the one named dark.

  • Switching theme while editing did nothing. The picker persists a pin and lets the recompute
    land in replaceTheme, which drops any change while a theme is being edited — right for walking
    into a space mid-edit, wrong for someone choosing a theme. It is now told which kind of change it
    is.

  • A scoped theme's unpinned roles resolved against :root. A custom property containing var()
    is substituted where it is declared, so a scoped theme could redeclare every colour token on its
    wrapper and still paint from the personal theme's scale.

  • The measure-and-correct layer never ran, in any theme, since the round that introduced it.
    Every derivation read a role through getPropertyValue, which hands back a custom property's
    unevaluated token stream; parseColor returned null and each one hit its continue. It degraded
    to the declared default — a plausible colour — and it did work for a role pinned to a literal,
    which is exactly what a spot-check would have looked at. Muted text was Lc 20 on black against a
    threshold of 60. The contrast suite models the same pipeline in its own arithmetic, so it reported
    green throughout.

  • The chroma taper was computed from the wrong end of the ramp, inverting colour end-for-end in
    every dark theme: 0% chroma at the dark end, 209% at the light one.

  • neutral-0 was migrated with light-theme eyes. A scale position inverts, so it is the lightest
    colour a light theme has and the darkest a dark one has; 55 sites had it backwards.

  • A label was chosen with WCAG 2 and graded with APCA, which disagree outright on a mid-tone
    fill — so the derivation picked the label that failed the standard it would be judged by.

  • A dock whose store registered late could never be resolved again, because the lookup that
    missed it registered no dependency to retry on. Guaranteed by provider nesting, not racy.

  • Theme scoping restyled the template it was meant to leave alone, because the scoped wrapper got
    a theme's parameters without the measurements — as did the editor's preview, its swatches, its
    picker, and both of the schema renderer's themed-node wrappers.

Known follow-ups

Open — for after this branch

Three things this branch establishes are worth doing and out of scope here. The first is the only
one I think is a genuine hole in the design rather than a nice-to-have.

  • A theme cannot change the shape of the ramp. It can move the hue, the saturation, the floor
    and the ceiling — but the fourteen step lightnesses in colorLightness are global constants. A
    theme wanting more resolution at the dark end, or a flatter middle, has no way to say so.

    Worth being precise about the size of this, because it looks larger than it is. It does not
    invalidate the parametric model — it extends it
    , adding a shape parameter beside the range
    parameters the ramp already has. And mechanically it is nearly free: the step positions are the
    only literals left in the ramp, since the transform, the chroma taper and the chroma ceiling all
    already resolve in CSS from --we-color-lightness-<step>. Emitting that literal as a variable
    makes the whole ramp reshapeable with no other CSS change.

    The difficulty is what a theme would state. Fourteen free numbers is a table, and tables are
    what the parametric model exists to avoid — it is the "theme as a list of colours" failure mode one
    level down, and it would let a theme put step 200 darker than step 900, silently inverting every
    role expressed over them with no test to catch it. The right shape is one number: a gamma on the
    distribution, 1.0 being today's. It expresses the actual want, it is one slider, and it cannot
    produce a non-monotonic ramp by construction.

    The work is on the JS side, where four things read the step table directly — chromaTaper,
    calculateColor, the contrast suite's lightness, and applyChromaCeilings's
    CHROMA_PEAK_LIGHTNESS = 0.6, which means "where the ramp peaks" and stops being 0.6 the moment
    the shape moves. Plus a theme field, a schemaVersion bump and a migration.

  • The enforcement now covers templates and not the design system. role-audit walks the
    composed schemas; a primitive or a layer-4 component painting var(--we-color-neutral-100) in its
    own SCSS is invisible to it, and there are some — Toast, Calendar, CollapsedContent and
    Select each hardcode one. Those are a different search (stylesheets, not schemas) and the same
    defect, and the same argument applies: the vocabulary being right was never the weak part.

  • A schema can still say a scale position where a role belongs. The audit catches it after the
    fact, which is a gate rather than a design. The stronger form is to make the wrong thing unsayable
    — scale positions reachable only where a palette is genuinely meant, and a validator error
    otherwise. That is a bigger change than a sweep and it needs a way to declare "this is a palette"
    at the node, which does not exist yet.

A note on why the two-layer split itself is not on this list. Scale positions following the
theme's parameters but not its decisions is the correct design, and the boundary is in the right
place: the two answer different questions, the layering is one-directional (roles are expressed over
the scale, never the reverse), and the derivations need a substrate that is not itself being
corrected or they have no ground truth to resolve against. The rule that a theme may never pin a
scale step is absolute, and this branch established elsewhere that absolutes are where theme systems
break — but that one is principled rather than lazy: a pinned step would silently move every role
expressed over it, breaking the layer beneath the one the author was editing.

Closed — everything this branch recorded

What each turned into, since three of them were not what they looked like:

  • The contrast suite now calls deriveRoleVars instead of modelling it. The split is along the
    one line that is inherent — resolving a declared value to a colour is done differently by
    necessity (the runtime asks a browser, the suite does the arithmetic), and everything decided from
    those colours is now the same call. Verified by mutation: disabling the foreground correction
    produces ten failures including the exact Lc 45 that was measured in a browser when the
    derivations genuinely were not running.
  • focus is var(--we-role-accent). A theme that pins its accent gets a ring in that colour —
    dark measured rgb(127,114,206) against its own rgb(112,94,201) before.
  • The :root aliases have one source. ROLE_ALIASES in @we/tokens is emitted by
    generate-css.ts and re-declared by themeParametersToStyle, so a new alias reaches both by
    existing, and a test asserts the re-statement end. It found two live drifts on the way in: a
    conditional block still re-pinning --we-color-focus to primary-500, which defeated the change
    above for any theme with a primaryHue, and a --we-focus-outline that said 2px at one end and
    var(--we-focus-ring-width) at the other.
  • danger, success and warning have the lightness control the accent had. The gap was not
    cosmetic: the only way to brighten a destructive button was to pin the role, which discards the
    derivations that keep its label readable — so brightening a button was also how you got an
    unreadable label on it.
  • "Follow system" chooses between the agent's own two themes. It resolved straight to the
    strings light and dark, which happen to be the ids of two built-ins, so an agent with their own
    pair could follow their machine or wear their own themes, never both. AgentSettings gains the two
    ids; empty means the built-in of that polarity, so nothing changes for anyone who has not set them.
    The pair is mirrored to localStorage for the same reason the current theme is — the boot screen
    has to answer before AgentSettings exists.
  • channels and timeline were audited: 25 pins down to 18. Seven turned out never to have been
    decisions — channels pinned its page, sunken surface, hover tint and text within half a point of
    what its own floor and ceiling already produce — and none of them changed a rendered colour, which
    Chrome confirmed after they were dropped. Three more were saying the right thing the wrong way and
    are now var(--we-role-page): channels' channel list and page are one sheet, timeline has no
    elevation at all. Timeline's worked by accident besides, since the parametric default is
    page + 10%, which at a white page clamps back to white — so a theme that later darkened its page
    would have kept two pure-white surfaces floating over it. A test now asks of every pin whether
    removing it changes the colour, so this cannot re-accumulate quietly.

One recorded follow-up turned out to be a non-issue, and the reasoning is worth keeping.
accentText, dangerText, successText and warningText were listed as "the same defect class as
the fills, left half-done". They are the opposite case. A fill left the ramp because it must not
invert — a brand's violet is that violet in either polarity. These are foregrounds on surfaces that
do invert, so a value that stayed put would be dark text on a dark background in half the themes.
Step 700 is a long way from 500 precisely because it swings: L 42% light, L 58% dark, which is the
accent reading dark on white and light on black. The rule underneath both is now recorded on the
roles themselves: a role that names a thing is absolute, and a role that names a relationship to
the surface it sits on
is a scale position.

Also closed, and not originally recorded: the enforcement gap. Round eleven found 197 nodes that
had drifted back to scale positions, swept them at source, and made the audit a test rather than a
script. That was listed above as the sharpest weakness in the design; it is the templates half of
it, and the design-system half is in the open list.

Not this branch's to fix: packages/graph-system/core has six pre-existing typecheck errors in
its test files, on dev as well as here.

Test plan

  • pnpm lint clean; pnpm build clean across the whole workspace; typecheck clean on every
    package but the pre-existing @we/graph-core failures; every suite green.
  • pnpm --filter @we/schema-shared validate — 28 schemas, no issues.
  • New tests: every built-in theme against the nine declared contrast pairs, and
    pickReadableForeground including the mid-click case (@we/themes); the 1→2 rename as a
    permutation, version stamping, a theme from the future, and a corrupt blob (@we/themes);
    colour parsing, conversion round trips, OKLCH round-trip exactness and its perceptual evenness
    versus HSL, WCAG reference values, translucent compositing (@we/design-utils); the tier ladder
    including the relative rung, and the roles-object update (@we/editor); camelCase-role
    rejection (@we/schema-shared).
  • The three-click path to an invisible theme — Dark, then a light base preset — as a test on
    reconcileSurfaces, along with the case it must not touch (re-picking within one polarity,
    which has to leave a tuned stack alone).
  • In a browser on a cleared profile, after the ramp move: hue 266, accent rgb(64,104,215),
    and channels' measured grey back at rgb(26,28,33) against rgb(28,27,29) before — the
    theme looks like itself.
  • The derived stack measured in Chrome across five parameter sets — light, three dark
    subtractors, and a heavily tinted one — all four roles in order in every case, and the tint
    carried through.
  • The elevation ordering as an invariant over every built-in theme — it caught three shipped
    dark themes with their surface stack inverted, which is what a value-faithful migration hides.
  • pnpm --filter @we/schema-shared surface-audit — imports every schema, walks the composed
    tree, reports the nearest painting ancestor of each surface-sunken node. 389 → 86, the
    remainder being re-export duplicates and deliberate inset chips.
  • The follow-up round, each verified by the thing it was about rather than by the suite passing:
    the contrast suite's call into deriveRoleVars by mutation (disabling the foreground
    correction produces the exact Lc 45 measured in a browser when the derivations were not
    running); the focus change by measuring the ring in Chrome (rgb(112,94,201), the pinned
    accent, where it was rgb(127,114,206)); the ROLE_ALIASES re-statement by a test that
    failed on first run and found two live drifts; the pin audit by measuring every role of
    every built-in theme in Chrome before and after — byte-identical across all seven themes,
    which is what "these pins were saying nothing" has to mean.
  • The "Follow system" pair driven through the shell's own picker in Chrome: both selects render
    with the right labels, all seven themes plus "Built-in light or dark" as options, and the
    built-in entry selected while nothing is set.
  • Not verified: "Follow system" repainting when the OS setting changes while the app is open.
    The preview harness boots with a pinned theme (data-we-theme=channels), so the document never
    follows system there and the flip cannot be observed — measured identical on the commit
    before this feature, so it is a limitation of the harness rather than a regression, but it is
    the one claim in this area that is asserted in comments and not by a test. Worth a manual pass:
    pick Follow system, set the dark half to a custom theme, and toggle the OS setting.
  • pnpm --filter @we/schema-shared role-audit — imports every schema, walks the composed tree,
    reports every bg/color/border naming a scale position. 197 → 0, and now a test (~2s)
    rather than a script. Mutation-checked: restoring one of the four defaults reports all 108 of
    its call sites by name.
  • Fifteen browser measurements pin the contrast resolver — three scale positions across five
    themes, sampled from Chrome, tolerance 1 rgb. The only assertions in that suite an internal
    change cannot satisfy by moving both sides at once; everything else grades from resolve().
    Mutation-checked: a broken chroma taper fails all fifteen.
  • Guards for the four things round eleven found, each of which fails invisibly by construction —
    a template with no node ids renders identically and cannot be clicked; a panel painting its own
    background is only wrong next to another panel; a spacing prop naming a token that does not
    exist is silently dropped. All three assert on source, because all three are the absence of
    something, and each was mutation-checked by reintroducing the defect.
  • Guards for two things that fail invisibly by construction: a multi-value part never matched by
    an exact-match attribute selector, and a built-in theme declaring a role in its CSS its preset
    does not pin.
  • In a browser, shape: 16 computed-style assertions — avatar host and picture follow the
    avatar group, image/iframe/inner-iframe follow the surface group, r='avatar'/r='media'
    resolve, heading font separates from body.
  • In a browser, padding: tab 8px 12px8px 24px under spacious controls; input and
    textarea follow inputSpacing; a pinned surface-hover reaches a hovered input.
  • In a browser, role coverage per route on the default template, every role pinned to a
    sentinel: About 85/86%, Cards 88/90%, Tasks 85/84%, Calendar 89/91%, Settings 92/93%
    (backgrounds/text), with the screen unchanged when the pins are removed.
  • In a browser, the tooltip is dark-with-light-text in both polarities; switching theme
    mid-edit repaints (and with the fix reverted, provably does not); the picker's token grid emits
    var(--we-color-success-500) and its colour area emits #bf2626; the swatch is square and
    painted, the token grid is styled, both dismissal paths work, and the format control renders
    as buttons.
  • In a browser, after the reclassification: the header band, the sticky nav and the section
    cards no longer paint the sunken sentinel, and the dark theme's stack measures in order
    (sunken 0.0057 < page 0.0077 < surface 0.0134 < raised 0.0225 relative luminance).
  • In a browser, on both branches: every built-in theme's neutral ramp captured step by step
    through the real picker and diffed. All seven now within 2.2 mean |ΔL| of the old build
    (Black 12.7 → 2.1, Cyberpunk 13.7 → 2.2, Channels 14.2 → 1.8; the other four unchanged).
  • In a browser, the derivations proven to run: --we-role-text-muted resolving to a corrected
    literal rather than its declared expression, and muted text at Lc 60–61 on every theme where it
    had been 20 / 27 / 33 / 45.
  • In a browser, APCA on five declared pairs across all seven themes — every one clears, with
    the single recorded exception of dark's primary button label at Lc 29, asserted as an equality
    so it cannot drift in either direction.
  • In a browser, theme isolation: the same theme applied to two elements while the document
    publishes different chroma ceilings. The declared-only element washes from rgb(19,156,46) to
    rgb(99,142,100); the applied element holds rgb(59,152,67) in both. This is the property the
    preview, the swatches and the picker were each missing.
  • In a browser, the accent follows its hue slider on all three themes that pin one, and
    accentLightness reaches a bright green (#008e00 → #60f16e across its range).
  • In a browser, performance measured on a 583-node document: 45.2 ms → 0.6 ms per apply, with
    the colour derivation itself at 0.24 ms of that.
  • In a browser, the colour picker's popover tracks its trigger inside a we-scroll-area at
    any scroll offset (was 352 px adrift at scrollTop 400).
  • New tests this round: dock-registry notification on late registration and removal (4);
    the theme-id resolution facts behind the boot flash (3); the cross-fade suppressed for an edit
    and kept for a switch (2); the split status labels as declared contrast pairs (28 more rows).
  • Not verified end to end: the theme editor driven through its own UI in we-preview — the
    pencil only appears for non-built-in themes, so it needs a fork first. Every fix in it is
    unit-tested and measured through the primitives directly, but the assembled panel has been
    reviewed on screen by the author of this branch's user, not by its harness.

jhweir added 3 commits August 23, 2026 18:23
Three things it was wrong or silent about, all of which this branch changed.

It named `onStatus`, a role that no longer exists — it split into onDanger,
onSuccess and onWarning, because one label shared across fills at three different
lightnesses is a compromise rather than a choice.

It listed four rungs for pinning a role and there are five. A role stated as
another role — `surface: var(--we-role-page)` — is how channels says its channel
list and its page are one sheet, and how timeline says it has no elevation; two
presets use it and the table did not mention it. The table also ranked a scale
position above a relative form, which is backwards now that the distinction
matters: it follows the theme's parameters but not its decisions, and the
derivations skip it entirely.

It said nothing at all about the fill lightnesses. That is the branch's main
addition to what an author can state, and it is the answer to the most common
thing they want — a fill that is the wrong weight rather than the wrong colour —
so its absence pushed people to pin the role instead, which opts the fill out of
the label and state derivations. Making a delete button brighter that way is also
how you make its label unreadable.

The example pinned `accent` to a literal, which is the one move the doc warns
against two sections later; it now shows a fill lightness and a relative pin. And
the boundaries section gains the limit an author actually hits: a theme cannot
reshape the ramp.
Collapsing the preview and expanding it again showed the app's chrome theme
instead of the space theme being edited.

The preview lives inside a `Show`, so collapsing destroys the element and
expanding mounts a new one. The element was held in a plain `let` and the effect
that applies the theme tracked `overrides()` alone, so reopening assigned the
variable and nothing re-ran: the fresh element never had `applyThemeVars` called
on it. Custom properties inherit, so it did not render unstyled — it quietly took
the ambient theme, which is the worst version of this failure, because a preview
showing you the wrong theme confidently is harder to notice than one showing
nothing.

Holding the element in a signal makes the effect depend on *which* element as well
as on what to put on it, so a remount re-applies by construction. That is the fix
rather than a style preference: what was missing was a dependency, and the only
thing that keeps a dependency present is needing it to read the value.

Not test-guarded, and worth being straight about why: the failure is the *absence*
of a reactive dependency, and there is nothing to assert about an absence short of
rendering the panel and toggling the section, which the editor has no harness for.

The `roleProbe` beside it keeps its plain `let`. It renders once at panel level,
outside every toggle, so there is no second element to miss — and its effect re-runs
on `overrides()` and `dragging()`, so even a remount would be corrected by the next
edit. Noted in place, since the two now look inconsistent and the reason they differ
is exactly the bug above. `SchemaRenderer`'s themed-node wrapper is a third instance
of the shape and is safe for a different reason: its element and its effect are
created together in the same closure, so neither can outlive the other.
**Six pre-existing typecheck errors in `@we/graph-core`'s test files**, identical
on `dev`, which this branch had been recording as out of scope. They are three
mistakes:

- A counting wrapper around a `SeedSource` called the thing it wraps with no
  arguments. `seed(options, context, signal?)` takes two or three; it now forwards
  what it was handed, which today is equivalent because `mutableSeed` reads
  neither, and is only accidentally so.
- A node-data factory typed its parameter `Record<string, unknown>` where
  `GraphNode.data` is `Record<string, GraphValue>`. The narrow type is the point:
  it is what lets a style rule resolve a field without a runtime check.
- A shared fixture `{ id, type }` was passed where `GraphNode | GraphEdge` was
  expected. It satisfies neither — no `kind` for the first, no ends for the second
  — and the error named only the second, which is why it read as being about edges.
  Typed as the `GraphNode` it is.

Fixed by making the test data satisfy the types it is passed as, rather than by
casting, since every one of them is the test describing the contract wrongly.

**And one that is this branch's**, which the same run caught: the portable-UI
playground implements `ThemePort` by hand and was not given `focusedRole` /
`focusRole` when the role-chip jump added them. Inert there rather than stubbed —
the playground has no theme editor to reveal a role in, so it answers by never
reporting one.

Worth recording how that got through. `pnpm build`, `pnpm test` and `pnpm lint`
were run on every change; `pnpm typecheck` was only ever run filtered to the
packages being edited, and the playground is not one of them. The full recursive
typecheck is a fourth command and CI runs it.
@jhweir
jhweir merged commit f6be0be into dev Aug 23, 2026
5 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant