Skip to content

CORE-2720: Add a global CSS token file for theme values and sweep migrated CSS onto it - #143

Open
OpenStaxClaude wants to merge 9 commits into
CORE-2710-compose-render-props-stylefrom
CORE-2720-global-css-theme-tokens
Open

CORE-2720: Add a global CSS token file for theme values and sweep migrated CSS onto it#143
OpenStaxClaude wants to merge 9 commits into
CORE-2710-compose-render-props-stylefrom
CORE-2720-global-css-theme-tokens

Conversation

@OpenStaxClaude

@OpenStaxClaude OpenStaxClaude commented Aug 31, 2026

Copy link
Copy Markdown
Contributor

Summary

Follows up on Beth's review comment on #130: "it might be good to move theme variables we want to keep using (like the color palette) to a global css file that it can reference … seems like we could end up with a lot of hardcoded colors, spacing, etc."

She was right, and the evidence was already in the migrated files. Before this PR, the 22 migrated stylesheets contained 86 hardcoded hex literals, nearly all of them palette values re-typed by hand:

  • palette.darkRed in four files in two casings; palette.paleRed as both #FBE7EA and #fbe7ea
  • palette.white as #ffffff (16×), #fff (4×), and the bare keyword white (2×)
  • Copilot's review on CORE-2004: Migrate NavBar components to plain CSS #130 caught three var() fallbacks that had already diverged from the JS values the component was binding. Nothing in CI would have caught them.

What changed

src/theme/theme.css — a :root block projecting the JS theme into --ox--prefixed custom properties (palette, colors.link, zIndex, padding.navbar). Prefixed so it can't collide with a consuming app's variables. Generated, not written: src/theme/themeCss.ts owns the projection and scripts/generate-theme-css.bash writes the file, as the first step of scripts/build.bash (and so of publish.bash, via build:clean). It is committed as well as generated, because jest and ladle read src/ directly and CI runs lint/test rather than build — the build covers the published artifact, the spec below covers everything else.

src/theme/tokens.spec.ts — the part that makes this stick. Fails the build if:

  1. the committed theme.css is not what the generator produces from the JS theme
  2. a component stylesheet writes a colour literal that duplicates a theme value
  3. a component stylesheet introduces a colour that is neither a theme value nor on the KNOWN_OFF_PALETTE allowlist
  4. a component stylesheet reads an --ox-* token that does not exist

Adding a new colour is therefore deliberate: put it in palette.ts, or in the allowlist with a reason.

The colour check parses declarations rather than grepping for hex, so it covers every syntax a colour can be written in — hex, rgb()/hsl()/oklch()/color(), and bare named colours wherever they appear, shorthands and gradient stops included. Functions that merely contain colours (var(), color-mix(), the gradients) are descended into rather than treated as literals. A translucent colour passes when its opaque channels are a theme value — rgba(0, 0, 0, 0.2) is black at 20% and has no token form — which still refuses a new hue smuggled in through rgba(). The checker has its own tests (40 cases, what it must flag and what it must leave alone), so the guarantee above is tested rather than asserted.

Four orphan colours moved into palette.ts. #be3c08, #b03808, #646464 and #4c4c4c were string literals in theme/buttons.ts with nothing recording that they're hover/active variants of palette.orange and palette.darkGray. They're now orangeHover, orangeActive, darkGrayHover, darkGrayActive, grouped under a // button variants comment — purely additive to the published palette. (Roy renamed these from my original darkOrange/darkerOrange/mediumGray/darkerGray in cbf780d; his names say what the colours are for rather than just where they sit on a lightness ramp, which is the better call.)

All 22 stylesheets swept onto tokens, including inside var() fallbacks.

Static colours no longer travel through React inline styles. The --component-* override hooks stay exactly as they were — only their defaults moved to the CSS side:

/* before: JS bound '--tabs-active-border-color': palette.darkGreen on every render */
.tabs [role="tab"] { border-color: var(--tabs-active-border-color, var(--ox-color-dark-green)); }

Genuinely dynamic bindings stay in JS: button/checkbox variant lookups, navbar height and maxWidth, disabled opacity, the dropdown caret colour.

README gained a Styling section documenting the pattern, so the remaining ~29 files are migrated against tokens rather than against hand-copied literals.

Two things worth a closer look

1. A latent bug fixed in passing. --button-shadow is shared by Button.css and DropdownMenu.css, and the JS binds palette.black for all three variants. Button's fallback had drifted to #424242 (neutralDarker) while DropdownMenu's was #000000. Unreachable today because the JS always sets the variable, so this is a zero-visual-change fix — but the two files would have rendered the same variant differently if it were ever reached.

2. One edge-case behaviour change. Removing an inline default changes who wins the cascade. If a consumer set, say, --tabs-border-color on an ancestor element via their own stylesheet, the inline default used to beat it; now the ancestor's value applies. That is arguably the fix rather than the regression, and passing style={{'--tabs-border-color': ...}} to the component itself — the documented override path, covered by specs — is unaffected. Flagging it because it is the only way this refactor is observable from outside.

Verification

  • npm test — 365 passed, 40 suites, 115 snapshots. 45 snapshots regenerated; every diff is an inline style attribute losing a static custom property. No DOM structure changes.
  • npm run typecheck and eslint over all changed files — clean.
  • npm run build — succeeds; theme.css lands in both dist/esm/theme/ and dist/cjs/theme/, and the side-effect imports survive as import '../theme/theme.css' (ESM) and require("../theme/theme.css") (CJS).
  • Specs that asserted the inline default were rewritten to assert what actually matters — that the override still works — with a comment pointing at tokens.spec.ts as what now guards the defaults.
  • Ladle pixel diff — all 105 stories captured on this branch and on main with Playwright at 1280×800, animations paused, then compared. 100/105 pixel-identical. The 5 that differ (loader--default, loader--large, error-modal--default, forms--data-references-in-namespaces, sidebar-nav--using-body-portal) differ by the same amount when main is captured twice and diffed against itself, so they are animation-frame and timestamp nondeterminism, not this change. That control run is the reason I trust the other 100.

A pre-existing flake, now fixed upstream. ProfileMenu › matches snapshot with user initials failed 3/3 runs with a cold jest cache, on data-focus-visible / data-focused being absent from the trigger — react-aria's focus state is timing-sensitive and the snapshot captured it. I first hit it when jest -u stripped those attributes and reddened a push (restored in 542b66e3), and originally wrote it up as an environment quirk of my machine; that was wrong, it reproduced on main independently of this branch.

#138 has since landed on main and replaces that snapshot with targeted assertions, which is the proper fix. It is not in this stack yet — see the note below — so a cold-cache run here still hits it.

Plumbing note

scripts/build.bash rsyncs .css 1:1 with no CSS bundler, so a CSS @import would be at the mercy of each consumer's resolver. Component .tsx files import theme.css alongside their own stylesheet instead — same pattern already in use, covered by the existing sideEffects entry, and no action needed from consuming apps.

Review follow-ups

Both threads on the first review are addressed in b9657d7, which touches only the theme plumbing — no component CSS or snapshots changed, so the pixel-diff evidence above still holds.

  • Roy — generate theme.css from the JS theme. Done, as described above. Removes the class of mistake rather than detecting it: you can no longer add a palette entry and forget the CSS. The one deliberate loss is the per-token trailing comments (/* darkGray, hover */), which would have needed either scraping palette.ts source or a second copy of the notes; they remain on the palette entries themselves.
  • Copilot — non-hex colour syntaxes bypassed the check. Correct, and the README claimed a guarantee the code didn't provide. Fixed by the declaration parser above. #00000033 came off the allowlist as a result — it now passes on the alpha rule instead of by exception, leaving just the two genuinely off-palette greys.

Second review, addressed in f12703b:

  • Roy — "it would have to be part of the build process." build.bash now regenerates theme.css before either tsc pass and before the rsync into dist/. Verified by corrupting the file: spec fails, npm run build restores it, both dist trees get the 48 tokens. ~1.7s on a build that already runs tsc twice.
  • Roy — "or have both files generated from some raw data file." Considered and not taken, with reasoning on the thread: theme.ts isn't pure data (zIndex is computed, defaultFocusOutline is a CSS fragment), so a data file would split the source of truth rather than centralise it; and palette.ts is published API whose as const literal types and per-entry comments consumers rely on. It is a cheap change to make later if the palette ever needs a non-TS consumer — themeCss.ts is the only thing that would move.

Third review, addressed in 652ed43:

  • Copilot — as string casts on describeColor(...).hex. Right, and the more dangerous of the two, because the cast is compile-time only so nothing would have thrown. An unresolvable theme colour would have entered themeValues under a null key and silently stopped being recognised — every stylesheet using it would then report it as off-palette, a failure a long way from its cause. The map now filters unresolvable entries out and a spec asserts there are none. Verified by pointing palette.red at oklch(): the suite fails naming --ox-color-red instead of misbehaving downstream.
  • Copilot (suppressed) — dead tokenised flag. Also right, as a consequence of Roy removing the var() short-circuit in 635f854. Field and guard removed; a no-op, since an unset optional was always falsy. Added a case pinning the behaviour that replaced it — rgba(var(--channels), 0.2) is asserted to be flagged rather than skipped, matching the reasoning in Roy's comment.

Fourth round — two defects I found and reported myself, fixed in 08ee93a. Both surfaced while answering a review question on the rex-web sibling (#3133), where Copilot raised them; the checker there is the same design. Tests were written failing first, so both are demonstrated rather than asserted.

  • parseHex validated the expanded length, not the digits. #ggg expanded to gggggg, passed the length test and came back as a colour — defeating unresolvableThemeColors, whose whole job is to stop a malformed palette value entering themeValues under a key nothing can match. Now checked against the hex grammar first.
  • The declaration walk discarded the property, so any identifier that happens to be a CSS named colour was read as one: animation-name: red reported as off-palette, font-family: white as duplicating --ox-color-white with a suggested fix that would break the declaration. The walk returns the property now, and a bare identifier is only read as a colour where one can go. Only bare identifiers are gated — hex and the colour functions stay in scope everywhere, including animation-name: #d5d5d5. Four cases pin that the gate didn't weaken real detection.

Both were latent — nothing in this repo's CSS triggers either — but the second would have bitten the first component that animates something named after a colour.

Stacked on #137

Base is CORE-2710-compose-render-props-style, per Roy's request. Rebased rather than merged, so history stays linear.

main
└── #137  CORE-2710
    ├── #141  CORE-2006 (unchanged)
    └── #143  CORE-2720  ← this PR
        └── #140  CORE-2005

The two overlapping files resolved in opposite directions:

#140 was based on this branch, so it was rebased onto the new head (39732916194fd1) and its base restored; its content is unchanged and 380 tests pass there. GitHub blocked the base change until the auto-created stack #144 was dissolved with gh stack unstack; #137/#141's stack was not touched.

Keeping up with the base

#137 was force-pushed onto a newer main, so this branch was rebased onto its new head (c466a0af7f8bdfb3).

#139 landed on main and deletes HelpMenu/__snapshots__/index.spec.tsx.snap, which this PR edited because the sweep changed values inside it — a modify/delete conflict, resolved by taking the deletion. That is the ordering outcome predicted when this was planned: with #139 in first, this part of the diff disappears instead of being churn thrown away later. One fewer changed file here.

#138 is queued to do the same to the ProfileMenu snapshot. #137's base is c6ed1cd8 (#139's merge), one commit behind main, so it does not yet include #138. This PR still edits that snapshot — correctly, since the file is live at its current base — and will hit the identical conflict once #137 catches up, resolved the same way (take the deletion, which also makes 542b66e3 moot). Rebasing #137 onto current main clears that and the flaky test with it; not done here because #141 is also based on #137 and would have to move too.

Sequencing

This wants to land ahead of the remaining migration subtasks. Done now, the remaining ~29 files are written against tokens at no extra cost; done at the end, all 51 get re-touched in a second pass of pure find-and-replace noise. #140 (CORE-2005) has already adopted the tokens and is stacked on this PR. #141 (CORE-2006) sits alongside this PR on #137 and will need a small follow-up to adopt them.

Related

🤖 Generated with Claude Code

This comment was marked as resolved.

RoyEJohnson

This comment was marked as resolved.

OpenStaxClaude added a commit that referenced this pull request Aug 31, 2026
…ur syntax

Addresses review on #143.

theme.css is now generated rather than hand-written and kept in sync by a test
(RoyEJohnson's suggestion). src/theme/themeCss.ts owns the projection from the JS
theme into --ox-* custom properties; `npm run generate:theme-css` writes the file;
tokens.spec.ts asserts the committed file equals the generator's output. That
replaces the three "do these two agree" tests with one that cannot be partially
satisfied, and it fixes the stale src/theme/theme.css.spec.ts reference in the
header comment, which Copilot caught.

The component-CSS colour check now parses declarations instead of grepping for
hex. It resolves hex, the functional notations and bare named colours anywhere
they appear — shorthands and gradient stops included — while descending into
var()/color-mix()/gradients rather than treating them as literals. A translucent
colour passes when its opaque channels are a theme value, so shadows keep working
without allowlisting each alpha, but a new hue via rgba() is still refused. That
lets #33 come off KNOWN_OFF_PALETTE. Also added: a check that every --ox-*
a stylesheet reads actually exists.

The checker has its own tests now — 28 cases covering what it must flag and what
it must leave alone — so the guarantee is tested rather than asserted.

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

This comment was marked as resolved.

This comment was marked as resolved.

This comment was marked as resolved.

RoyEJohnson

This comment was marked as resolved.

@OpenStaxClaude
OpenStaxClaude changed the base branch from main to CORE-2710-compose-render-props-style September 1, 2026 18:48
OpenStaxClaude added a commit that referenced this pull request Sep 1, 2026
Stacked on #143, so these three components use the :root token file rather
than binding theme values inline per component -- the same sweep #143 applied
to everything already migrated.

- SidebarNav/ButtonNav/Pagination CSS now reads var(--component-hook,
  var(--ox-token)), and the inline CSSPropertiesWithVariables objects are gone.
- The sidebar backdrop z-indexes stop being literals. That caveat only existed
  because component-scoped custom properties inherit down the tree and the
  backdrop rules target siblings of the nav; :root tokens reach siblings fine,
  so these are now calc(var(--ox-z-index-navbar) + 1) and
  calc(var(--ox-z-index-sidebar) - 1).
- #959595 on the toggle button border is genuinely off-palette, carried over
  verbatim from the styled-components original, so it goes in KNOWN_OFF_PALETTE
  with a reason rather than being snapped to neutralMedium, which would be a
  visual change rather than a refactor.

The three "binds theme values as custom properties" tests are dropped: there is
no inline binding left to assert, and tokens.spec.ts now checks the theme wiring
centrally. Replaced where a public contract survives -- that consumers can still
override through the documented --sidebar-nav-* / --pagination-* hooks.

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

Copy link
Copy Markdown
Contributor Author

Stacked onto #137 as asked. Base is now CORE-2710-compose-render-props-style; the branch was rebased, not merged, so the history stays linear and your three commits (df59d0e, 635f854, cbf780d) are preserved with your authorship.

main
└── #137  CORE-2710
    ├── #141  CORE-2006 (unchanged)
    └── #143  CORE-2720  ← this PR
        └── #140  CORE-2005

Pre-rebase SHA was 08ee93a if you want to unwind it.

The overlap was real, and one part of it needed a decision

NavBarMenuButtons.tsx and Tree/TreeCheckbox.tsx conflicted, in opposite ways.

TreeCheckbox merged cleanly in substance. #137 wraps the style object in composeRenderProps; this PR removes the static entries from that object. Result keeps both: your wrapper, carrying only the bindings that genuinely vary at runtime. All of #137's TreeCheckbox tests pass untouched.

NavBarMenuButtons is where they genuinely collide. This PR removes every custom property that component set, so after the sweep there is nothing left for composeRenderProps to merge style with. style is no longer destructured at all — it passes through in ...props, and react-aria handles both the object and callback forms itself. The bug #137 fixes can't occur here anymore, because its cause was us overwriting the caller's style, which we no longer do.

That made three of #137's new tests fail — the ones asserting the component still sets its own variables inline:

● NavBarMenuItem › merges a render-callback style
● NavBarMenuItem › keeps merging an object style, caller last
● NavBarPopover › merges a render-callback style

Each failed on an expect(...getPropertyValue("--navbar-menu-item-hover-bg")).toBeTruthy()-style assertion. I removed those assertions and kept everything else in those tests — the render-callback and object forms are still asserted to reach the element, which is the contract #137 exists to protect, and tokens.spec.ts now guards the defaults centrally. There are comments at each site so it doesn't read as coverage quietly dropped.

Flagging it prominently because it is me editing tests from your PR. If you'd rather keep those assertions and have the NavBar variables stay in JS, say so and I'll reverse that part — it's a one-file change.

#140 came along

Its base was this branch, so rewriting my history would have left it showing a garbage diff. I rebased it onto the new head and force-pushed (39732916194fd1); its base is back to CORE-2720-global-css-theme-tokens and its own commits, including your a4546195, are intact. Nothing about its content changed.

Worth knowing: GitHub refused the base change outright — "Cannot change the base branch because the pull request is part of a stack" — because #143 and #140 had been auto-grouped into stack #144. I dissolved that grouping with gh stack unstack 144, retargeted, then rebuilt it. Both PRs stayed open throughout and no other stack was touched (#137/#141's stack #142 is untouched).

Verification

@OpenStaxClaude

This comment was marked as resolved.

@RoyEJohnson RoyEJohnson left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Resolve the conflicts

OpenStaxClaude added a commit that referenced this pull request Sep 1, 2026
…ur syntax

Addresses review on #143.

theme.css is now generated rather than hand-written and kept in sync by a test
(RoyEJohnson's suggestion). src/theme/themeCss.ts owns the projection from the JS
theme into --ox-* custom properties; `npm run generate:theme-css` writes the file;
tokens.spec.ts asserts the committed file equals the generator's output. That
replaces the three "do these two agree" tests with one that cannot be partially
satisfied, and it fixes the stale src/theme/theme.css.spec.ts reference in the
header comment, which Copilot caught.

The component-CSS colour check now parses declarations instead of grepping for
hex. It resolves hex, the functional notations and bare named colours anywhere
they appear — shorthands and gradient stops included — while descending into
var()/color-mix()/gradients rather than treating them as literals. A translucent
colour passes when its opaque channels are a theme value, so shadows keep working
without allowlisting each alpha, but a new hue via rgba() is still refused. That
lets #33 come off KNOWN_OFF_PALETTE. Also added: a check that every --ox-*
a stylesheet reads actually exists.

The checker has its own tests now — 28 cases covering what it must flag and what
it must leave alone — so the guarantee is tested rather than asserted.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
@OpenStaxClaude
OpenStaxClaude force-pushed the CORE-2720-global-css-theme-tokens branch from c466a0a to 7f8bdfb Compare September 1, 2026 19:29
OpenStaxClaude added a commit that referenced this pull request Sep 1, 2026
Stacked on #143, so these three components use the :root token file rather
than binding theme values inline per component -- the same sweep #143 applied
to everything already migrated.

- SidebarNav/ButtonNav/Pagination CSS now reads var(--component-hook,
  var(--ox-token)), and the inline CSSPropertiesWithVariables objects are gone.
- The sidebar backdrop z-indexes stop being literals. That caveat only existed
  because component-scoped custom properties inherit down the tree and the
  backdrop rules target siblings of the nav; :root tokens reach siblings fine,
  so these are now calc(var(--ox-z-index-navbar) + 1) and
  calc(var(--ox-z-index-sidebar) - 1).
- #959595 on the toggle button border is genuinely off-palette, carried over
  verbatim from the styled-components original, so it goes in KNOWN_OFF_PALETTE
  with a reason rather than being snapped to neutralMedium, which would be a
  visual change rather than a refactor.

The three "binds theme values as custom properties" tests are dropped: there is
no inline binding left to assert, and tokens.spec.ts now checks the theme wiring
centrally. Replaced where a public contract survives -- that consumers can still
override through the documented --sidebar-nav-* / --pagination-* hooks.

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

Copy link
Copy Markdown
Contributor Author

Resolved — MERGEABLE again.

The cause was that #137 was force-pushed (e18e5ec0590bda0d) onto a newer main, so this branch was sitting on a commit that no longer existed in its base. Rebased onto the new head; nothing here was merged, so history stays linear and your three commits keep their authorship.

c466a0af7f8bdfb3. Pre-rebase SHA is on backup/pre-137-refresh-143 if you want to compare.

The one real conflict

#139 landed on main and deletes HelpMenu/__snapshots__/index.spec.tsx.snap — the file this PR edits, because the token sweep changed values inside it. Classic modify/delete.

Resolved by taking the deletion. This is the good outcome I'd flagged when we discussed ordering: with #139 in first, that part of this PR's diff simply disappears rather than being churn that gets thrown away. #139's replacement spec asserts behaviour directly and touches no styles, so there was nothing to carry over. Net effect here is one fewer changed file.

Everything else replayed clean.

One thing you should know before merging the stack

#137 is one commit behind main — based on c6ed1cd8 (#139's merge) rather than ed2e43f5a, so it doesn't yet include #138, which deletes ProfileMenu/__snapshots__/index.spec.tsx.snap.

Two consequences:

  1. The same modify/delete conflict is queued up. This PR still edits that ProfileMenu snapshot, correctly, because the file is live at its current base. The moment CORE-2710: Compose render-callback style in react-aria-components wrappers #137 picks up CORE-2715: replace flaky ProfileMenu snapshot with targeted assertions #138, it'll conflict exactly like HelpMenu just did, and resolves the same way — take the deletion, which also makes 542b66e3 ("Restore react-aria focus attributes…") moot.
  2. The flaky test is still in this stack. A cold-cache full run here still fails ProfileMenu › matches snapshot with user initials — the flake I reported earlier, and the one CORE-2715: replace flaky ProfileMenu snapshot with targeted assertions #138 exists to fix. It passes warm and on CI, but it's noise for anyone running the suite locally on this branch.

Rebasing #137 onto current main clears both. I haven't done it: #141 is also based on #137, so moving it drags that PR along too, and that's your call on merge order rather than mine to make unasked. Say the word and I'll do #137, #141, #143 and #140 in one pass.

Verification

OpenStaxClaude added a commit that referenced this pull request Sep 1, 2026
Follows the CORE-2720 (#143) sweep, which this branch is now stacked on. The two
new stylesheets repeated eleven palette hexes as var() fallbacks; they now read
the --ox-* tokens instead, e.g.

  color: var(--help-menu-button-color, var(--ox-color-gray));

The override hooks are unchanged -- only their defaults moved from JavaScript to
the CSS side, so the components no longer bind static custom properties inline.
That means style is no longer destructured in ProfileMenuButton,
ProfileMenuItem, HelpMenuButton or HelpMenuItem: it passes through in ...props
and react-aria handles both the object and render-callback forms itself.

Two consequences worth naming:

- The CORE-2710 (#137) dependency is gone rather than deferred. The bug it
  guards against was a wrapper overwriting the caller's style, which these
  wrappers no longer do, so the menu items need nothing from #137. Same
  reasoning as the note #143 leaves on NavBarMenuItem.
- iframeWrapperStyle and putAwayStyle are gone; the iframe wrapper and the
  put-away bar take their colours from HelpMenu.css.

className composition stays -- that one is a real bug fix, not a default.

The specs that asserted the inline defaults now assert what matters instead:
the caller's style reaches the element in both forms, and the override hook
still wins. Defaults are covered centrally by src/theme/tokens.spec.ts, which
also fails on any colour literal that duplicates a theme value -- both new
stylesheets pass it.

Also carries CORE-2715 (#138) as a cherry-pick: it is on main but not yet in
this base, and without it the old flaky ProfileMenu snapshot fails against the
migrated component. It drops out as a duplicate when #143 rebases onto main.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
OpenStaxClaude and others added 9 commits September 2, 2026 20:26
Theme values were being copied into CSS by hand — 86 hex literals across the 22
migrated stylesheets, in inconsistent casings, with three var() fallbacks already
drifted from the JS values they duplicated (caught in review on #130, not by CI).

src/theme/theme.css projects the JS theme into --ox-* custom properties.
palette.ts stays the source of truth; tokens.spec.ts fails the build if the two
disagree, if a stylesheet repeats a theme value as a literal, or if it introduces
a colour that is neither a theme value nor on the KNOWN_OFF_PALETTE allowlist.

Static colours no longer travel through React inline styles. The --component-*
override hooks stay, with their defaults moved to the CSS side as
var(--component-x, var(--ox-color-y)); only genuinely dynamic bindings (variant
lookups, navbar sizing, disabled opacity) remain in JS.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
…ur syntax

Addresses review on #143.

theme.css is now generated rather than hand-written and kept in sync by a test
(RoyEJohnson's suggestion). src/theme/themeCss.ts owns the projection from the JS
theme into --ox-* custom properties; `npm run generate:theme-css` writes the file;
tokens.spec.ts asserts the committed file equals the generator's output. That
replaces the three "do these two agree" tests with one that cannot be partially
satisfied, and it fixes the stale src/theme/theme.css.spec.ts reference in the
header comment, which Copilot caught.

The component-CSS colour check now parses declarations instead of grepping for
hex. It resolves hex, the functional notations and bare named colours anywhere
they appear — shorthands and gradient stops included — while descending into
var()/color-mix()/gradients rather than treating them as literals. A translucent
colour passes when its opaque channels are a theme value, so shadows keep working
without allowlisting each alpha, but a new hue via rgba() is still refused. That
lets #33 come off KNOWN_OFF_PALETTE. Also added: a check that every --ox-*
a stylesheet reads actually exists.

The checker has its own tests now — 28 cases covering what it must flag and what
it must leave alone — so the guarantee is tested rather than asserted.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Roy's follow-up: "It would have to be part of the build process."

scripts/build.bash now runs scripts/generate-theme-css.bash as its first step, so
the file is regenerated before either tsc pass and before the rsync that copies it
into dist/esm and dist/cjs. scripts/publish.bash goes through build:clean, so a
published package cannot ship a stale theme.css even if the committed copy drifted.

The file stays committed as well as generated: jest and ladle read src/ directly
and never run the build, and CI runs lint and test rather than build. The freshness
check in tokens.spec.ts is what catches a stale copy there — the build regenerating
it and the spec failing on it cover different paths, so both are needed.

Verified by corrupting src/theme/theme.css: the spec fails, `npm run build`
restores it, and both dist trees get the 48 tokens.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com>
Both from Copilot's second review.

Removing the var() short-circuit in 635f854 left `tokenised` with no writer, so
the field on Color and the `tokenised ||` guard in colorProblems were dead. Gone —
a pure no-op, since an unset optional was always falsy. Added a case locking in
the behaviour that replaced it: rgba(var(--channels), 0.2) is flagged rather than
skipped, because we cannot tell what colour it is and skipping would let an
off-palette value through.

themeValues was built with `describeColor(...).hex as string`. The cast is
compile-time only, so a theme colour the parser could not reduce to channels would
have entered the map under a null key and quietly stopped being recognised — every
stylesheet using it would then report it as off-palette, a long way from the cause.
The map now filters unresolvable entries out and a spec asserts there are none, so
the failure names the offending entry instead.

Verified by pointing palette.red at oklch(): the suite fails with
"--ox-color-red: oklch(63% 0.19 25)" rather than misbehaving downstream.

342 tests pass; typecheck, lint and build:clean clean.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Both were reported on this PR after Copilot raised them on the rex-web sibling
(rex-web#3133); the checker there is the same design.

1. parseHex validated the expanded length rather than the digits, so '#ggg'
   expanded to 'gggggg', passed the length test and came back as a colour. That
   defeated unresolvableThemeColors, whose whole job is to stop a malformed palette
   value entering themeValues under a key nothing can match — every use of that
   colour would then have read as off-palette, a long way from the cause. Now
   checked against the hex grammar before parsing.

2. The declaration walk discarded the property, so any identifier that happens to
   be a CSS named colour was read as one: 'animation-name: red' reported as
   off-palette, 'font-family: white' as duplicating --ox-color-white with a
   suggested fix that would break the declaration. The walk now returns the
   property, and a bare identifier is only read as a colour where one can go — a
   custom property, anything with 'color' in the name, the border family, or one of
   the colour-bearing shorthands, vendor prefixes stripped.

   Only bare identifiers are gated. Hex and the colour functions are only ever
   colours, so they stay in scope everywhere, including 'animation-name: #d5d5d5'.

Both were latent — no CSS in the repo triggers either — but (2) would have bitten
the first component that animates something named after a colour.

Tests were written failing first: the four new cases reproduce the defects on the
old code. Added cases pinning that the property gate did not weaken detection in
real colour positions (custom property, box-shadow, vendor-prefixed, gradient stop).

353 tests pass; typecheck, lint and build:clean clean.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Consequence of stacking this onto #137. Three of that PR's new tests asserted the
component still sets its own --navbar-menu-item-* / --navbar-popover-border-color
inline (`expect(...).toBeTruthy()`), which is exactly what this PR moves into
NavBarMenuButtons.css.

The assertions on the vanished inline defaults are gone; everything else in those
tests stays. What CORE-2710 was actually protecting — that a caller's style reaches
the element in both the object and render-callback forms — is still asserted, and
tokens.spec.ts now guards the defaults centrally.

Comments in place pointing at where the defaults live now, so the next reader does
not think the coverage was simply dropped.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
@OpenStaxClaude
OpenStaxClaude force-pushed the CORE-2720-global-css-theme-tokens branch from 7f8bdfb to f9cf833 Compare September 2, 2026 20:27
OpenStaxClaude added a commit that referenced this pull request Sep 2, 2026
Stacked on #143, so these three components use the :root token file rather
than binding theme values inline per component -- the same sweep #143 applied
to everything already migrated.

- SidebarNav/ButtonNav/Pagination CSS now reads var(--component-hook,
  var(--ox-token)), and the inline CSSPropertiesWithVariables objects are gone.
- The sidebar backdrop z-indexes stop being literals. That caveat only existed
  because component-scoped custom properties inherit down the tree and the
  backdrop rules target siblings of the nav; :root tokens reach siblings fine,
  so these are now calc(var(--ox-z-index-navbar) + 1) and
  calc(var(--ox-z-index-sidebar) - 1).
- #959595 on the toggle button border is genuinely off-palette, carried over
  verbatim from the styled-components original, so it goes in KNOWN_OFF_PALETTE
  with a reason rather than being snapped to neutralMedium, which would be a
  visual change rather than a refactor.

The three "binds theme values as custom properties" tests are dropped: there is
no inline binding left to assert, and tokens.spec.ts now checks the theme wiring
centrally. Replaced where a public contract survives -- that consumers can still
override through the documented --sidebar-nav-* / --pagination-* hooks.

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

Copy link
Copy Markdown
Contributor Author

Rebased onto the current #137 head to clear the conflicts that were blocking this and #140.

This branch was still carrying a stale copy of #137's commit (590bda0d) from before #137 was force-pushed onto a newer main — one that now includes #138 and #139. Those two deleted the ProfileMenu and HelpMenu snapshots that this PR's token sweep was editing, which is where the modify/delete conflicts came from.

Replayed the ten commits here onto the real #137, dropping the stale duplicate:

  • ProfileMenu/__snapshots__/index.spec.tsx.snap — took the deletion from CORE-2715: replace flaky ProfileMenu snapshot with targeted assertions #138. Verified ProfileMenu/index.spec.tsx has no toMatchSnapshot calls on the new base, so keeping the file would have restored a snapshot nothing asserts.
  • 542b66e3 "Restore react-aria focus attributes in the ProfileMenu snapshot" — dropped. It's one file, two lines, entirely inside that snapshot, so it has no purpose once the file is gone.
  • The NavBarMenuButtons / TreeCheckbox conflicts the merge preview showed didn't appear during the rebase — f9cf8331 "reconcile the CORE-2710 style tests with the token sweep" already covers that interplay and applied cleanly against the real CORE-2710: Compose render-callback style in react-aria-components wrappers #137.

7f8bdfbf9cf8331, 10 commits → 9, no content change beyond the two resolutions above.

368 tests pass on the new base (40 suites, no failures), typecheck and lint clean, checks green, and this now reports MERGEABLE/CLEAN. #140 has been rebased to follow. Old head was 7f8bdfb36 if you want to compare.

OpenStaxClaude added a commit that referenced this pull request Sep 2, 2026
Follows the CORE-2720 (#143) sweep, which this branch is now stacked on. The two
new stylesheets repeated eleven palette hexes as var() fallbacks; they now read
the --ox-* tokens instead, e.g.

  color: var(--help-menu-button-color, var(--ox-color-gray));

The override hooks are unchanged -- only their defaults moved from JavaScript to
the CSS side, so the components no longer bind static custom properties inline.
That means style is no longer destructured in ProfileMenuButton,
ProfileMenuItem, HelpMenuButton or HelpMenuItem: it passes through in ...props
and react-aria handles both the object and render-callback forms itself.

Two consequences worth naming:

- The CORE-2710 (#137) dependency is gone rather than deferred. The bug it
  guards against was a wrapper overwriting the caller's style, which these
  wrappers no longer do, so the menu items need nothing from #137. Same
  reasoning as the note #143 leaves on NavBarMenuItem.
- iframeWrapperStyle and putAwayStyle are gone; the iframe wrapper and the
  put-away bar take their colours from HelpMenu.css.

className composition stays -- that one is a real bug fix, not a default.

The specs that asserted the inline defaults now assert what matters instead:
the caller's style reaches the element in both forms, and the override hook
still wins. Defaults are covered centrally by src/theme/tokens.spec.ts, which
also fails on any colour literal that duplicates a theme value -- both new
stylesheets pass it.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants