Skip to content

feat(theme): move current to the theme axis, add an invert type - #1332

Merged
tenphi merged 29 commits into
mainfrom
claude/button-item-current-theme-js20o6
Aug 20, 2026
Merged

feat(theme): move current to the theme axis, add an invert type#1332
tenphi merged 29 commits into
mainfrom
claude/button-item-current-theme-js20o6

Conversation

@tenphi

@tenphi tenphi commented Aug 19, 2026

Copy link
Copy Markdown
Member

Describe changes

Three changes, all on the theme / type grid.

1. current moves from the type axis to the theme axis

current never named a shape — it named where the colors come from (the inherited currentcolor rather than a brand ramp), which is the question theme answers. As a type it occupied the slot that decides emphasis, so picking it meant giving up the choice between a filled control, an outlined one and a bare label.

On the theme axis it composes, and every type now has a current flavour:

Type current flavour
item the old Item shape — no border, nothing painted at rest, fill stepping in on hover/pressed/selected
clear the same ramp plus the focus ring a standalone control needs. Default for Item.Action / ItemBadge
outline the old Button shape — a resting #current.03 chip inside a #current.08 border
outline-2 outline for a container that already paints something. No opaque base to swap, so the tint roughly doubles at every step
primary paint the color: the fill is the inherited color at full opacity and the label is punched out of it with #surface
invert paint the page: the same two colors in the opposite roles — a #surface fill with the inherited color written on it
link no chip; "soft" is the inherited color at .8, "strong" is full opacity
card a static #current.05 panel inside a #current.2 border (Item only)

current.outline and current.item are byte-identical to the old Button and Item flavours, so nothing that used type="current" changes appearance. Every alpha ramp stops at #current.24 — the measured AA floor for a full-strength label on a dark surface.

Breaking: type="current" is removed with no runtime fallback — it resolves to no variant, the same as any other unknown type. The spelling shipped one release ago and has no consumers outside the kit, so it is a clean break rather than a deprecation.

Old New
Button type="current" Button theme="current" (type defaults to outline)
Item type="current" Item theme="current" (type defaults to item)
Item.Action type="current" / ItemBadge type="current" drop it — current is already the default theme

Item now accepts theme="current" with every type except header; the warning that fired for type="current" with any theme but default is gone. current is registered in TastyThemeNames, so it autocompletes on every tasty component.

Item.Action / ItemBadge defaults. type now defaults to clear and theme to current, and neither is read from ItemActionContext — the two axes are independent, so a shape no longer implies a color source. Both are plain defaults the lint registry can prove, which the previous theme entry (skip: 'context') was not. One behaviour change: an action that named a type but no theme used to inherit the host row's theme and now takes the host's color instead; pass theme="default" to opt back into a fixed palette. The host theme still reaches the element as data-surface, which the current ramp reads to pick the alphas that work over the special theme's fixed dark-purple surface — the job data-theme did before theme="current" claimed that attribute.

2. New invert type, on every theme

The filled type that follows the color scheme, where primary deliberately does not. The fill is the theme's accent-text — the color normally painted on the page — and the label is #surface, the page itself, so the control lands on the opposite side of the page in either scheme: a dark chip with a light label in light mode, a light chip with a dark label in dark mode. primary pins a fixed #white label on a brand surface and therefore reads with the same weight in both.

fill label measured contrast
light accent-text (L 0.47) #surface (L 1.00) 6.96:1
dark accent-text (L 0.76) #surface (L 0.24) 7.52:1

The trap this pairing invites is labelling with surface-text, which is painted on the page and so sits on the same side of the fill as accent-text does — that measures 2.60 / 1.91 and was rejected on those numbers.

Hover and pressed darken through a second fill layer rather than stepping to a darker sibling, because accent-text has none — its -soft counterpart is lighter — and a #black overlay darkens in both schemes, so the monotonic default → hover → pressed direction survives the scheme flip. Disabled hands over to the brand-tinted pair primary already mutes to, so the two filled types stay calibrated in one place; the fill's base layer is pinned across all four states so the overlay interpolates instead of the base flashing through mid-transition.

Two themes build it by swapping rather than from accent-text. special inverts against its own fixed dark surface instead of the page, giving a white pill with the theme's dark accent on it — the figure special.clear already strikes when selected. And current has no accent-text at all: it has exactly one color, the one it inherits, so invert there is primary with its two colors exchanged.

current fill label
primary #current #surface
invert #surface #current

That is the same swap special makes, so the pairing reads the same way on every theme even where the tokens differ. It also drops the machinery primary needs there: #current is both the fill and the value currentcolor resolves against, so the label has to be painted with -webkit-text-fill-color and every icon slot recolored by hand. With an absolute #surface fill, color is simply the label and icons inherit it. Hover and pressed tint toward the inherited color rather than laying #black over it — that reads as a darkening on a white page and a lightening on a dark one, where a fixed #black over a near-black #surface would barely move.

Registered on Button, Item, Item.Action and Item.Badge. Around the edges: the split-button border collapse counts invert among the bordered types, ItemBadge drops its rim as it does for primary, and the HotKeys hint on an invert row takes the inherit flavour so it tracks the #surface label rather than primary's fixed #white.

The five brand variants come from an invertStyles(accent) factory rather than hand-copied objects. The only thing that varies is the theme prefix, and hand-copying that is exactly how the selected & disabled fills below landed on the wrong token for four themes earlier in this branch.

3. selected & disabled no longer outweighs its enabled state

On outline, outline-2 and clear across default / danger / success / warning / note, the state borrowed accent-disabled-surface / accent-disabled-surface-text — the pair built for a PRIMARY button, whose enabled state is already an opaque brand fill under a #white label, so a mid-tone chip is a step down there. On a non-solid type it is a step up: against a 9% brand tint under soft accent text, the chip read as a filled pill and its tone: 'max' label resolved to literal white in light mode. A disabled segmented control drew more attention than a live one.

It now keeps the enabled selected chip at a slightly lower alpha and fades only the label, through a new accent-disabled-text token. Selection survives as a brand tint on a chip of unchanged weight, which is what CUB-3912 asked for. No existing token changed value — the palette snapshot diff is purely additive. primary keeps accent-disabled-surface (correct for a solid fill); special and current keep their own white-alpha and currentcolor registers, retuned to roughly 2:1 for the muted state.

Banner also drops the border-clear override on its outline actions, so a current outline stays visible on a saturated surface.

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

Stories. New CurrentStates on Button and Item sweep every type and state on the current theme, inside containers that paint their own text color — the matrix the other themes get from *States / TypesAndThemes. primary and invert sit next to each other there, which is the clearest place to read the swap. The existing context sweeps are renamed CurrentTheme. invert also joins BUTTON_TYPES and Item's TypesAndThemes grid, and gets its own group in the Item.Action / ItemBadge Types stories; those Types stories pin theme="default" so the shapes still show against a brand palette, and the Themes stories gain a current group. The Pressed&Hovered column is dropped from the Button states stories — pressed outranks hovered in every variant's state map, so it rendered identically to the Pressed cell beside it.

Single fade per subtree. #current is the color an element inherits, so a .4 disabled fade applied twice down one chain multiplies to .16. Every current flavour now gates its fade on disabled & !inherit-disabled & !inside-wrapper: the first mod is set by Item.Action inside a disabled row, the second by ItemButton on the row it renders inside ActionsWrapper, and the wrapper reproduces the gated color so actions rendered as siblings of the row inherit a faded currentcolor too. Probed on all four paths — standalone control, action in a disabled row, and both halves of an ItemButton — each lands on exactly one .4.

Verification. pnpm test (2042 passed), pnpm build, pnpm lint and pnpm audit-defaults all clean; pnpm audit-docs reports only the pre-existing isChecked / scrollMargin / place base-prop gaps. pnpm probe confirms current.outline and current.item emit the same CSS as before, that the surface=special branch still wins over @dark, that the disabled-fade gate resolves as described above, and the measured tone/chroma/contrast figures quoted throughout.

Regression tests. src/data/item-themes.test.ts pins two invariants that a blanket edit can silently break: the selected & disabled chip must equal its enabled counterpart modulo one alpha step across all fifteen brand-theme × non-solid-type variants (the tokens are not uniform — default.clear tints from accent-surface while the four status themes tint from accent-text), and every interactive current flavour must state the gated fade rather than a bare disabled. Both fail on the exact bugs they describe, checked by reintroducing them.

Two test/registry follow-ons. The probe spike's "case E" (a default that is redundant bare but load-bearing under a provider) moved from ItemAction theme to isDisabled, which is now the only context-resolved prop there; docs/rules/eslint-plugin.md and the fixtures' comments were updated to match. The unreleased item-action-current-default changeset was reconciled so the two entries do not contradict each other in one release note.


Note

Medium Risk
Touches core variant resolution and default props across widely used action/list components; visual changes are possible where row actions no longer inherit the host brand theme, though legacy type="current" mappings aim to preserve prior appearance.

Overview
current is now a theme, not a type, on Button, Item / ItemButton, Item.Action, and ItemBadge. Inherited-color styling composes with every shape (outline, primary, clear, link, outline-2, item, card) via new current.* variants in item-themes.ts; current.outline / current.item stay pixel-identical to the old type="current" button and item. Variants resolve as `${theme}.${type}` instead of folding current under default.

Item.Action / ItemBadge default to theme="current" and type="clear" and no longer inherit type/theme from ItemActionContext; host row theme is exposed as data-surface so the current alpha ramp can target the special surface (surface=special replaces theme=special on ramp tokens).

Patch: selected & disabled on non-solid brand types uses new accent-disabled-surface-soft tokens so muted selection is lighter than enabled, not heavier.

Docs, Storybook (CurrentStates, renamed CurrentTheme), palette snapshots, and the eslint defaults registry (plain defaults for action theme/type; probe case E moved to isDisabled) are updated. Changesets document migration from type="current" to theme="current".

Reviewed by Cursor Bugbot for commit 1690170. Bugbot is set up for automated code reviews on this repo. Configure here.


Note

Medium Risk
Wide changes to variant keys, defaults, and visual tokens on core action/list components; row actions may look different when they no longer inherit the host theme, and consumers must migrate from type="current" to theme="current".

Overview
current moves from type to theme on Button, Item / ItemButton, Item.Action, and ItemBadge. Styling now resolves as theme.type with a full set of current.* flavours in item-themes.ts (outline, primary, clear, link, outline-2, item, card); legacy current.outline / current.item match the old type="current" look. Variants no longer fold current under default, and theme="current" is registered for Tasty autocomplete.

Item.Action / ItemBadge default to theme="current" and type="clear" and no longer inherit type/theme from context; the host row theme is exposed as data-surface so the current ramp can target special surfaces (surface=special on ramp tokens). Actions that only set type now tint from currentcolor instead of the host brand palette unless an explicit theme is passed.

Patch: For brand outline / outline-2 / clear (plus special and current.clear), selected & disabled keeps the enabled selected chip and fades the label with new accent-disabled-text; special disabled labels are retuned for ~2:1 contrast. Banner drops the border-clear override on outline actions so current outlines stay visible on saturated surfaces.

Docs, Storybook (CurrentStates, renamed CurrentTheme), palette snapshots, unit tests, and the eslint defaults registry are updated accordingly.

Reviewed by Cursor Bugbot for commit b116368. Bugbot is set up for automated code reviews on this repo. Configure here.


Note

Medium Risk
Broad changes to variant keys, default props, and tokens on widely used action/list components; row actions may render differently when they no longer inherit the host brand theme, and consumers must migrate from type="current" to theme="current".

Overview
current is now a theme, not a type, on Button, Item / ItemButton, Item.Action, and ItemBadge. Variants resolve as theme.type with a full current.* set (outline, primary, clear, link, outline-2, item, card); current.outline / current.item match the old type="current" look. Consumers must use theme="current" instead of type="current" (no runtime fallback for the old type).

New invert type on every theme: scheme-following filled control (accent-text fill, #surface label), with current.invert and special.invert built by swapping roles like current.primary. Banner.Action switches to type="invert" on the banner’s theme so actions stay legible on white-on-saturated banners where current filled types collapse to 1:1 contrast.

Item.Action / ItemBadge default to theme="current" and type="clear" and no longer inherit type/theme from context; host theme is data-surface for the current ramp (surface=special on ramp tokens). selected & disabled on non-solid brand types (and special / current.clear) keeps the enabled selected chip and fades the label via accent-disabled-text; current flavours gate disabled label fade so it is not applied twice inside disabled rows / action wrappers.

Docs, Storybook, palette snapshots, item-themes.test.ts, and eslint defaults registry updates accompany the migration.

Reviewed by Cursor Bugbot for commit f37a0d0. Bugbot is set up for automated code reviews on this repo. Configure here.

`current` never named a shape — it named where the colors come from, which
is the question `theme` answers. As a type it occupied the slot that decides
emphasis, so picking it meant giving up the choice between a filled control,
an outlined one and a bare label.

On the `theme` axis it composes, and every type now has a `current` flavour:
`item`, `clear`, `outline`, `outline-2`, `primary`, `link` and `card`.
`current.outline` and `current.item` are byte-identical to the old `Button`
and `Item` flavours. `primary` escalates the alpha ramp rather than inverting
(one inherited color has nothing to punch a label out with), `outline-2`
doubles the tint in place of the opaque base it cannot swap, and `link` goes
soft at `#current.8`.

`type="current"` still renders, mapped to the flavour it used to be, and
warns once in development.

`ItemAction` / `ItemBadge` now default to `type="clear"` + `theme="current"`
and read neither from `ItemActionContext`; both are plain defaults the lint
registry can prove. The host theme still reaches them as `data-surface`,
which the `current` ramp reads to pick the alphas that work over the special
theme's fixed dark-purple surface.

Adds `CurrentStates` stories to `Button` and `Item` sweeping every type and
state on the theme; the context sweeps become `CurrentTheme`.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01BU1Vwm6cGgGRcMubszgbGD
@changeset-bot

changeset-bot Bot commented Aug 19, 2026

Copy link
Copy Markdown

🦋 Changeset detected

Latest commit: 545af49

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

@vercel

vercel Bot commented Aug 19, 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 20, 2026 2:06pm

Request Review

@github-actions

github-actions Bot commented Aug 19, 2026

Copy link
Copy Markdown
Contributor

📦 NPM canary release

Deployed canary version 0.0.0-canary-8d79305.

@github-actions

github-actions Bot commented Aug 19, 2026

Copy link
Copy Markdown
Contributor

🧪 Storybook is successfully deployed!

@github-actions

github-actions Bot commented Aug 19, 2026

Copy link
Copy Markdown
Contributor

🏋️ Size limit report

Name Size Passed?
All 492.18 KB (+0.05% 🔺) Yes 🎉
Tree shaking (just a Button) 120.73 KB (+0.26% 🔺) Yes 🎉

Compared against main at ffd1c7brun 32357954115, 2026-08-20T10:14:26Z.

To see which modules changed, download the size-limit-statoscope-report artifact from this run and open report.html.

…s stories

`pressed` already wins over `hovered` in every variant's state map, so the
combined cell rendered identically to `Pressed` next to it — six columns of
noise across seven theme stories.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01BU1Vwm6cGgGRcMubszgbGD
claude added 3 commits August 19, 2026 13:20
…state

`selected & disabled` on `outline`, `outline-2` and `clear` borrowed
`accent-disabled-surface` — the mid-tone pill a PRIMARY button steps DOWN to,
which on a non-solid type is a step UP — under a `tone: 'max'` label that
resolved to literal white in light mode. A disabled segmented control drew
more attention than a live one.

Adds `accent-disabled-surface-soft` / `accent-disabled-surface-soft-text`:
the neutral disabled geometry (the same `-3.5` chip and `-23` label deltas
against `surface`, adaptive) carrying brand chroma instead of extra weight.
Selection still reads, now as a tint rather than a heavier chip.

No existing token changed value; the palette snapshot diff is additive.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01BU1Vwm6cGgGRcMubszgbGD
`current` shipped on the `type` axis one release ago and has no consumers
outside the kit, so the axis move is a clean break rather than a mapped and
warned-about alias. The changeset carries the migration.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01BU1Vwm6cGgGRcMubszgbGD
Comment thread src/components/actions/Button/Button.tsx
The brand-tinted chip introduced in the previous commit fixed the weight
inversion but read over-saturated beside the enabled selected chips it sits
next to. The chip now stays exactly as it is when enabled and only the label
fades: the chip is what says "this one is on", and a disabled control has no
business saying that differently from a live one.

Replaces the two `accent-disabled-surface-soft*` tokens with a single
`accent-disabled-text` — the neutral `disabled-surface-text` geometry at
brand chroma, ~2x the neutral and comfortably under `accent-text-soft`.

The chip is `.08` rather than a literal reuse of `selected`'s `.09` so the
two entries do not serialize identically; `mergeEntriesByValue` would
otherwise merge them and negate `selected & (hovered | focused)`, the bug
`SPECIAL_CLEAR_STYLES` documents. Verified with `pnpm probe`: all four
selected states still emit distinct rules.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01BU1Vwm6cGgGRcMubszgbGD
Comment thread src/data/item-themes.ts Outdated
The four status themes tint their `clear` selected chip from `accent-text`,
not `accent-surface` — `default.clear` is the odd one out — so the blanket
edit in the previous commit shifted their hue and weight on
`selected & disabled`, breaking the very invariant it introduced.

Adds a unit test over `ITEM_VARIANTS` pinning both halves of that invariant
across all fifteen brand-theme x non-solid-type variants: the disabled
selected fill equals the enabled one modulo the `.09` -> `.08` step, and the
label is its theme's `accent-disabled-text`. Verified the test fails on the
bug it describes.

Reported by Cursor Bugbot.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01BU1Vwm6cGgGRcMubszgbGD
Every disabled label in the special theme sat far above the house figure for
a dead control. Measured against the chip it sits on, `outline` disabled came
out at cr 3.24 and `outline` selected + disabled at 4.21 — not only too
legible, but inverted, since the selected one out-read the plain one. For
scale, `disabled-surface-text` measures ~2.02 against `surface`, and this
theme's own `primary` disabled pair measures 1.73.

The labels are now solved for cr ~2.0 against whatever each sits on:
`#white.23` for the plain disabled states across `outline`, `clear`, `item`
and `link`, `#white.28` for `outline` selected + disabled on its chip. The
two differ because they resolve against different chips, which lands them on
the same contrast rather than the same opacity.

`outline` selected + disabled also keeps the enabled selected chip, as the
colored themes now do. `clear` — whose selected state inverts to a solid
white pill — keeps that pill and fades its DARK label instead, to
`#special-accent-text.45`.

Generalises the `ITEM_VARIANTS` test to cover both constructions, and pins
the `current` theme as the documented exception: its disabled alphas are
pre-multiplied against an already-faded `currentcolor`, so they rise where
every other theme's fall.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01BU1Vwm6cGgGRcMubszgbGD
A disabled selected `clear` on the `current` theme fell through the item
ramp to a bare `transparent` and rendered no chip at all, so the state was
indistinguishable from an unselected one.

It now carries `#current.18` — `CURRENT_OUTLINE_STYLES`' own disabled
selected chip, so the two differ by exactly the border. Same split the
colored themes already make between `*_ITEM_STYLES`, where a disabled row
drops its chip, and `*_CLEAR_STYLES`, where it keeps one.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01BU1Vwm6cGgGRcMubszgbGD
Comment thread src/data/item-themes.ts Outdated
…bled

Fallout from e31092d, and a good catch: `current.invert` gates its label
fade on `!inherit-disabled`, on the grounds that something above already
faded the color it paints from. That holds automatically while the color is
INHERITED. An offered accent is not inherited — the element applies it
itself — so nothing above touches it, and inside a disabled banner the
action kept a full-strength `accent-text` label on a dead chip: measured
cr 5.69 light / 6.13 dark, reading live next to a muted dismiss.

Fixed on the offering side rather than the reading side, because that keeps
one rule instead of two. The gate already means "someone above handled the
fade"; the fix is to make that true for the accent path as well. So: a
container that OFFERS a color owns that color in every state. `Banner` now
pairs each `theme=X` accent with a `theme=X & disabled` counterpart at `.4`,
which lands on 1.81 / 2.20 — the same ~2:1 band every other disabled label
in `item-themes` is tuned to.

The alternative, changing the reader to fade on a bare `disabled`, was
rejected: with no accent offered it would fade a second time against a host
that already faded, which is exactly the `.16` washout fixed in 9125615.

Probed — tasty splits the two into mutually exclusive rules, so there is no
cascade ambiguity:

  [data-theme="danger"]:not([data-disabled]) > [data-element="Actions"]
    { --current-accent: var(--danger-accent-text-color) }
  [data-disabled][data-theme="danger"]       > [data-element="Actions"]
    { --current-accent: rgb(var(--danger-accent-text-color-rgb)/.4) }

The accent map is exported as `BANNER_ACTION_ACCENT` and a new
`Banner.test.ts` pins the live/muted pairing, so the contract holds by test
rather than by discipline. The reader-side comment in `CURRENT_INVERT_STYLES`
states it too, for whoever offers the next accent.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01BU1Vwm6cGgGRcMubszgbGD
Reported from the `CurrentTheme` story: on the "Dark banner" container the
`primary` button renders as a blank white chip in light mode.

Same root cause as the Banner case, mirrored. `current.primary`'s pill IS
`currentcolor`, so its label has to contrast with an arbitrary color, and the
fixed `#surface` only manages that while the inherited color sits away from
the page. A container that INVERTS the surface breaks it: the dark banner
paints `#white`, so the pill is white and `#surface` is white too in light
mode — cr 1.00. In dark mode `#surface` is near-black and it happens to
work, which is why the report says "only in light schema".

No fixed token solves both directions: `#surface-text` fixes the inverted
container and collapses the ordinary one (measured 2.60 light / 1.91 dark on
a tinted container), which is the trade-off already documented on this
flavour.

So `primary` now reads the same `--current-accent` the invert flavour does.
The property already means "the color to write with when the inherited one
will not do", and this is the other flavour that cannot always write with
it. The fallback is the `#surface` it always used, so no container that
offers nothing moves at all.

A container that inverts the surface has the answer to hand: its own fill
contrasts with its own text by construction, and the pill IS that text. The
story's two inverted containers ("Dark banner", "Brand") now offer theirs;
the tinted ones leave it unset.

Probed the chain end to end — the container emits
`--current-accent: var(--fixed-dark-color)` and the button reads
`-webkit-text-fill-color: var(--current-accent, var(--surface-color))`, so
the label lands on the container's own dark fill against a white pill. The
four icon slots repeat it, since SVG stroked with `currentColor` never sees
`-webkit-text-fill-color`.

Tests: the reader test now pins BOTH filled flavours and nothing else, and a
new case pins that every read spells out its fallback — a bare
`var(--current-accent)` would resolve to nothing and drop the label wherever
no container offers one. Checked by introducing exactly that.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01BU1Vwm6cGgGRcMubszgbGD
Comment thread src/data/item-themes.ts Outdated
…ixes

The headline: `current.primary` and `current.invert` cannot share one
offered color. Reported from the story — on the dark banner in dark mode
`invert` rendered a near-black label on a near-black chip.

They sit on different chips. `primary`'s is `currentcolor`, whatever the
container paints text with, often scheme-fixed. `invert`'s is `#surface`,
which flips. On a container painting `#white` the first needs a dark label
in BOTH schemes and the second needs one that flips with the page, so a
single value always loses one of them:

  offered            primary (chip #white)   invert (chip #surface)
  container fill     16.31 / 16.31           16.31 /  1.00   <- reported
  #surface-text      16.31 /  1.12           16.31 / 14.51

So `primary` now reads `--current-label` and `invert` keeps
`--current-accent`, each with its own fallback, and the story's two inverted
containers offer both — their own fill for the first, `#surface-text` for
the second. Tests pin that each flavour reads only its own property, that
every read spells out its fallback, and that neither fades an offered color
on a bare `disabled`.

Also, from an external review — all four verified before acting:

1. HotKeys on `current.primary` was `#white` on a light fill. My comment
   claimed `inherit` would draw the hint's rim in the fill color, which is
   false: the hint renders inside `Suffix`, and this same file recolors that
   slot to the LABEL, so `currentcolor` there is the label. Measured cr 2.17
   on a `#note` container in dark. The whole `current` theme now takes
   `inherit` — the neutral `#dark.65`-on-`#dark.04` chip the other flavours
   were getting all but vanishes on a dark overlay, which is where this theme
   is meant to live.

2. `CURRENT_PRIMARY_STYLES.fill` had no second layer at rest, so hover
   snapped instead of interpolating — the one filled flavour in the file
   missing the pin its own neighbours document. Now `#current #black.0`.

3. `current-theme-axis.md` still promised the `type="current"` shim that
   eeb6683 removed, telling consumers the old spelling was safe when it
   resolves to no variant. Rewritten to state the clean break.

4. `BANNER_ACTION_ACCENT` is now keyed by `Record<BannerTheme, true>`, so a
   fifth theme fails the build instead of silently falling through to the
   cr 1.00 the map exists to avoid.

Not taken, with reasons: a `useWarn` for the removed `type="current"` was
declined earlier in this branch (the changeset carries the migration); and
the `hovered` vs `hovered | focused` split between the two `invert` families
is per-family consistency — every `current` flavour steps on focus, every
brand one does not.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01BU1Vwm6cGgGRcMubszgbGD
Comment thread src/data/item-themes.ts Outdated
claude added 2 commits August 20, 2026 10:31
One conflict, in `src/tokens/palette.ts`, and additive on both sides: this
branch adds the `accent-disabled-text` token immediately above the
loading-face block, while main rewrote that block's comment ("Isometric cube
faces", neutral chroma and WCAG-floor contrast). Kept both — our token, then
main's comment and its re-authored face values.

Palette snapshots and the full suite pass on the merge (2053).
The gate I put on this label in 4f0fa82 outlived its reason inside the same
commit. It went on while `primary` read `--current-accent`, which `Banner`
pre-mutes — so the reader had to skip its own fade or halve the value twice.
Splitting the hook moved `primary` onto `--current-label`, which no container
pre-mutes, and the gate then bought nothing while costing the fade in every
nested disabled path: a crisp `#surface` label on a faded chip, still reading
live.

The rule underneath is simpler than "who owns the color", and it is the
fallback that decides it:

  invert   falls back to `currentcolor` — a disabled host already muted it,
           so the reader gates, and the offerer owns every state
  primary  falls back to `#surface`, absolute and untouched by any host,
           so the reader always fades, and the offerer supplies live only

So the asymmetry between the two properties is not a choice; it follows from
which fallback each flavour has. Both sides are now stated where they are
read, and pinned from both directions: one test asserts `invert` never fades
the accent on a bare `disabled`, its converse asserts `primary` always does,
on the label property and all four icon slots.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01BU1Vwm6cGgGRcMubszgbGD
Comment thread src/data/item-themes.ts
Comment thread src/data/item-themes.ts
I had drifted `current.invert` into a second scheme-following construction
with its own property, then spent two commits patching the seams between
them. It is simpler than that, and always was: on this theme `invert` is
`primary` with the two colors exchanged, and nothing else.

  primary   fill #current        label <swap>
  invert    fill <swap>          label #current

`<swap>` is one property, `var(--current-accent, var(--surface-color))`,
read by both. A container offers one value and both flavours move together,
so the mirror cannot come apart — which is what the previous two-property
split allowed, and why the dark banner ended up with a near-black label on a
near-black chip.

Unset, the fallback is the `#surface` both flavours used before the hook, so
nothing outside such a container changes. The story's inverted containers
offer their own fill: `primary` gets a dark label on its white chip and
`invert` a white label on its dark chip, which is what was asked for.

`#current`-derived fades keep their gate — a disabled host has already muted
what they resolve against — while the swap color is faded by whoever reads
it, since nothing above touches it. That asymmetry is now stated once rather
than argued per property.

Also folded in, both from review and both consequences of `current.primary`
keeping `color` as its fill so `#current` resolves there:

- the `Actions` slot is recolored to the label alongside the icon slots. A
  nested `Item.Action` defaults to `theme="current"` and mixes its label from
  the `currentcolor` it inherits, which was the chip — probed, it rendered
  invisible against it.
- `ITEM_RESTING_COLOR_VARIANTS` overrides the same variant, so `ItemButton`'s
  wrapper hands sibling actions the label rather than the chip.

Tests now pin the mirror directly — `primary` paints what `invert` writes and
vice versa — which is a claim about the pair rather than about either object,
and is the thing that kept breaking.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01BU1Vwm6cGgGRcMubszgbGD

@cursor cursor Bot 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.

Cursor Bugbot has reviewed your changes using default effort and found 1 potential issue.

There are 2 total unresolved issues (including 1 from previous review).

Fix All in Cursor

❌ Bugbot Autofix is OFF. To automatically fix reported issues with cloud agents, enable autofix in the Cursor dashboard.

Want higher recall? High effort reviews run extra passes and find more bugs. A team admin can switch effort levels in the Cursor dashboard.

Reviewed by Cursor Bugbot for commit 1f7f7fb. Configure here.

Comment thread src/data/item-themes.ts Outdated
…nt-fill`

Removes the `invert` type from `Button`, `Item`, `Item.Action` and
`Item.Badge` — the `invertStyles()` factory and all eight `*_INVERT_STYLES`,
the type and variant unions, the styles maps, and the stories, docs and
changesets that described it. `Banner.Action` goes back to `current.outline`
on `Item.Action`'s own theme default, so `BANNER_ACCENT_THEMES`,
`BANNER_ACTION_ACCENT`, the `$current-accent` declaration and `Banner.test.ts`
go with it.

`current.primary` keeps the problem `--current-accent` was papering over: it
fills with the color it INHERITS, so its label cannot come from that color
too, and the page (`#surface`) is only the right answer while the inherited
color sits away from the page. A container whose own text color IS the page —
a `Banner` labels itself `#white`, and `#surface` is white in light mode —
collapses label and chip to cr 1.00.

The answer is now an explicit token rather than a private custom property.
`#current-fill` is declared in a new `CONTEXT_TOKENS` block with a `#surface`
default, so it takes the alpha suffix (`#current-fill.5` is the disabled
label) and a container redirects the label, the icon slots and the chip's rim
with one declaration. Two hand-written `color-mix()` strings and
`CURRENT_SWAP_COLOR` are gone with it.

Also fixes a stale paragraph in the CURRENT THEME header, which still claimed
`current.primary` "escalates on alpha instead" of punching out a label — the
construction it described predates the opaque fill.

Size: the branch was 6 bytes over the `Button` budget (124.01 kB against
124 kB); dropping `invert` takes 0.34 kB off that entry and off `All`, landing
at 123.67 kB / 504.04 kB. `.size-limit.cjs` records the measured numbers, and
that `current` is the one feature which legitimately moves the Button budget:
it sits on the theme axis of `Button` itself, so its flavours are in the
variants map and there is nothing to tree-shake.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Addresses the review note that the `current` ramp's `surface=special` steps
keyed off a `data-surface` attribute only `ItemAction` and `ItemBadge` set, so
`Button` and `Item` on the same fixed dark-purple surface silently used the
light ramp — and `theme` can no longer be `special` and `current` at once
anyway. A per-surface axis three of five call sites miss is worse than none,
so the five special steps go, and with them `data-surface` (introduced on this
branch, read by nothing else) and the `contextTheme` reads that fed it.

The dark ramp was the real concern, and it is now derived rather than authored.
Each `@dark` step is solved so its OKHST tone delta from the surface matches
the light step's, which — tone being contrast-shaped — also lands the chip on
the light step's contrast against the page:

  step             light   ΔT     dark   ΔT     cr(chip, page)
  hover            .04     2.64   .031   2.62   1.084 / 1.083
  press            .06     3.99   .046   3.98   1.129 / 1.129
  selected         .18    12.57   .13   12.37   1.467 / 1.457
  selected-hover   .24    17.20   .175  17.16   1.689 / 1.686
  selected-press   .3     22.08   .221  22.13   1.959 / 1.961

The dark alphas come out LOWER, not higher: near the dark end of the scale a
small sRGB move is a large perceptual one, so a light tint on a dark surface
reads stronger than the same tint of a dark label on a light page. The previous
hand-tuned values overshot most at the low end (hover measured 1.210 against
light's 1.084) and already matched at the top step. Calibrated against
`#surface` / `#surface-text` per scheme, which is the only tractable reference
for a theme that paints an arbitrary inherited color over an arbitrary
container.

Also corrects the claim that the dark steps were capped by the AA floor for
their label at `.24`. That figure belonged to the special surface, whose
`#white.8` label measures 4.53 against a `.21` chip; on the plain dark page the
label is opaque and the same steps measure 6.9-9.8, so there was no ceiling
shaping them.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
@tenphi
tenphi merged commit ac2ec33 into main Aug 20, 2026
16 checks passed
@tenphi
tenphi deleted the claude/button-item-current-theme-js20o6 branch August 20, 2026 14:17
@tenphi tenphi mentioned this pull request Aug 20, 2026
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.

2 participants