Feature: Theming shapes, roles, & accesibility checks - #127
Conversation
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>
… 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.
✅ Deploy Preview for coasys-we ready!
To edit notification comments on pull requests, go to your Netlify project configuration. |
Theming — verification, bugs found along the way, and follow-upsThe second half of the description for this PR. Split off because GitHub caps a body at 65,536 Bugs found and fixed along the way
Known follow-upsOpen — for after this branchThree things this branch establishes are worth doing and out of scope here. The first is the only
A note on why the two-layer split itself is not on this list. Scale positions following the Closed — everything this branch recordedWhat each turned into, since three of them were not what they looked like:
One recorded follow-up turned out to be a non-issue, and the reasoning is worth keeping. Also closed, and not originally recorded: the enforcement gap. Round eleven found 197 nodes that Not this branch's to fix: Test plan
|
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.
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
avatarRadiusis its own group. Avatars are the only components guaranteed square, and that iswhat makes a percentage radius safe:
50%resolves per-axis, so it is a circle on a square box andan 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 second50%(winningfor exactly the avatars that have a picture, the reported symptom),
we-videopainted over[part='base'], andwe-iframehad no base at all, so every visual prop it accepted was discarded.Media joins
surfaceRadiusrather than taking a fifth group.r: 'avatar'/r: 'media'resolveto the group variables for layer-4 components, where there is no cascade entry to inherit from.
Two dead density slots.
inputSpacinghad five components declaring it and no theme key able toset it. Tabs got the
we-buttontreatment — own vertical, control-group horizontal — becausecontrolPaddingXmeans "breathing room either side of a label" and buttons apply it with a hard 0vertical 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 becauseaccentissized for a fill),
dangerText/successText/warningText(status as a foreground; the status rolescovered a tinted panel only, and danger text appeared as danger-400, -500 and -600 in neighbouring
files),
accentHover/accentActive, andsurfaceInverse.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
hoverPropsis a state, not an elevation; a graph's node colours are a categorical palette (a nodepainted
warning-100because it is a note is not a warning); andneutral-50means the page at aroot, 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.scssalready knew it: theline 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-textinside a ghost button, so they inherited the button's unmigrated colour and pinningtextmoved 25 of 52 text elements. It also left components half-migrated —
alert.neutralread{ bg: 'page', color: 'neutral-800' }, a pair that is no longer a pair. The semantic pass takes theshade 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— whichreproduces the old neutral-300/400 to within a point and inverts correctly in a dark theme because
textdoes.isRawCSSValuelearned aboutcolor-mix()to allow it.rolesnow merges with a preset's instead of replacing it. Pinning one role onchannelsdiscarded 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-pickernow has a token grid (emitsvar(--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-utilsbecause the contrast checking in part five needs the sameconversions.
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'sbutton; 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 aneyedropper 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:
accentTextmeant the foreground you put on an accent fill, whileaccentStrongmeantthe accent used as text. Somebody reading the table would pick exactly the wrong one. They are now
onAccentandaccentText, withtextInversebecomingonInverseto match — a bare noun is a fillor a foreground in its own right,
on<Fill>is a foreground that exists to contrast with a specificfill.
controlSurfacewas added for the filled neutral of a control (a switch track, a progresstrough, 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.
schemaVersionandparseOverrides()now sit on every read path. The rename map is applied in one pass because itis a permutation — applying it sequentially would move
accentText's value into its own new slot andlose 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 to0ms, so every component that animates through a token stops at once), andforced-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 —
onAccentis defined as the thing that sits onaccent— so both sidesof 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.
applyAutoContrastpicksbetween 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 pin —
color-mix(in srgb, var(--we-role-surface) 88%, var(--we-role-text))— is theone 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 aneutral-50page that grey looked right either way. Sothe 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 —
cardShellandsectionCardare contributed from another package. Sopnpm --filter @we/schema-shared surface-auditimports each schema, walks the real composed tree, and reports the nearest painting ancestor of
every sunken node: a node whose parent is
pageis 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.
pageis neutral-50 andsurfaceis neutral-0, so the inversion flips their order: in dark a card lands below the pageand 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 ondark,blackandcyberpunk; all three now pin their stacks, and the editor's Light/Dark buttonsdo the same so a hand-flipped theme does not inherit the bug the presets just escaped. Equal is
allowed:
channelsshares one colour between page and card and separates with borders, which is adesign.
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. Itwalks 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 includedtests/**only, soThemePanel.test.ts— beside the module it covers, this repo's usual placement — had never onceexecuted, while reporting as a passing suite. Fixing the glob revealed the panels are not importable
under vitest at all (a
.tsxpulling in@we/components/solidruns a client-only API at modulescope), so the pure logic moved into
themeRoles.tsandpaintedRoles.ts. 41 editor tests now runwhere 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.
Checked against every shipped theme before it was adopted: light
100/100/90.4against100/100/90pinned, dark
14.1/19.5/6.9against14/19/8, cyberpunk15.2/20.6/7.9against15/21/8. Sodarkandcyberpunkdrop their stacks, and a theme built from scratch is correct with no pinsat 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-mixcannot do this job either — it interpolates betweentwo 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 alsocarries
candhthrough, so a tinted theme gets a tinted stack for free — verified in Chrome:page
rgb(38,20,46)→ surfacergb(49,31,58), still purple, which the scale positions never did.Two themes still pin, both with a reason.
blacksits at the sRGB floor, where a +0.045 stepand the page round to the same 8-bit value — no formula survives that.
channelswants its page andcards identical, separated by borders. The invariant permits equality precisely so it can say so.
An
@supportsblock returns the scale positions where relative colour syntax is missing, andapplyThemeVarsrepeats the feature test because an inline custom property outranks the stylesheetthat 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
500measured 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,warningTextandsuccessTextsat at-600,-700and-800— three steps for one concept, because green and amber are lighter than red at thesame number. They share
-700now. That is what the change buys, and it is the same shape as everyother 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.
chromaTaperreproduces what HSL'ssaturation × min(l, 1−l)did implicitly, sinceOKLCH chroma is absolute and pale steps would otherwise sit outside sRGB. The
CHROMA_PER_SATURATIONconstant is calibrated: at saturation 50 it puts step 500 at chroma 0.140where the old ramp measured 0.141.
Two role resteps fell out of it.
accentmoves 500 → 600, because on a perceptual scale the stepthat 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 theunitless number a chroma has to be.
migrate.tsv3 handles both.Four silent bugs surfaced on the way, each worth recording:
channelscarriessaturation: 85— ordinary under HSL, wheresaturation 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.
parseOverridesread them as v1 and ranevery migration over them — converting an already-converted hue a second time, 266 → 299.
themeToStylere-declares the palette formulas for scoped themes and was still emitting HSL,quietly overriding the generated OKLCH at runtime.
channels,timelineand the fixed roles were HSL percentagesbeing 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
ThemeOverrideschanges and therefore cheapest before anyone has authored a theme.The parametric model was the weakest part.
multiplierwas1or-1in every preset — aboolean typed as a number — and
subtractor: '112%'meant "reflect the ramp and offset it", whichis 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,lightnessFloorandlightnessCeiling. Same arithmetic, exact conversion, and the two bounds turnout to be the contrast control the system had by accident and could not reach.
Status colours had no fill.
accenthad six slots;dangerhad two. A theme could completelyrestyle the primary button and could say nothing about the destructive one, which was hardcoded to
danger-500. There aredanger/success/warningfills now, with one derivedonStatusbetweenthem 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
blackandchannelsat 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 (
fontScalemoved both, since the steps are inrem);disabledOpacityreplaces a hardcoded0.5— as a control, not the role I first proposed, becauseone colour cannot serve a disabled ghost button and a disabled danger one; and
ringColoris gone,having been a second way to say the
focusrole 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: 100gave the violet 70% of its range and stoppedaffecting the teal at about 29.
applyThemeVarsmeasures the sRGB boundary per hue and publishes aceiling 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 (
dangerhadgrown a hover and an active that
successandwarninglacked), the focus ring's width becamethemeable now that a border's is, and three controls moved to the heading they belong under.
Modules can theme their own components.
COMPONENT_CASCADEwas a closed literal, so a featuremodule 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.
registerComponentCascadeopens 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
blackandchannelshad 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-0came 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-0means the dark end in a dark theme. A scale position inverts, so it is the lightestcolour 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 — fora parametric role, a literal string beginning
oklch(calc(….parseColorreturns null for it, andeach derivation hit its
continue. SoapplyLegibleFills,applyAutoContrast,applyLegibleForegroundsand the state direction did nothing at all, except where a role happened tobe 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 onchannels, 33 oncyberpunk,45 on
dark, against a threshold of 60. All four read 60–61 now. The contrast suite resolves thoseexpressions 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-colorandread 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.
pickReadableForegroundscored with WCAG 2while 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:
onStatussplit intoonDanger/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_CONTRASTdropped the hover and pressedvariants, 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.
themeToStyleis nowthemeParametersToStyle. It returns a theme's parameters and role defaultsand 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% / ceiling110% by arithmetic, which reads an HSL lightness as though it were an OKLCH one.
darkwas refittedby hand at the time and the others were not. Mean |ΔL| across the neutral ramp against the old build:
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.
blacklost four pins in the process, with a convincing note attached: "its page is pure black, anda +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
blackpins a lightness, which was asserting the defect.Performance, which the working derivations exposed
A write to
root.styleinvalidates the document, so the nextgetComputedStyleforces a synchronousrecalculation of it. The four derivations each read a role, computed, wrote it, and read the next.
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
deriveRoleVarsis pure arithmetic over what it answered.Separately, the primary button lagged behind the text beside it during a drag —
--we-theme-switch-durationwas raised on every application, so each frame re-armed a 250 mscross-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
edgeas a stringkey into
hostDockStores, a plain object; if the store is not there yet the resolving memo nevertouches the accessor, registers no dependency, and cannot recover.
ShellStoreProviderwrapsEditorStoreProvider, so this was guaranteed rather than racy — the theme panel could not beopened at all, and opening any other panel was the only escape. Registration is observable now.
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-areain the test, which is what the real panel uses.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.
different material from every module panel at the same edge.
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.
polarity, and it silently froze every theme's accent at one lightness. Forking dark and dragging the
hue to green gave
#3a862dwith nothing able to brighten it;accentLightnessis that third axis.builtInThemes, so on a fresh account it was the most prominent row in the picker, above everyactual 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
automaticThemessection now, rendered last in both the picker and the settings list. Kept in thepicker 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.
page, somoving 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 inwhichever 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-kitis invisible to any grep overthe route that renders it, so the audit imports each schema and walks the real composed tree, the
way
surface-auditdoes.They were not 197 decisions. Four defaults:
statChip's iconneutral-600text-mutedattributeRow's iconprimary-600accent-textneutral-100/primary-600accent-muted/accent-textneutral-300text-faintThe 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.
statChipis the one worth looking at twice — its icon wasneutral-600while the label an inch toits right was already
text-muted, so the two disagreed in every theme, and nobody noticed becausein the theme it was written in they happen to land close together.
The audit runs in ~2s, so unlike
surface-auditit is a test rather than a script somebodyremembers 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.
channelssetsits surface equal to its page;
timelinesets its to pure white; a node naming a step hears neither:In Channels a card painted
neutral-100is a dark hole punched in a design whose premise is that thecard 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-600is never measured against what is behindit, 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
chromaTapertook 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 itfollows the ramp exactly.
They agree wherever the ramp is the identity, which is
lightandretro, and that is why itsurvived. On
dark's step 200 they are 2.3× apart: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.
chromaTapernow takes a resolved lightness rather than a step name, which makes the mistakeunspellable: 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 internaldisagrees 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,cyberpunkandchannelsas badly wrong. They were not — thosethree 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: surfaceand nothing else. The background wasright — 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.scsssetsbody { color: var(--we-role-text) }, and most text in the app says nothing aboutits own colour, so it is painted by the document. The readout walks the schema and only the schema,
found no
colorin the chain, and reported nothing — indistinguishable from "this element has notext colour". The timestamp beside it looked correct only because its node happens to set
text-mutedexplicitly.
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
pagewould be a guess about a root thefragment 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
ghostbutton does — soit 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:
node.idis what the renderer stamps asdata-we-node-id, and it is the whole handle the visual editor has. Six setters wrote the livetemplate and three had forgotten to call
ensureNodeIds;saveTemplateAswas the one thatmattered, 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.surfaceover the dock frame'ssurface-sunken, soit sat lighter than every neighbouring panel. The code and theme panels had each been corrected
before, separately — three reports of one drift.
gap="150"twice andpx="150"once resolve tovar(--we-space-150), which does not exist, so the chips sat flush withno padding. They were the only three in the repo.
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.