Skip to content

feat(palette): runtime-tunable palette, region previews, and two hue zones - #1277

Merged
tenphi merged 12 commits into
mainfrom
main-tuneable-hue
Aug 5, 2026
Merged

feat(palette): runtime-tunable palette, region previews, and two hue zones#1277
tenphi merged 12 commits into
mainfrom
main-tuneable-hue

Conversation

@tenphi

@tenphi tenphi commented Aug 4, 2026

Copy link
Copy Markdown
Member

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

setPaletteConfig({
  hue: 210,      // accent zone — the brand
  baseHue: 60,   // base zone — the neutral chrome; inherits `hue` when unset
  saturation: 72,
  themes: { danger: { hue: 12 }, code: { saturation: 60 } },
  pastel: false,
  contrastLevel: 'auto',
});

Hue is split into two zones. hue drives the accent zone (the accent-* family, primary/purple/special, plus focus, the loading faces, the disabled chip); baseHue drives the neutral chrome (surface and its ladder, the surface-text* ramp, border, placeholder). A coloured theme's tinted surface deliberately 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 saturation is a 0–1 factor of it, so moving it rescales the palette while keeping the proportions the palette was designed around.

<Root palette={…}> and usePaletteConfig() layer over the store. Nothing re-renders on a re-seed: every colour compiles to var(--*-color), so replacing one rule on body recolours the whole tree and the browser repaints.

Region previews

<Block tokens={renderColorTokens({ hue: 210, scheme: 'dark' })} fill="#surface">
  …renders in that theme, inside a light page…
</Block>

The document palette emits @dark/@hc state 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 theme

Neither 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 factor 1.0. Tune it with themes.code.saturation; the tokens stay adaptive, keeping their ['AA','AAA'] floor against the real surface.

Two changes that alter shipped output

  • High-contrast surface ladder. #surface-2/-3/-4 and the tinted <theme>-surface gain 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 & @hc only. Light and dark are byte-identical.
  • Switch / Checkbox onChange now typechecks — see below.

Everything else is output-neutral, enforced by a new snapshot of every token in all four scheme variants.


Reviewer notes

Aria*Props from react-aria silently resolve to any. This is the root cause of the Switch/Checkbox fix and it is worth knowing about. tsconfig.json sets preserveSymlinks: true, so TS resolves react-aria's type re-exports from the symlink path and never finds the @react-aria/* subpackages (not direct dependencies); skipLibCheck then hides the failure.

Consequences: interface X extends AriaFooProps inherits nothing (which is why onChange vanished), while Omit<AriaFooProps, …> becomes an index signature that accepts anything — so Slider, NumberInput, TextInput and the *Group fields appear to work but are entirely unchecked. The fix declares the toggle contract explicitly (ToggleSelectionProps) and removes four @ts-expect-errors. 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.

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's build-app-theme-tokens.ts, and renderColorTokens covers its theme-preview UI. The mirrored recipe copies can then be deleted. Worth verifying against the canary before that.

Checklist
  • Pipeline is passed
  • Tests are added (including unit tests and stories in the storybook)
  • Tests are passed successfully
  • If you're adding a new component/new props, add stories that describe how this component/prop works
  • Changeset(s) is(are) added
  • You have passed the threshold of the library size
  • Commit message follows commit guidelines

Closes: N/A

Other information

Verification. 1183 tests pass (46 in the new palette spec). The load-bearing ones:

  • the default palette snapshot — 156 tokens × 4 scheme variants, byte-identical through the Glaze bump, the builder refactor, the code-* extraction and the baseHue split;
  • each rendered region variant equals the corresponding state of the document palette exactly (light'', dark'@dark', both HC pairs), so previews cannot drift from the real thing;
  • contrastLevel 0 ≡ 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;
  • the code theme's mirrored surface resolves to the same colour as the live #surface in light and dark, which is what keeps its contrast floors honest.

Also verified in a browser: dragging the brand hue recolours the page while danger and the syntax colours hold; all four scheme regions keep their own scheme when the page flips to dark; a manual contrast level drops all 22 prefers-contrast rules 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

tenphi and others added 4 commits August 4, 2026 17:26
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>
@cursor

cursor Bot commented Aug 4, 2026

Copy link
Copy Markdown
Contributor

Bugbot is paused — on-demand spend limit reached

Bugbot 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.

@vercel

vercel Bot commented Aug 4, 2026

Copy link
Copy Markdown

The latest updates on your projects. Learn more about Vercel for GitHub.

Project Deployment Actions Updated (UTC)
cube-ui-kit Ready Ready Preview Aug 5, 2026 11:38am

Request Review

@changeset-bot

changeset-bot Bot commented Aug 4, 2026

Copy link
Copy Markdown

🦋 Changeset detected

Latest commit: fd864e7

The changes in this PR will be included in the next version bump.

This PR includes changesets to release 1 package
Name Type
@cube-dev/ui-kit Minor

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

@tenphi tenphi changed the title feat(palette)!: runtime-tunable palette, region previews, and two hue zones feat(palette): runtime-tunable palette, region previews, and two hue zones Aug 4, 2026
@github-actions

github-actions Bot commented Aug 4, 2026

Copy link
Copy Markdown
Contributor

📦 NPM canary release

Deployed canary version 0.0.0-canary-7d79005.

@github-actions

github-actions Bot commented Aug 4, 2026

Copy link
Copy Markdown
Contributor

🧪 Storybook is successfully deployed!

@github-actions

github-actions Bot commented Aug 4, 2026

Copy link
Copy Markdown
Contributor

🏋️ Size limit report

Name Size Passed?
All 444.09 KB (+0.64% 🔺) Yes 🎉
Tree shaking (just a Button) 115.26 KB (+0.34% 🔺) Yes 🎉

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>
tenphi and others added 2 commits August 5, 2026 12:54
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>
@cursor

cursor Bot commented Aug 5, 2026

Copy link
Copy Markdown
Contributor

Bugbot is paused — on-demand spend limit reached

Bugbot 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.

@tenphi
tenphi merged commit 1de5e56 into main Aug 5, 2026
14 checks passed
@tenphi
tenphi deleted the main-tuneable-hue branch August 5, 2026 12:13
@tenphi tenphi mentioned this pull request Aug 5, 2026
tenphi added a commit that referenced this pull request Aug 5, 2026
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.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant