feat(palette): runtime-tunable palette, region previews, and two hue zones - #1277
Conversation
Glaze 1.3.0 adds `contrastLevel`, a manual 0-100 contrast level that replaces the two-tier high-contrast model. Verified output-neutral: every token was dumped in all four scheme variants and diffed against 1.2.0 - byte-identical. Tasty 2.11.2 fixes global-style hooks. `useGlobalStyles` now keys its injection slots per root instead of one module-level map, and the SSR/RSC collectors treat an `id`-keyed entry as replaceable rather than deduplicating by content - which is what the runtime palette re-injection relies on. The tasty bump adds ~400 B to the tree-shaken Button entry (measured by rebuilding against both versions; nothing in the kit itself moved), so that size-limit budget moves 118 kB -> 119 kB. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
The palette was generated once at module import from hard-coded seeds and frozen
by three module-level memos. Cube Cloud needs per-user accent colours, so it
re-implements the whole Glaze recipe in several files and re-seeds them itself;
those copies drift every time this palette moves. This moves the capability into
the kit.
`setPaletteConfig({ hue, baseHue, saturation, pastel, contrastLevel, themes })`
re-seeds and every token re-resolves. Hue is split into two zones: `hue` drives
the accent zone, `baseHue` the neutral chrome (inheriting `hue` when unset).
Saturation is deliberately one seed per theme - each colour's `saturation` is a
0-1 factor of it, so moving it rescales the palette while keeping the designed
proportions. `<Root palette>` and `usePaletteConfig()` layer over the store.
`renderColorTokens({ ...config, scheme, highContrast })` resolves one config and
one scheme to flat literal values for a tasty `tokens` prop, so a region can
carry its own theme - a theme picker, or a dark panel in a light page. The
document palette emits `@dark`/`@hc` state maps and so can only ever show one
scheme; collapsing it is what lets several coexist.
The `code-*` family becomes its own theme with its own seed, so neither the
accent hue nor the palette saturation reaches it. It needs a separate seed
because a Glaze colour cannot exceed its own seed and four of these sit at
factor 1.0.
Nothing re-renders on a re-seed: every colour compiles to a CSS custom property,
so replacing one rule on `body` recolours the whole tree.
Also deepens the surface elevation ladder in high contrast: `#surface-2/-3/-4`
and the tinted `<theme>-surface` gain HC tone pairs. Measured across all 156
tokens in all four variants - 67 moved, all of them in `@hc`/`@dark & @hc` only;
light and dark are byte-identical.
Refactor is otherwise output-neutral, enforced by a new snapshot of every token
in all four scheme variants.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Controlled `onChange` did not typecheck and every call site carried a `@ts-expect-error`. Root cause is not a prop conflict: `tsconfig.json` sets `preserveSymlinks: true`, so TypeScript resolves `react-aria`'s type re-exports from the symlink path and never finds the `@react-aria/*` subpackages (they are not direct dependencies). `skipLibCheck` then hides the failure and every `Aria*Props` silently becomes `any`. That explains the shape of the bug: `interface X extends AriaFooProps` inherits nothing, so `onChange`/`isSelected`/`defaultSelected` vanished from the public types, while `Omit<AriaFooProps, …>` becomes an index signature that accepts anything - which is why Slider and the *Group fields appear to work but are entirely unchecked. Declares the selection contract explicitly as `ToggleSelectionProps` and mixes it into both components, so a wrong handler signature fails again. Removes four suppressions (Disclosure and Tree internals plus the theming stories), and stops Checkbox typing its non-DOM `onChange` onto the `<label>` it spreads props onto. `Radio` deliberately keeps no `onChange`: in React Aria a single radio has none, selection is owned by `Radio.Group`. Dropping `preserveSymlinks` is the real fix but surfaces ~320 previously hidden errors across ~60 files, so it needs its own migration. Documented at the flag and in AGENTS.md so the next person does not re-diagnose it. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
…RATOR.md Documents the runtime palette: what is tunable, the two hue zones, why saturation is one scale per theme, the `code-*` exception, inherited vs pinned, region previews, and the caveats (a numeric `contrastLevel` removes the high-contrast tier; `pastel` is a redesign not a filter; the palette is process-global). Four stories: a live playground, per-status tuning, the contrast level, and region previews - the last showing both schemes side by side on one page with a preview-local contrast slider, plus a candidate-theme picker. Removes `src/THEME_GENERATOR.md`: 608 lines describing the pre-1.0 Glaze API (`lightness`, `minContrast`, `'@High-Contrast'`). Nothing referenced it, and anyone reading it as current would author invalid colour definitions. The Glaze docs are symlinked at `docs/glaze/` and the kit-specific rationale now lives in the Theming page and the palette recipe comments. Also adds Colors, Theming and Typography to the Storybook sidebar ordering, which previously sorted them after every named entry. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Bugbot is paused — on-demand spend limit reachedBugbot uses usage-based billing for this team and has hit its on-demand spend limit. A team admin can raise the spend limit in the Cursor dashboard, or wait for the next billing cycle to continue. |
|
The latest updates on your projects. Learn more about Vercel for GitHub.
|
🦋 Changeset detectedLatest commit: fd864e7 The changes in this PR will be included in the next version bump. This PR includes changesets to release 1 package
Not sure what this means? Click here to learn what changesets are. Click here if you're a maintainer who wants to add another changeset to this PR |
📦 NPM canary releaseDeployed canary version 0.0.0-canary-7d79005. |
🧪 Storybook is successfully deployed!
|
🏋️ Size limit report
Click here if you want to find out what is changed in this build |
The region-preview story demonstrated the mechanism with static cards, which made it a poor place to actually judge a theme. This turns it into a builder: the whole palette config on the left as a vertical control column, an app-shell preview on the right rendered into a single region. Scheme is Auto / Light / Dark and contrast is Auto / Normal / High, with no manual contrastLevel - the two switches cover what a consumer can actually choose. `Auto` is resolved from the document rather than passed through, because `renderColorTokens()` returns flat literal values and a literal cannot mean "follow the OS"; the story reads `<html data-schema>` / `data-contrast` with the matching media query as fallback - the same two sources `Root` resolves from - and re-renders when either moves. The preview shows all three surface levels with their own text ramps, since a theme that reads well on the page surface can fall apart two panels deep, plus the accent and status chips, real buttons, a syntax block, and theme presets for a starting point. Known limitation worth seeing in the story: the Cube mark's light/dark artwork is swapped by the `@dark` state, which follows the document, not the region - a state-keyed style cannot be overridden by tokens. Its colour does follow the preview, since that is a token. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Follow-ups on the Theme Builder: - Contrast gains a `Custom` option with a 0-100 level slider. `highContrast` is meaningless alongside a manual level (the level carries the preference itself, so there is no separate tier to ask for), so the two are passed as alternatives rather than together. - Both switchers get visible labels instead of `aria-label` only. - The sidebar uses `ItemButton` on a `1bw` gap with even `1x` padding, per the sidebar pattern in the Layout guide. `Item` is already `nowrap`, which fixes the wrapping - and the nav column widened to `24x` so nowrap does not just move the problem to an ellipsis. - Main content drops to `2x` padding and gains a second column of `Alert`s, one per status theme. Alerts pair a tinted `<theme>-surface` with its own text and border, so they are the fastest read on whether the status hues still hold at a given saturation. `special` and `disabled` are left out. - The two button rows were mixing axes: types next to a state next to a theme, which reads as one row of peers when it is three different things. Now one row varies the type at a fixed theme and one varies the theme at a fixed type, each complete, straight from `src/data/item-themes.ts`. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Theme Builder: - Switchers move from `type="tabs"` to `type="button"`. Tabs implies the choice restructures the form; these are plain options. Same for the preview's Results/Chart/SQL group. - Contrast splits into two switchers. `Contrast level` is Auto / Custom, and only when it is Auto does a `Contrast mode` switcher appear (Normal / High) - a manual level carries the contrast preference itself, so there is no tier to choose between alongside it. Custom reveals a 0-100 slider. - Neither `Color scheme` nor `Contrast mode` offers Auto any more. Auto means "follow the OS", which is a preference; a preview renders one concrete variant and has to name which. That removes the ambient-detection hook entirely - no MutationObserver, no matchMedia, no "resolved from the document" caveat. The contrast *level* keeps its Auto, because there the choice is between the two-tier model and a hand-set number. - Both switchers gained visible labels. - Preview gains Switch, Checkbox and a radio group, plus Tag across all six themes. `View documentation` is a real `Link`. `special` is out of the button themes row. Palette retune (authored by @tenphi, snapshot re-baselined here): - Wider high-contrast tone pairs on the surface ladder, `#border` and the tinted `<theme>-surface`. - The tinted `<theme>-border` drops to `saturation: 0.3`, so a themed border reads as a border rather than a second accent. - `Alert` borders switch to `#<theme>-border`, making alerts and outline items agree. Measured across all 156 tokens in all four variants: 67 moved. Only six move in the normal and dark schemes, and they are exactly the six themed borders - the saturation change. Everything else is high-contrast only. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
The mark is two drawings swapped by the `@dark` state, and that state is resolved against the document. A region themed through `tokens` overrides token *values*, so it could not reach the swap — in the Theme Builder the logo kept the page's artwork while everything around it followed the preview. `scheme="light" | "dark"` pins one mark. Omitting it is unchanged: the CSS swap, no re-render, correct under SSR. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
`#primary` is a fixed brand fill that barely moves between schemes; `#primary-text` is contrast-solved against the surface. The invalid and valid states already used `#danger-text` / `#success-text`, so this makes the default case consistent and lets the dot lighten in dark (L 0.54 → 0.76) and in high contrast. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Merging made removal awkward and `<Root palette>` wrong: a declarative prop
could pin a field but never un-pin it, so re-rendering with a smaller config
left the dropped field in place.
`setPaletteConfig(config)` now works like `useState` — the config passed is
the config, resolved against the shipped defaults. Dropping a customization
means dropping it from the object. For one-field controls there is an
updater form, `setPaletteConfig((config) => ({ ...config, hue }))`, handed
the sparse config so spreading preserves what inherits.
`resolvePaletteConfig` / `renderColorTokens` keep layering over the live
config — a preview means "the theme in use, but in dark", so its omitted
fields have to come from the current palette, not the defaults.
No released behaviour changes: the API is new in this unmerged branch, so
the existing minor changeset still covers it.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Two defects found reviewing the branch, both in `renderPaletteTokens`: - The memo was keyed only on the resolved config, but `buildPalette` also reads Glaze's own global config (`darkTone`, `darkDesaturation`, the state map). A host that drove Glaze directly and then called `invalidatePaletteTokens()` got fresh document tokens and stale region previews — verified: `#surface` moved in the document and did not move in the region. - The variant map was published field-by-field under the old key, so a throw part-way through could leave a half-filled map readable as if complete. Built into a local and swapped in at the end instead. Also harden the config store: `commit` now snapshots and freezes what it stores, so mutating the object after passing it in cannot desync `input` from `resolved`, and a write to a config handed back by `getPaletteConfig()` / `getPaletteConfigInput()` fails loudly instead of silently moving the config without bumping the version. Docs: document that removing `<Root palette>` entirely does not reset the palette (so the common `<Root>` cannot clobber a host's imperative call), add the missing `scheme` prop to the CubeLogo properties list, and correct the measured drift in the surface-ladder changeset (68 tokens, not 67). Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Bugbot is paused — on-demand spend limit reachedBugbot uses usage-based billing for this team and has hit its on-demand spend limit. A team admin can raise the spend limit in the Cursor dashboard, or wait for the next billing cycle to continue. |
Brings in the palette rework (#1277) and the DatePicker period pickers (#1279). Three conflicts, all in build config: - package.json: keeps `@tenphi/tasty` at `^3.0.0` (the point of this branch, now resolving to 3.0.1) and takes main's newer `@tenphi/glaze` 1.3.0. - .size-limit.cjs: the two increases stack. main had already gone 118 -> 119 kB for the ~400 B that tasty 2.11.0 -> 2.11.2 added; v3 costs a further ~3.8 kB. Re-measured on the merged tree at 121.79 kB and set to 123 kB rather than carrying either side's number forward. - pnpm-lock.yaml: regenerated. Verified against main's own baseline: the 13 type errors are byte-identical to `origin/main`'s, so the merge introduces no regressions. 1200 tests pass (main added 61), zero tasty lint errors, both size entries inside their limits (451.54/460 kB and 121.79/123 kB). Notably main's new palette and DatePicker code had never been built against v3, and needed no changes.
Describe changes
The palette was generated once at module import from hard-coded seeds and frozen by three module-level memos. After the first token read it could never change.
That blocks the kit's main consumer. Cube Cloud already lets users pick their own accent colour, and to do it it re-implements the entire Glaze recipe across several files and re-seeds them per user — copies that drift every time this palette moves, guarded only by a spec that checks a handful of tokens within ±2/channel. This moves the capability into the kit.
Runtime tuning
Hue is split into two zones.
huedrives the accent zone (theaccent-*family,primary/purple/special, plusfocus, the loading faces, the disabled chip);baseHuedrives the neutral chrome (surfaceand its ladder, thesurface-text*ramp,border,placeholder). A coloured theme's tintedsurfacedeliberately still follows its own hue, because a danger banner should read as red.Saturation is deliberately not split. It is one seed per theme, and each colour's
saturationis a 0–1 factor of it, so moving it rescales the palette while keeping the proportions the palette was designed around.<Root palette={…}>andusePaletteConfig()layer over the store. Nothing re-renders on a re-seed: every colour compiles tovar(--*-color), so replacing one rule onbodyrecolours the whole tree and the browser repaints.Region previews
The document palette emits
@dark/@hcstate maps, so a page can only ever show one scheme.renderColorTokens()collapses it to one config and one scheme as flat literal values, which is what lets several themes coexist — a theme picker, or a dark panel in a light page. Nothing is applied globally.code-*becomes its own themeNeither the accent hue nor the palette saturation reaches it, so a re-seeded brand can no longer collide string literals with
#code-number(156°) and muting the app cannot wash out a code block. It needs a separate seed rather than pinned factors because a Glaze colour can never exceed its own seed and four of these sit at factor1.0. Tune it withthemes.code.saturation; the tokens stay adaptive, keeping their['AA','AAA']floor against the real surface.Two changes that alter shipped output
#surface-2/-3/-4and the tinted<theme>-surfacegain HC tone pairs so nested panels stay distinguishable. Measured across all 156 tokens in all four variants: 67 moved, every one of them in@hc/@dark & @hconly. Light and dark are byte-identical.Switch/CheckboxonChangenow typechecks — see below.Everything else is output-neutral, enforced by a new snapshot of every token in all four scheme variants.
Reviewer notes
Aria*Propsfromreact-ariasilently resolve toany. This is the root cause of theSwitch/Checkboxfix and it is worth knowing about.tsconfig.jsonsetspreserveSymlinks: true, so TS resolves react-aria's type re-exports from the symlink path and never finds the@react-aria/*subpackages (not direct dependencies);skipLibCheckthen hides the failure.Consequences:
interface X extends AriaFooPropsinherits nothing (which is whyonChangevanished), whileOmit<AriaFooProps, …>becomes an index signature that accepts anything — soSlider,NumberInput,TextInputand the*Groupfields appear to work but are entirely unchecked. The fix declares the toggle contract explicitly (ToggleSelectionProps) and removes four@ts-expect-errors. DroppingpreserveSymlinksis the real fix but surfaces ~320 previously hidden errors across ~60 files, so it needs its own migration — documented at the flag and inAGENTS.md.The type-error count stays at 13. Those are pre-existing, in files this PR does not touch. It does not drop, because the four directives removed were suppressing errors and so never counted.
Cloud follow-up.
setPaletteConfig({ hue })is a drop-in for cloud'sbuild-app-theme-tokens.ts, andrenderColorTokenscovers its theme-preview UI. The mirrored recipe copies can then be deleted. Worth verifying against the canary before that.Checklist
Closes: N/A
Other information
Verification. 1183 tests pass (46 in the new palette spec). The load-bearing ones:
code-*extraction and thebaseHuesplit;light≡'',dark≡'@dark', both HC pairs), so previews cannot drift from the real thing;contrastLevel0 ≡ the normal tier and 100 ≡ the high-contrast tier, bit for bit, in both schemes;code-*light values are unchanged at saturation 40 and 100;surfaceresolves to the same colour as the live#surfacein light and dark, which is what keeps its contrast floors honest.Also verified in a browser: dragging the brand hue recolours the page while
dangerand the syntax colours hold; all four scheme regions keep their own scheme when the page flips to dark; a manual contrast level drops all 22prefers-contrastrules with zero colour change at level 0.pnpm size: 454.54 kB / 460 kB, and 118.03 kB / 119 kB for the tree-shaken Button. That budget moved from 118 kB because the tasty 2.11.2 bump adds ~400 B there — attributed by rebuilding against both versions; nothing in the kit itself moved.Also in here: removed
src/THEME_GENERATOR.md(608 lines of pre-1.0 Glaze API, nothing referenced it, actively misleading), and added Colors/Theming/Typography to the Storybook sidebar ordering.🤖 Generated with Claude Code