From 1916cc28983291a31b2dd238644c76326d4b1f08 Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 19 Aug 2026 12:32:14 +0000 Subject: [PATCH 01/26] feat(theme): move `current` from the type axis to the theme axis MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `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 Claude-Session: https://claude.ai/code/session_01BU1Vwm6cGgGRcMubszgbGD --- .changeset/current-theme-axis.md | 45 ++++ .changeset/item-action-current-default.md | 10 +- docs/rules/eslint-plugin.md | 8 +- src/components/actions/Button/Button.docs.mdx | 12 +- .../actions/Button/Button.stories.tsx | 121 ++++++---- src/components/actions/Button/Button.tsx | 62 +++-- src/components/actions/Button/button.test.tsx | 44 ++++ .../actions/ItemAction/ItemAction.docs.mdx | 6 +- .../actions/ItemAction/ItemAction.stories.tsx | 80 ++++++- .../actions/ItemAction/ItemAction.tsx | 87 ++++--- src/components/content/Item/Item.docs.mdx | 20 +- src/components/content/Item/Item.stories.tsx | 138 +++++++++-- src/components/content/Item/Item.test.tsx | 44 ++++ src/components/content/Item/Item.tsx | 50 ++-- .../content/ItemBadge/ItemBadge.docs.mdx | 4 +- .../content/ItemBadge/ItemBadge.stories.tsx | 30 ++- .../content/ItemBadge/ItemBadge.tsx | 80 +++++-- src/data/item-themes.ts | 225 +++++++++++++----- src/eslint-plugin/defaults.generated.ts | 16 +- src/eslint-plugin/fixtures.tsx | 25 +- src/eslint-plugin/probe.test.tsx | 12 +- src/stories/Theming.docs.mdx | 2 +- src/stories/Theming.stories.tsx | 4 +- src/stories/Usage.docs.mdx | 1 + src/tasty-augment.d.ts | 4 + 25 files changed, 851 insertions(+), 279 deletions(-) create mode 100644 .changeset/current-theme-axis.md diff --git a/.changeset/current-theme-axis.md b/.changeset/current-theme-axis.md new file mode 100644 index 000000000..b86e4b6b7 --- /dev/null +++ b/.changeset/current-theme-axis.md @@ -0,0 +1,45 @@ +--- +'@cube-dev/ui-kit': minor +--- + +`current` moves from the `type` axis to the `theme` axis on `Button`, `Item` (and `ItemButton`), `Item.Action` and `ItemBadge`. It was never a shape: it names 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 `current` meant giving up the choice between a filled button, an outlined one and a bare label. + +On the `theme` axis it composes instead, and every type now has a `current` flavour: + +- `item` — the old `Item` shape: no border, nothing painted at rest, the fill stepping in on hover, pressed and selected. +- `clear` — the same ramp plus the focus ring a standalone control needs. The default for `Item.Action` and `ItemBadge`. +- `outline` — the old `Button` shape: a resting `#current.03` chip inside a `#current.08` border. +- `outline-2` — `outline` for a container that is already painting something. The brand themes swap an opaque base (`#surface-3` for `#surface-2`); `current` has no opaque base to swap, so the same intent is carried by roughly doubling the tint at every step. +- `primary` — the high-emphasis control. Every other theme paints an opaque brand fill under a `#white` label; with one inherited color and an unknown surface behind it there is nothing to punch the label out with, so `primary` escalates the alpha ramp instead, resting at `#current.14`. It reads as filled, not as inverted. +- `link` — no chip at all. The brand themes intensify from `accent-text-soft` to `accent-text` on hover; here "soft" is the inherited color at `.8` and "strong" is it at full opacity. +- `card` — the static panel: a `#current.05` fill 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. + +The top step of each ramp stops at `#current.24`. That is the measured AA floor for a full-strength label on a dark surface — the one place this construction inverts, since the chip climbs toward an equally light label instead of away from a dark one. + +### Migration + +`type="current"` still renders, mapped to the flavour it used to be, and warns once in development: + +| Old | New | +| ------------------------------ | ------------------------------------------------------- | +| ` + + ``` ### Sizes. `size` prop diff --git a/src/components/actions/Button/Button.stories.tsx b/src/components/actions/Button/Button.stories.tsx index 01555420d..f1736e186 100644 --- a/src/components/actions/Button/Button.stories.tsx +++ b/src/components/actions/Button/Button.stories.tsx @@ -24,7 +24,7 @@ export default { argTypes: { /* Visual presentation */ type: { - options: ['primary', 'outline', 'outline-2', 'clear', 'link', 'current'], + options: ['primary', 'outline', 'outline-2', 'clear', 'link'], control: { type: 'radio' }, description: 'Visual style variant of the button', table: { @@ -32,7 +32,15 @@ export default { }, }, theme: { - options: ['default', 'danger', 'success', 'warning', 'note', 'special'], + options: [ + 'default', + 'danger', + 'success', + 'warning', + 'note', + 'special', + 'current', + ], control: { type: 'radio' }, description: 'Semantic colour palette theme', table: { @@ -174,21 +182,15 @@ const BUTTON_TYPES = [ 'outline-2', 'clear', 'link', - 'current', ] as const; -const SELECTED_TYPES: string[] = ['outline', 'outline-2', 'clear', 'current']; +const SELECTED_TYPES: string[] = ['outline', 'outline-2', 'clear']; // Types whose base fill is `#surface-3` and are therefore designed to sit // on a `#surface-2` container (so they remain visible against the // surrounding ladder). const SURFACE_2_TYPES: string[] = ['outline-2']; -// `current` mixes every color from the inherited text color, so it only means -// anything inside a container that paints one. Shown on a note-colored block -// here; `CurrentType` below sweeps the rest of the contexts. -const INHERITED_COLOR_TYPES: string[] = ['current']; - const BASE_MODS = { hovered: false, pressed: false, @@ -205,7 +207,14 @@ const TypeStatesRow = ({ theme?: CubeButtonProps['theme']; }) => { const hasSelected = SELECTED_TYPES.includes(type!); - const titleColor = theme === 'special' ? '#white' : undefined; + // On `current` the heading rides the container's own color, the same way the + // buttons under it do. + const titleColor = + theme === 'special' + ? '#white' + : theme === 'current' + ? '#current' + : undefined; return ( @@ -320,25 +329,25 @@ const TypeStatesRow = ({ const ThemeStatesTemplate: StoryFn<CubeButtonProps> = ({ theme }) => { const isSpecial = theme === 'special'; + // `current` mixes every color from the inherited text color, so it only means + // anything inside a container that paints one — the whole sweep therefore runs + // inside a colored block. `CurrentTheme` below sweeps the other contexts. + const isCurrent = theme === 'current'; // `outline-2` uses `#surface-3` as its base fill (so it stands out on a // `#surface-2` container) and has no counterpart in the special theme, // which is anchored on the fixed `#special-surface` base. - // `current` derives its colors from the inherited text color and has a - // single, theme-agnostic variant — showing it once (on the default theme) - // says everything the per-theme repeats would. const visibleTypes = BUTTON_TYPES.filter( - (type) => - !(isSpecial && type === 'outline-2') && - !(theme && theme !== 'default' && type === 'current'), + (type) => !(isSpecial && type === 'outline-2'), ); return ( <Space flow="column" gap="3x" - padding={isSpecial ? '2x' : undefined} - fill={isSpecial ? '#black' : undefined} + padding={isSpecial || isCurrent ? '2x' : undefined} + fill={isSpecial ? '#black' : isCurrent ? '#note-surface' : undefined} + color={isCurrent ? '#note-accent-text' : undefined} radius="1x" > {visibleTypes.map((type) => @@ -346,18 +355,10 @@ const ThemeStatesTemplate: StoryFn<CubeButtonProps> = ({ theme }) => { <Space key={type} flow="column" - fill="#surface-2" - padding="1.5x" - radius="1x" - > - <TypeStatesRow key={type} type={type} theme={theme} /> - </Space> - ) : INHERITED_COLOR_TYPES.includes(type) ? ( - <Space - key={type} - flow="column" - fill="#note-surface" - color="#note-accent-text" + // `current` has no opaque base to swap, so the "one rung up the + // surface ladder" container is a tint of the inherited color rather + // than `#surface-2`. + fill={isCurrent ? '#current.08' : '#surface-2'} padding="1.5x" radius="1x" > @@ -404,6 +405,20 @@ SpecialStates.args = { theme: 'special', }; +export const CurrentStates = ThemeStatesTemplate.bind({}); +CurrentStates.args = { + theme: 'current', +}; + +CurrentStates.parameters = { + docs: { + description: { + story: + 'Every type, every state, on the `current` theme. Nothing here names a color: the block is painted `#note-surface` / `#note-accent-text` and each button mixes its fill, border and label from that inherited text color. `primary` is the strongest step of the same alpha ramp rather than an inverted fill — a single inherited color has nothing to punch the label out with — and `outline-2` sits in a `#current.08` panel, the `current` stand-in for the `#surface-2` container `outline-2` is drawn for. Swap the block color and the whole sweep follows it; `CurrentTheme` below does exactly that across seven containers.', + }, + }, +}; + export const Small = Template.bind({}); Small.args = { children: 'Button', @@ -539,8 +554,8 @@ DisabledWithTooltip.parameters = { }, }; -// Contexts the `current` type is meant to live in: each one paints its own text -// color, and the button is expected to adopt it without any theme prop. +// Contexts the `current` theme is meant to live in: each one paints its own text +// color, and the button is expected to adopt it whatever its type. const CURRENT_CONTEXTS = [ { label: 'Page surface (inherited)', fill: undefined, color: undefined }, { label: 'Danger', fill: '#danger-surface', color: '#danger-accent-text' }, @@ -577,25 +592,45 @@ const CurrentContext = ({ </Space> ); -export const CurrentType: StoryFn<CubeButtonProps> = () => ( +export const CurrentTheme: StoryFn<CubeButtonProps> = () => ( <Space flow="column" gap="3x"> <Title level={5}>Inherited Colors {CURRENT_CONTEXTS.map(({ label, fill, color }) => ( - - - - - + ))} ))} @@ -616,7 +651,7 @@ export const CurrentType: StoryFn = () => ( @@ -630,7 +665,7 @@ export const CurrentType: StoryFn = () => ( {(['xsmall', 'small', 'medium', 'large', 'xlarge'] as const).map( (size) => ( - ), @@ -640,13 +675,13 @@ export const CurrentType: StoryFn = () => ( ); -CurrentType.parameters = { +CurrentTheme.parameters = { docs: { description: { story: - 'The `current` type derives its colors — fill, border, label — from the inherited text color (`currentcolor`), so a button adopts whatever color its container paints with and needs no `theme` (the type has a single, theme-agnostic variant). The label stays fully opaque; the resting chip is a `#current.03` fill inside a `#current.08` border, and hover, pressed and selected step the same alpha ramp up from there. The focus ring stays `#primary-accent-text`, like every other type. Use it inside colored containers — alerts, banners, dark overlays, tooltips — where a themed type would either clash with the container or have to be picked to match it. `Item` has a `current` type too, shaped like the neutral `item` type instead: borderless and invisible at rest.', + 'The `current` theme derives its colors — fill, border, label — from the inherited text color (`currentcolor`), so a button adopts whatever color its container paints with. It sits on the `theme` axis rather than the `type` axis because it is a color source, not a shape: every type has a `current` flavour, so emphasis is still chosen the usual way. `outline` (the default) is a `#current.03` chip inside a `#current.08` border, with hover, pressed and selected stepping the same alpha ramp; `primary` starts that ramp at `#current.14`; `clear` paints nothing at rest; `link` is the label alone. The label stays fully opaque and the focus ring stays `#primary-accent-text`, like every other theme. Use it inside colored containers — alerts, banners, dark overlays, tooltips — where a brand theme would either clash with the container or have to be picked to match it.', }, }, }; -CurrentType.args = {}; +CurrentTheme.args = {}; diff --git a/src/components/actions/Button/Button.tsx b/src/components/actions/Button/Button.tsx index e43d6e6ed..a341d2e0b 100644 --- a/src/components/actions/Button/Button.tsx +++ b/src/components/actions/Button/Button.tsx @@ -19,11 +19,15 @@ import { } from 'react'; import { OverlayProps } from 'react-aria'; -import { useEvent } from '../../../_internal'; +import { useDeprecationWarning, useEvent } from '../../../_internal'; import { useIsFirstRender } from '../../../_internal/hooks/use-is-first-render'; import { useWarn } from '../../../_internal/hooks/use-warn'; import { - CURRENT_BUTTON_STYLES, + CURRENT_CLEAR_STYLES, + CURRENT_LINK_STYLES, + CURRENT_OUTLINE_2_STYLES, + CURRENT_OUTLINE_STYLES, + CURRENT_PRIMARY_STYLES, DANGER_CLEAR_STYLES, DANGER_LINK_STYLES, DANGER_OUTLINE_2_STYLES, @@ -105,6 +109,14 @@ export interface CubeButtonProps extends CubeActionProps { | 'clear' | 'outline' | 'outline-2' + | (string & {}); + theme?: + | 'default' + | 'danger' + | 'success' + | 'warning' + | 'note' + | 'special' | 'current' | (string & {}); size?: @@ -135,9 +147,13 @@ export interface CubeButtonProps extends CubeActionProps { } export type ButtonVariant = - // The `current` type derives every color from the inherited `currentcolor`, - // so it has no per-theme flavours — see `CURRENT_BUTTON_STYLES`. - | 'default.current' + // The `current` theme mixes every color from the inherited `currentcolor` + // instead of a brand ramp — see the CURRENT THEME section of `item-themes`. + | 'current.primary' + | 'current.outline' + | 'current.outline-2' + | 'current.clear' + | 'current.link' | 'default.primary' | 'default.outline' | 'default.outline-2' @@ -322,8 +338,12 @@ const ButtonElement = tasty({ qa: 'Button', styles: DEFAULT_BUTTON_STYLES, variants: { - // Inherited-color type — theme-agnostic, see `CURRENT_BUTTON_STYLES` - 'default.current': CURRENT_BUTTON_STYLES, + // Current theme — colors mixed from the inherited `currentcolor` + 'current.primary': CURRENT_PRIMARY_STYLES, + 'current.outline': CURRENT_OUTLINE_STYLES, + 'current.outline-2': CURRENT_OUTLINE_2_STYLES, + 'current.clear': CURRENT_CLEAR_STYLES, + 'current.link': CURRENT_LINK_STYLES, // Default theme 'default.primary': DEFAULT_PRIMARY_STYLES, @@ -392,6 +412,26 @@ export const Button = forwardRef(function Button( } = allProps; type = type ?? splitContext?.type; + + // `current` moved from the `type` axis to the `theme` axis: it selects a color + // source, not a shape, so every shape can now have a `current` flavour. The + // old spelling still renders — mapped to the flavour it used to be, the + // standalone `outline` chip — and warns. + const isLegacyCurrentType = type === 'current'; + + useDeprecationWarning(!isLegacyCurrentType, { + property: 'type="current"', + name: 'Button', + betterAlternative: 'theme="current"', + reason: + '`current` is a color source rather than a shape, so it now lives on the `theme` axis and composes with every `type`. `type="current"` maps to `theme="current" type="outline"`.', + }); + + if (isLegacyCurrentType) { + type = 'outline'; + theme = 'current'; + } + const size = sizeProp ?? splitContext?.size ?? (type === 'link' ? 'inline' : 'medium'); @@ -609,10 +649,6 @@ export const Button = forwardRef(function Button( const effectiveType = theme === 'special' && type === 'outline-2' ? 'outline' : type; - // `current` paints from the inherited `currentcolor`, so a theme would have - // nothing to change — it has a single, theme-agnostic variant. - const variantTheme = effectiveType === 'current' ? 'default' : theme; - return ( ', () => { spy.mockRestore(); }); + describe('current theme', () => { + it('should render the outline shape by default', () => { + render( + , + ); + + const button = screen.getByTestId('Current'); + + expect(button).toHaveAttribute('data-theme', 'current'); + expect(button).toHaveAttribute('data-type', 'outline'); + }); + + it('should compose with every type', () => { + render( + , + ); + + const button = screen.getByTestId('Current'); + + expect(button).toHaveAttribute('data-theme', 'current'); + expect(button).toHaveAttribute('data-type', 'primary'); + }); + + // `current` used to live on the `type` axis. The old spelling keeps + // rendering the flavour it named — the standalone outline chip — so the + // move is not a breaking change. + it('should keep rendering the legacy type="current" spelling', () => { + render( + , + ); + + const button = screen.getByTestId('Legacy'); + + expect(button).toHaveAttribute('data-theme', 'current'); + expect(button).toHaveAttribute('data-type', 'outline'); + }); + }); + describe('disabled state', () => { it('should use the native attribute when there is no tooltip', () => { render(); diff --git a/src/components/actions/ItemAction/ItemAction.docs.mdx b/src/components/actions/ItemAction/ItemAction.docs.mdx index bfda74378..8a31a983c 100644 --- a/src/components/actions/ItemAction/ItemAction.docs.mdx +++ b/src/components/actions/ItemAction/ItemAction.docs.mdx @@ -5,7 +5,7 @@ import * as ItemActionStories from './ItemAction.stories'; # ItemAction -A compact action button designed for use inside `Item`, `ItemButton`, and `ItemCard`. Automatically inherits `type` and `theme` from its parent context. +A compact action button designed for use inside `Item`, `ItemButton`, and `ItemCard`. Defaults to the `current` theme, so it takes its colors from whatever row hosts it. ## When to Use @@ -22,8 +22,8 @@ A compact action button designed for use inside `Item`, `ItemButton`, and `ItemC ## Properties - **`icon`** `ReactNode | 'checkmark'` — Icon element or `'checkmark'` for a selection indicator -- **`type`** `'primary' | 'outline' | 'clear' | 'current'` (default: `'current'`) — Visual type. `current` derives every color from the inherited `currentcolor`, so the action tracks whatever row hosts it without being told the row's type. Passing an explicit `theme` falls back to `'clear'`, since `current` is theme-agnostic and the theme would have nothing to color. -- **`theme`** `'default' | 'danger' | 'success' | 'warning' | 'note' | 'special'` (default: `'default'`) — Color theme. Inherits from parent context. +- **`type`** `'primary' | 'outline' | 'clear'` (default: `'clear'`) — Visual type. `clear` is borderless and paints nothing at rest, so a row full of actions does not read as busy. +- **`theme`** `'current' | 'default' | 'danger' | 'success' | 'warning' | 'note' | 'special'` (default: `'current'`) — Color theme. `current` derives every color from the inherited `currentcolor`, so the action tracks whatever row hosts it without being told the row's type or theme. Name any other theme to have the action paint itself instead. - **`isLoading`** `boolean` (default: `false`) — Shows loading spinner - **`isSelected`** `boolean` (default: `false`) — Selected state (works with `icon="checkmark"`) - **`isDisabled`** `boolean` (default: `false`) — Disables the action. Inherits from parent; use `isDisabled={false}` to override. diff --git a/src/components/actions/ItemAction/ItemAction.stories.tsx b/src/components/actions/ItemAction/ItemAction.stories.tsx index 323721643..2b598b61b 100644 --- a/src/components/actions/ItemAction/ItemAction.stories.tsx +++ b/src/components/actions/ItemAction/ItemAction.stories.tsx @@ -148,17 +148,47 @@ export const Types: Story = { Primary - } tooltip="Edit" /> - } tooltip="Copy" /> - } tooltip="Delete" /> + } + tooltip="Edit" + /> + } + tooltip="Copy" + /> + } + tooltip="Delete" + /> Outline (unselected) - } tooltip="Edit" /> - } tooltip="Copy" /> - } tooltip="Delete" /> + } + tooltip="Edit" + /> + } + tooltip="Copy" + /> + } + tooltip="Delete" + /> @@ -166,18 +196,21 @@ export const Types: Story = { } tooltip="Edit" /> } tooltip="Copy" /> } tooltip="Delete" @@ -187,9 +220,9 @@ export const Types: Story = { Clear (unselected) - } tooltip="Edit" /> - } tooltip="Copy" /> - } tooltip="Delete" /> + } tooltip="Edit" /> + } tooltip="Copy" /> + } tooltip="Delete" /> @@ -197,19 +230,19 @@ export const Types: Story = { } tooltip="Edit" /> } tooltip="Copy" /> } tooltip="Delete" /> @@ -270,10 +303,32 @@ export const Themes: Story = { } tooltip="Delete" /> + + Current Theme (default) + + } tooltip="Edit" /> + } tooltip="Copy" /> + } tooltip="Delete" /> + + ), }; +Themes.parameters = { + docs: { + description: { + story: + "`current` is the default theme, and the only one that names no color of its own: it mixes fill and label from the container's inherited text color, so an action tracks whatever row hosts it. Name any other theme to have the action paint itself instead.", + }, + }, +}; + export const States: Story = { render: (args) => ( @@ -432,7 +487,6 @@ export const InsideItemButton: Story = { } tooltip="Delete" - type="clear" theme="danger" /> diff --git a/src/components/actions/ItemAction/ItemAction.tsx b/src/components/actions/ItemAction/ItemAction.tsx index 048b5450e..a738916f9 100644 --- a/src/components/actions/ItemAction/ItemAction.tsx +++ b/src/components/actions/ItemAction/ItemAction.tsx @@ -9,8 +9,11 @@ import { useMemo, } from 'react'; +import { useDeprecationWarning } from '../../../_internal'; import { - CURRENT_ITEM_STYLES, + CURRENT_CLEAR_STYLES, + CURRENT_OUTLINE_STYLES, + CURRENT_PRIMARY_STYLES, DANGER_CLEAR_STYLES, DANGER_OUTLINE_STYLES, DANGER_PRIMARY_STYLES, @@ -45,8 +48,9 @@ export interface CubeItemActionProps children?: ReactNode; isLoading?: boolean; isSelected?: boolean; - type?: 'primary' | 'outline' | 'clear' | 'current' | (string & {}); + type?: 'primary' | 'outline' | 'clear' | (string & {}); theme?: + | 'current' | 'default' | 'danger' | 'success' @@ -64,8 +68,10 @@ export interface CubeItemActionProps } type ItemActionVariant = - // Theme-agnostic inherited-color type — see `CURRENT_ITEM_STYLES`. - | 'default.current' + // Inherited-color theme — see the CURRENT THEME section of `item-themes`. + | 'current.primary' + | 'current.outline' + | 'current.clear' | 'default.primary' | 'default.outline' | 'default.clear' @@ -90,11 +96,10 @@ const ItemActionElement = tasty({ styles: { ...ITEM_ACTION_BASE_STYLES, recipe: 'reset button', - // Every variant below defines its own ring and overrides this one. It exists - // for `current`, which reuses `CURRENT_ITEM_STYLES` — an `*_ITEM_STYLES` - // flavour, and those leave focus to the collection that owns the row. A - // focusable action is not a row, so it needs the ring back, and it uses the - // same `#primary-accent-text` as every other type in `item-themes.ts`. + // Every variant below defines its own ring and overrides this one, which is + // kept as the floor for a custom `type` that resolves to no variant at all. + // It uses the same `#primary-accent-text` as every variant in + // `item-themes.ts`. outline: { '': '0 #primary-accent-text.0', focused: '1bw #primary-accent-text', @@ -126,9 +131,12 @@ const ItemActionElement = tasty({ }, }, variants: { - // Inherited-color type — theme-agnostic, see `CURRENT_ITEM_STYLES`. Actions - // inside a `current` Item use the borderless item flavour, not the chip. - 'default.current': CURRENT_ITEM_STYLES, + // Current theme — colors mixed from the inherited `currentcolor`. The + // default `clear` flavour is borderless, so an action does not put a resting + // chip on every row. + 'current.primary': CURRENT_PRIMARY_STYLES, + 'current.outline': CURRENT_OUTLINE_STYLES, + 'current.clear': CURRENT_CLEAR_STYLES, // Default theme 'default.primary': DEFAULT_PRIMARY_STYLES, @@ -173,19 +181,15 @@ export const ItemAction = forwardRef(function ItemAction( isDisabled: contextIsDisabled, } = useItemActionContext(); - const { - // `current` derives every color from the row's inherited `currentcolor`, so - // one type covers every host type and theme — no need to mirror the row's - // `type` from context. - // - // An explicitly *themed* action is the exception: it is asking to paint - // itself, not to match its host, and `current` is theme-agnostic by - // construction, so it would have nothing to color with. Such actions fall - // back to `clear`. The `!== 'default'` guard keeps `theme="default"` inert — - // passing a prop's own default value must never change what renders, which - // is the invariant `no-redundant-default-prop` lints for. - type = allProps.theme && allProps.theme !== 'default' ? 'clear' : 'current', - theme = contextTheme ?? 'default', + let { + // Borderless by default: an action sits inside a row, where a resting chip + // on every one of them would read as noise. + type = 'clear', + // The `current` theme derives every color from the row's inherited + // `currentcolor`, so one default covers every host type and theme — no need + // to mirror the row's own `theme` from context. An action that names a theme + // is asking to paint itself rather than match its host, and gets it. + theme = 'current', icon, children, isLoading = false, @@ -197,13 +201,31 @@ export const ItemAction = forwardRef(function ItemAction( ...rest } = allProps; + // `current` moved from the `type` axis to the `theme` axis. The old spelling + // still renders — mapped to the flavour it used to be, the borderless `clear` + // one — and warns. + const isLegacyCurrentType = type === 'current'; + + useDeprecationWarning(!isLegacyCurrentType, { + property: 'type="current"', + name: 'ItemAction', + betterAlternative: 'theme="current"', + reason: + '`current` is a color source rather than a shape, so it now lives on the `theme` axis and composes with every `type`. It is already the default theme, so `type="current"` can simply be dropped.', + }); + + if (isLegacyCurrentType) { + type = 'clear'; + theme = 'current'; + } + // Inherit disabled state from context, but allow local override const isDisabled = isDisabledProp ?? contextIsDisabled; // Whether that disabled state came from the host row rather than this action's - // own prop. The `current` type paints from the inherited color, and a disabled - // host has already faded it — so fading a second time washes the label out. See - // `CURRENT_ITEM_STYLES.color`. + // own prop. The `current` theme paints from the inherited color, and a + // disabled host has already faded it — so fading a second time washes the + // label out. See `CURRENT_ITEM_STYLES.color`. const isDisabledInherited = isDisabledProp == null && !!contextIsDisabled; // Determine if we should show a checkmark @@ -325,11 +347,14 @@ export const ItemAction = forwardRef(function ItemAction( return ( diff --git a/src/components/content/Item/Item.docs.mdx b/src/components/content/Item/Item.docs.mdx index 441b91888..41bd3426d 100644 --- a/src/components/content/Item/Item.docs.mdx +++ b/src/components/content/Item/Item.docs.mdx @@ -30,8 +30,8 @@ A foundational component that provides a standardized layout and styling for ite - **`suffix`** `ReactNode` — Element rendered after the content (before rightIcon) - **`description`** `ReactNode` — Description text displayed with the item - **`descriptionPlacement`** `'inline' | 'block'` (default: `inline`) — How the description is positioned relative to the main content. Defaults to `'block'` for `type="card"` and `type="header"` -- **`type`** `'item' | 'header' | 'primary' | 'outline' | 'outline-2' | 'clear' | 'link' | 'card' | 'current'` (default: `item`) — Visual style variant -- **`theme`** `'default' | 'danger' | 'success' | 'warning' | 'note' | 'special'` (default: `default`) — Semantic colour palette theme +- **`type`** `'item' | 'header' | 'primary' | 'outline' | 'outline-2' | 'clear' | 'link' | 'card'` (default: `item`) — Visual style variant +- **`theme`** `'default' | 'danger' | 'success' | 'warning' | 'note' | 'special' | 'current'` (default: `default`) — Semantic colour palette theme. `current` is not a brand palette: it mixes every color from the inherited text color, so the item adopts the color of whatever container it sits in - **`size`** `'xsmall' | 'small' | 'medium' | 'large' | 'xlarge' | 'inline' | number` (default: `medium`) — Item size. Accepts custom number or string values - **`shape`** `'card' | 'button' | 'sharp' | 'pill'` (default: `button`) — Shape of the item border radius. Defaults to `'card'` for `type="card"` - **`isSelected`** `boolean` (default: `false`) — Marks the item as selected @@ -92,8 +92,8 @@ The `mods` property accepts the following modifiers: - **`disabled`** `boolean` — Applied when isDisabled is true or when loading - **`loading`** `boolean` — Applied when isLoading is true - **`size`** `string` — Applied based on size prop value (xsmall, small, medium, large, xlarge, inline) -- **`type`** `string` — Applied based on type prop value (item, header, primary, outline, outline-2, clear, link, card, current) -- **`theme`** `string` — Applied based on theme prop value (default, danger, success, special, warning, note) +- **`type`** `string` — Applied based on type prop value (item, header, primary, outline, outline-2, clear, link, card) +- **`theme`** `string` — Applied based on theme prop value (default, danger, success, special, warning, note, current) - **`shape`** `string` — Applied based on shape prop value (card, button, sharp, pill) ## Variants @@ -107,8 +107,7 @@ The `mods` property accepts the following modifiers: - `outline-2` - Same as `outline` but uses `#surface-3` as the base fill, designed to sit on `#surface-2` containers without blending in (not available for the `special` theme) - `clear` - Transparent item with minimal styling; `isSelected` adds brand-tinted fill - `link` - Link-styled item appearance (does not support icons or loading state) -- `card` - Card appearance for notifications (supports `default`, `success`, `danger`, `warning`, `note` themes; defaults to `shape="card"`, `descriptionPlacement="block"`, uses semantic heading tags via `level` prop, and has `.5x` padding) -- `current` - Same shape as `item` — no border, nothing painted at rest — but every color is derived from the inherited text color (`#current`) instead of the fixed `#surface-text`, so the item adopts the color of whatever container it sits in. The fill steps in on hover (`.04`), pressed (`.06`) and selected (`.09` → `.15`); the label stays fully opaque. Theme-agnostic: only supports the `default` theme (any other theme warns, since there is nothing for it to change). Use it inside colored containers — alerts, banners, dark overlays, tooltips. `Button` has a `current` type too, shaped like a standalone control instead: a resting `#current.03` chip inside a `#current.08` border +- `card` - Card appearance for notifications (supports `default`, `success`, `danger`, `warning`, `note`, `current` themes; defaults to `shape="card"`, `descriptionPlacement="block"`, uses semantic heading tags via `level` prop, and has `.5x` padding) ### Themes @@ -118,6 +117,7 @@ The `mods` property accepts the following modifiers: - `warning` - Yellow/amber theme for caution or attention - `note` - Violet theme for informational context - `special` - Special theme with unique styling (not available for `card` or `header` types) +- `current` - Not a palette at all: every color is derived from the inherited text color (`#current`) instead of a brand ramp, so the item adopts the color of whatever container it sits in. Every type except `header` has a `current` flavour, so the shape is still yours to pick — `item` keeps its borderless shape (nothing painted at rest, the fill stepping in on hover, pressed and selected), `outline` adds the chip and border, `primary` escalates it further. The label stays fully opaque. Use it inside colored containers — alerts, banners, dark overlays, tooltips ### Sizes @@ -267,17 +267,17 @@ Like headers, card labels use semantic heading tags (h1-h6) controlled by the `l ``` -### Current Type +### Current Theme -Use `type="current"` inside a container that paints its own text color. The fill and label are mixed from that inherited color, so the item matches the container without picking a theme — and keeps matching it if the container's color changes: +Use `theme="current"` inside a container that paints its own text color. The fill and label are mixed from that inherited color, so the item matches the container without picking a brand theme — and keeps matching it if the container's color changes. Every type composes with it: ```jsx - }>Adopts the note color + }>Adopts the note color - }>Adopts white on a dark banner + }>An outlined chip on a dark banner ``` diff --git a/src/components/content/Item/Item.stories.tsx b/src/components/content/Item/Item.stories.tsx index e84e14307..509970796 100644 --- a/src/components/content/Item/Item.stories.tsx +++ b/src/components/content/Item/Item.stories.tsx @@ -2360,8 +2360,14 @@ CustomSize.parameters = { export const TypesAndThemes: StoryFn = (args) => { // Valid type+theme combinations: // - title: only 'default' - // - card: 'default', 'success', 'danger', 'warning', 'note' - // - all other types: 'default', 'success', 'danger', 'warning', 'note', 'special' + // - card: 'default', 'success', 'danger', 'warning', 'note', 'current' + // - all other types: 'default', 'success', 'danger', 'warning', 'note', + // 'special', 'current' + // + // `current` is swept by `CurrentTheme` / `CurrentStates` instead of here: it + // mixes its colors from the inherited text color, so it only means anything + // inside a container that paints one, and a bare cell would show nothing the + // `default` cell next to it does not. const standardTypes = [ 'item', 'primary', @@ -2453,13 +2459,13 @@ TypesAndThemes.parameters = { docs: { description: { story: - 'Showcases all valid type and theme combinations. Valid combinations: `title` type only supports `default` theme; `card` type supports `default`, `success`, `danger`, `warning`, and `note` themes; all other types (`item`, `primary`, `outline`, `outline-2`, `clear`, `link`) support `default`, `success`, `danger`, `warning`, `note`, and `special` themes (the `outline-2` + `special` combination falls back to `outline` since `outline-2` paints over `#surface-3`, which has no analogue on the special theme). The `link` type does not support icons or loading state (`isLoading`). Using an invalid type+theme combination, icons with `link` type, or `isLoading` with `link` type will trigger a console warning.', + 'Showcases all valid type and theme combinations. Valid combinations: `title` type only supports `default` theme; `card` type supports `default`, `success`, `danger`, `warning`, `note` and `current` themes; all other types (`item`, `primary`, `outline`, `outline-2`, `clear`, `link`) support `default`, `success`, `danger`, `warning`, `note`, `special` and `current` themes (the `outline-2` + `special` combination falls back to `outline` since `outline-2` paints over `#surface-3`, which has no analogue on the special theme). The `current` theme is swept by `CurrentTheme` and `CurrentStates` instead of shown here, since it only means anything inside a container that paints its own text color. The `link` type does not support icons or loading state (`isLoading`). Using an invalid type+theme combination, icons with `link` type, or `isLoading` with `link` type will trigger a console warning.', }, }, }; -// Contexts the `current` type is meant to live in: each one paints its own text -// color, and the item is expected to adopt it without any theme prop. +// Contexts the `current` theme is meant to live in: each one paints its own text +// color, and the item is expected to adopt it whatever its type. const CURRENT_CONTEXTS = [ { label: 'Page surface (inherited)', fill: undefined, color: undefined }, { label: 'Danger', fill: '#danger-surface', color: '#danger-accent-text' }, @@ -2483,7 +2489,7 @@ const CURRENT_STATES = [ { label: 'disabled', mods: { disabled: true } }, ] as const; -export const CurrentType: StoryFn = (args) => ( +export const CurrentTheme: StoryFn = (args) => ( Inherited Colors @@ -2500,13 +2506,13 @@ export const CurrentType: StoryFn = (args) => ( {label} - }> + }> Default - }> + }> Selected - }> + }> Disabled @@ -2543,7 +2549,7 @@ export const CurrentType: StoryFn = (args) => ( key={stateLabel} {...args} mods={mods} - type="current" + theme="current" icon={} > {stateLabel} @@ -2556,7 +2562,7 @@ export const CurrentType: StoryFn = (args) => ( - Against the Neutral `item` Type + Against the Default Theme Same ramp, same steps — `current` only swaps the fixed `#surface-text` anchor for the inherited color, so on the page surface the two are @@ -2581,13 +2587,18 @@ export const CurrentType: StoryFn = (args) => ( > {label} - {(['item', 'current'] as const).map((type) => ( - + {(['default', 'current'] as const).map((itemTheme) => ( + - {type} + {itemTheme} {CURRENT_STATES.map(({ label: stateLabel, mods }) => ( - + {stateLabel} ))} @@ -2615,7 +2626,7 @@ export const CurrentType: StoryFn = (args) => ( {...args} icon={} size={size} - type="current" + theme="current" > {size} @@ -2623,22 +2634,22 @@ export const CurrentType: StoryFn = (args) => ( )} - } /> - } shape="pill"> + } /> + } shape="pill"> Pill } rightIcon={} > Both icons - + With hotkeys - }> + }> Loading @@ -2646,7 +2657,7 @@ export const CurrentType: StoryFn = (args) => ( {...args} description="Actions and description inherit the same color" icon={} - type="current" + theme="current" width="40x" actions={ <> @@ -2663,11 +2674,90 @@ export const CurrentType: StoryFn = (args) => ( ); -CurrentType.parameters = { +CurrentTheme.parameters = { + docs: { + description: { + story: + 'The `current` theme derives every color — fill and label — from the inherited text color (`currentcolor`), so an item adopts whatever color its context paints with. It sits on the `theme` axis rather than the `type` axis because it is a color source, not a shape: every type except `header` has a `current` flavour, so the shape is still chosen the usual way. The default `item` type keeps its borderless shape — nothing painted at rest, the fill stepping in on hover (`.04`), pressed (`.06`) and selected (`.18` → `.30`) — and the label stays fully opaque. Use it inside colored containers — alerts, banners, dark overlays, tooltips — where a brand theme would either clash with the container or have to be picked to match it. `CurrentStates` below sweeps every type and state on one container.', + }, + }, +}; + +// Every shape the `current` theme has a flavour for. `header` is the one type +// left out: it is theme-agnostic and folds onto `item` visuals on the default +// theme whatever theme it is given. +const CURRENT_TYPES = [ + 'item', + 'primary', + 'outline', + 'outline-2', + 'clear', + 'link', +] as const; + +export const CurrentStates: StoryFn = (args) => ( + + {[ + { + label: 'On note surface', + fill: '#note-surface', + color: '#note-accent-text', + }, + { label: 'On dark', fill: '#fixed-dark', color: '#white' }, + ].map(({ label, fill, color }) => ( + + + {label} + {CURRENT_TYPES.map((type) => ( + + {type} + + {CURRENT_STATES.map(({ label: stateLabel, mods }) => ( + + {stateLabel} + + ))} + + + ))} + + card + } + > + Card + + + + + ))} + +); + +CurrentStates.parameters = { docs: { description: { story: - 'The `current` type derives every color — fill and label — from the inherited text color (`currentcolor`), so an item adopts whatever color its context paints with and needs no `theme` (passing one other than `default` warns). It is shaped like the neutral `item` type: no border, nothing painted at rest, the fill stepping in on hover (`.04`), pressed (`.06`) and selected (`.09` → `.15`); the label stays fully opaque. Use it inside colored containers — alerts, banners, dark overlays, tooltips — where a themed type would either clash with the container or have to be picked to match it. `Button` has a `current` type too, shaped like a standalone control instead: a resting `#current.03` chip inside a `#current.08` border.', + 'Every type, every state, on the `current` theme — the matrix the other themes get from `TypesAndThemes`, run on the one theme whose colors are not its own. Nothing in the grid names a color: each block paints a text color and every item mixes its fill, border and label from it. `primary` is the strongest step of the same alpha ramp rather than an inverted fill — a single inherited color has nothing to punch the label out with — and `outline-2` sits in a `#current.08` panel, the `current` stand-in for the `#surface-2` container it is drawn for. `link` carries no chip at all: it goes soft (`#current.8`) at rest and full strength on hover.', }, }, }; diff --git a/src/components/content/Item/Item.test.tsx b/src/components/content/Item/Item.test.tsx index 92aa8aca0..056a32d93 100644 --- a/src/components/content/Item/Item.test.tsx +++ b/src/components/content/Item/Item.test.tsx @@ -10,6 +10,50 @@ import { ItemButton } from '../../actions/ItemButton'; import { Item } from './Item'; describe('', () => { + describe('current theme', () => { + it('should render the item shape by default', () => { + renderWithRoot( + + label + , + ); + + const item = screen.getByTestId('Current'); + + expect(item).toHaveAttribute('data-theme', 'current'); + expect(item).toHaveAttribute('data-type', 'item'); + }); + + it('should compose with every type', () => { + renderWithRoot( + + label + , + ); + + const item = screen.getByTestId('Current'); + + expect(item).toHaveAttribute('data-theme', 'current'); + expect(item).toHaveAttribute('data-type', 'outline'); + }); + + // `current` used to live on the `type` axis. The old spelling keeps + // rendering the flavour it named — the borderless row — so the move is not + // a breaking change. + it('should keep rendering the legacy type="current" spelling', () => { + renderWithRoot( + + label + , + ); + + const item = screen.getByTestId('Legacy'); + + expect(item).toHaveAttribute('data-theme', 'current'); + expect(item).toHaveAttribute('data-type', 'item'); + }); + }); + describe('disabled state', () => { it('should announce the disabled state without the native attribute', () => { renderWithRoot( diff --git a/src/components/content/Item/Item.tsx b/src/components/content/Item/Item.tsx index bee063c05..6172798e9 100644 --- a/src/components/content/Item/Item.tsx +++ b/src/components/content/Item/Item.tsx @@ -21,6 +21,7 @@ import { import { OverlayProps } from 'react-aria'; import { useHotkeys } from 'react-hotkeys-hook'; +import { useDeprecationWarning } from '../../../_internal'; import { useWarn } from '../../../_internal/hooks/use-warn'; import { ITEM_VARIANTS, @@ -143,7 +144,6 @@ export interface CubeItemProps extends BaseProps, ContainerStyleProps { | 'clear' | 'link' | 'card' - | 'current' | (string & {}); theme?: | 'default' @@ -152,6 +152,7 @@ export interface CubeItemProps extends BaseProps, ContainerStyleProps { | 'special' | 'warning' | 'note' + | 'current' | (string & {}); /** Keyboard shortcut that triggers the element when pressed */ hotkeys?: string; @@ -572,6 +573,25 @@ const Item = ( ...rest } = props; + // `current` moved from the `type` axis to the `theme` axis: it selects a color + // source, not a shape, so every shape can now have a `current` flavour. The + // old spelling still renders — mapped to the flavour it used to be, the + // borderless `item` row — and warns. + const isLegacyCurrentType = type === 'current'; + + useDeprecationWarning(!isLegacyCurrentType, { + property: 'type="current"', + name: 'Item', + betterAlternative: 'theme="current"', + reason: + '`current` is a color source rather than a shape, so it now lives on the `theme` axis and composes with every `type`. `type="current"` maps to `theme="current" type="item"`.', + }); + + if (isLegacyCurrentType) { + type = 'item'; + theme = 'current'; + } + // Determine if Label will be rendered const hasLabel = !!(children || labelProps); @@ -590,7 +610,8 @@ const Item = ( const finalIsDisabled = isDisabledProp === true || (isLoading && isDisabledProp !== false); - // Validate type+theme combinations + // Validate type+theme combinations. `current` is a theme like any other here: + // it has a flavour for every type, so it is listed wherever `default` is. const STANDARD_THEMES = [ 'default', 'success', @@ -598,19 +619,22 @@ const Item = ( 'warning', 'note', 'special', + 'current', + ]; + const CARD_THEMES = [ + 'default', + 'success', + 'danger', + 'warning', + 'note', + 'current', ]; - const CARD_THEMES = ['default', 'success', 'danger', 'warning', 'note']; const HEADER_THEMES = ['default']; - // `current` takes every color from the inherited `currentcolor`, so a theme - // would have nothing to change. - const CURRENT_THEMES = ['default']; const isInvalidCombination = (type === 'header' && !HEADER_THEMES.includes(theme)) || - (type === 'current' && !CURRENT_THEMES.includes(theme)) || (type === 'card' && !CARD_THEMES.includes(theme)) || - (!['header', 'current', 'card'].includes(type) && - !STANDARD_THEMES.includes(theme)); + (!['header', 'card'].includes(type) && !STANDARD_THEMES.includes(theme)); useWarn(isInvalidCombination, { key: ['Item', 'invalid-type-theme', type, theme], @@ -618,11 +642,9 @@ const Item = ( `Item: Invalid type+theme combination. type="${type}" does not support theme="${theme}".` + (type === 'header' ? ' The "header" type only supports theme: default.' - : type === 'current' - ? ' The "current" type derives every color from the inherited text color and only supports theme: default.' - : type === 'card' - ? ' The "card" type only supports themes: default, success, danger, warning, note.' - : ' Standard types support themes: default, success, danger, warning, note, special.'), + : type === 'card' + ? ' The "card" type only supports themes: default, success, danger, warning, note, current.' + : ' Standard types support themes: default, success, danger, warning, note, special, current.'), ], }); diff --git a/src/components/content/ItemBadge/ItemBadge.docs.mdx b/src/components/content/ItemBadge/ItemBadge.docs.mdx index d77b1da58..359552b27 100644 --- a/src/components/content/ItemBadge/ItemBadge.docs.mdx +++ b/src/components/content/ItemBadge/ItemBadge.docs.mdx @@ -17,8 +17,8 @@ A non-interactive badge indicator for list items. Similar to `ItemAction` but wi ## Properties - **`icon`** `ReactNode | 'checkmark'` — Icon or checkmark display -- **`type`** `'primary' | 'outline' | 'clear' | 'current'` (default: `'current'`) — Visual type. `isSelected` applies brand-tinted fill to `outline` and `clear`. `current` derives every color from the inherited `currentcolor`, so the badge tracks whatever row hosts it. Passing an explicit `theme` falls back to `'clear'`, since `current` is theme-agnostic. -- **`theme`** `'default' | 'danger' | 'success' | 'special'` (default: `'default'`) — Color theme. Inherits from parent context. +- **`type`** `'primary' | 'outline' | 'clear'` (default: `'clear'`) — Visual type. `isSelected` applies brand-tinted fill to `outline` and `clear`. +- **`theme`** `'current' | 'default' | 'danger' | 'success' | 'special'` (default: `'current'`) — Color theme. `current` derives every color from the inherited `currentcolor`, so the badge tracks whatever row hosts it. Name any other theme to have the badge paint itself instead. - **`isLoading`** `boolean` (default: `false`) — Show loading spinner - **`isSelected`** `boolean` (default: `false`) — Selected state (for checkmark) - **`tooltip`** `string | object` — Tooltip content shown on hover for icon-only badges. A string shows tooltip text; an object supports `{ title, ...tooltipProps }` for advanced configuration. diff --git a/src/components/content/ItemBadge/ItemBadge.stories.tsx b/src/components/content/ItemBadge/ItemBadge.stories.tsx index 05f84ef8f..0ec49830d 100644 --- a/src/components/content/ItemBadge/ItemBadge.stories.tsx +++ b/src/components/content/ItemBadge/ItemBadge.stories.tsx @@ -36,19 +36,30 @@ export const WithLabel: Story = { export const Types: Story = { render: () => ( - } type="primary" tooltip="Primary" /> - } type="outline" tooltip="Outline" /> + } + type="primary" + tooltip="Primary" + /> + } + type="outline" + tooltip="Outline" + /> } type="outline" tooltip="Outline (selected)" /> - } type="clear" tooltip="Clear" /> + } tooltip="Clear" /> } - type="clear" tooltip="Clear (selected)" /> @@ -62,6 +73,17 @@ export const Themes: Story = { } theme="danger" tooltip="Danger" /> } theme="success" tooltip="Success" /> } theme="special" tooltip="Special" /> + + {/* `current` is the default theme: no color of its own, mixed from the + container's inherited text color instead. */} + } tooltip="Current (default)" /> + ), }; diff --git a/src/components/content/ItemBadge/ItemBadge.tsx b/src/components/content/ItemBadge/ItemBadge.tsx index c3434b7e6..25310ef99 100644 --- a/src/components/content/ItemBadge/ItemBadge.tsx +++ b/src/components/content/ItemBadge/ItemBadge.tsx @@ -8,8 +8,11 @@ import { useMemo, } from 'react'; +import { useDeprecationWarning } from '../../../_internal'; import { - CURRENT_ITEM_STYLES, + CURRENT_CLEAR_STYLES, + CURRENT_OUTLINE_STYLES, + CURRENT_PRIMARY_STYLES, DANGER_CLEAR_STYLES, DANGER_OUTLINE_STYLES, DANGER_PRIMARY_STYLES, @@ -35,8 +38,14 @@ export interface CubeItemBadgeProps extends BaseProps { children?: ReactNode; isLoading?: boolean; isSelected?: boolean; - type?: 'primary' | 'outline' | 'clear' | 'current' | (string & {}); - theme?: 'default' | 'danger' | 'success' | 'special' | (string & {}); + type?: 'primary' | 'outline' | 'clear' | (string & {}); + theme?: + | 'current' + | 'default' + | 'danger' + | 'success' + | 'special' + | (string & {}); tooltip?: | string | (Omit, 'children'> & { @@ -45,8 +54,10 @@ export interface CubeItemBadgeProps extends BaseProps { } type ItemBadgeVariant = - // Theme-agnostic inherited-color type — see `CURRENT_ITEM_STYLES`. - | 'default.current' + // Inherited-color theme — see the CURRENT THEME section of `item-themes`. + | 'current.primary' + | 'current.outline' + | 'current.clear' | 'default.primary' | 'default.outline' | 'default.clear' @@ -70,9 +81,12 @@ const ItemBadgeElement = tasty({ }, }), variants: { - // Inherited-color type — theme-agnostic, see `CURRENT_ITEM_STYLES`. Badges - // inside a row use the borderless item flavour, not the chip. - 'default.current': CURRENT_ITEM_STYLES, + // Current theme — colors mixed from the inherited `currentcolor`. The + // default `clear` flavour is borderless, so a badge does not put a resting + // chip on every row. + 'current.primary': CURRENT_PRIMARY_STYLES, + 'current.outline': CURRENT_OUTLINE_STYLES, + 'current.clear': CURRENT_CLEAR_STYLES, // Default theme 'default.primary': DEFAULT_PRIMARY_STYLES, @@ -99,18 +113,18 @@ const ItemBadgeElement = tasty({ export const ItemBadge = forwardRef( function ItemBadge(allProps, ref) { // `contextType` is read for its presence only — it marks "inside a row", - // which drives the `context` mod below. The variant no longer depends on it. + // which drives the `context` mod below. `contextTheme` names the surface the + // badge is painted on, which the `current` ramp reads. Neither one picks the + // variant any more. const { type: contextType, theme: contextTheme } = useItemActionContext(); - const { - // See `ItemAction` for the full rationale: `current` tracks the host - // through `currentcolor`, so the row's `type` no longer has to be mirrored - // from context, and only an explicitly *themed* badge falls back to a - // concrete type. `theme="default"` stays inert. - type = allProps.theme && allProps.theme !== 'default' - ? 'clear' - : 'current', - theme = contextTheme ?? 'default', + let { + // See `ItemAction` for the full rationale: the `current` theme tracks the + // host through `currentcolor`, so neither the row's `type` nor its `theme` + // has to be mirrored from context, and a badge that names a theme is + // asking to paint itself rather than match its host. + type = 'clear', + theme = 'current', icon, children, isLoading = false, @@ -120,6 +134,24 @@ export const ItemBadge = forwardRef( ...rest } = allProps; + // `current` moved from the `type` axis to the `theme` axis. The old spelling + // still renders — mapped to the flavour it used to be, the borderless + // `clear` one — and warns. + const isLegacyCurrentType = type === 'current'; + + useDeprecationWarning(!isLegacyCurrentType, { + property: 'type="current"', + name: 'ItemBadge', + betterAlternative: 'theme="current"', + reason: + '`current` is a color source rather than a shape, so it now lives on the `theme` axis and composes with every `type`. It is already the default theme, so `type="current"` can simply be dropped.', + }); + + if (isLegacyCurrentType) { + type = 'clear'; + theme = 'current'; + } + // Determine if we should show a checkmark const hasCheckmark = icon === 'checkmark'; @@ -207,14 +239,14 @@ export const ItemBadge = forwardRef( return ( = { - // Inherited-color type — theme-agnostic, see `CURRENT_ITEM_STYLES` - 'default.current': CURRENT_ITEM_STYLES, + // Current theme — colors mixed from the inherited `currentcolor` + 'current.item': CURRENT_ITEM_STYLES, + 'current.primary': CURRENT_PRIMARY_STYLES, + 'current.outline': CURRENT_OUTLINE_STYLES, + 'current.outline-2': CURRENT_OUTLINE_2_STYLES, + 'current.clear': CURRENT_CLEAR_STYLES, + 'current.link': CURRENT_LINK_STYLES, + 'current.card': CURRENT_CARD_STYLES, // Default theme 'default.primary': DEFAULT_PRIMARY_STYLES, 'default.outline': DEFAULT_OUTLINE_STYLES, @@ -1251,7 +1373,7 @@ export const ITEM_VARIANTS: Record = { }; // Resolve a `theme` + `type` pair to the variant key that actually exists in -// `ITEM_VARIANTS`. Three of the combinations users can write have no entry of +// `ITEM_VARIANTS`. Two of the combinations users can write have no entry of // their own and are folded onto one that does. // // Shared rather than inlined because more than one component has to arrive at @@ -1271,20 +1393,16 @@ export function resolveItemVariant( const effectiveType = theme === 'special' && type === 'outline-2' ? 'outline' : type; - // `header` reuses the `item` visuals, and both `header` and `current` are - // theme-agnostic — `current` paints from the inherited `currentcolor`. + // `header` reuses the `item` visuals and is theme-agnostic. const variantType = effectiveType === 'header' ? 'item' : effectiveType; - const variantTheme = - effectiveType === 'header' || effectiveType === 'current' - ? 'default' - : theme; + const variantTheme = effectiveType === 'header' ? 'default' : theme; return `${variantTheme}.${variantType}` as ItemVariant; } // Each variant reduced to the label colors an actions wrapper has to reproduce. // -// The `current` type paints from `currentcolor`, which only reaches an action +// The `current` theme paints from `currentcolor`, which only reaches an action // that is a DOM *descendant* of the row. `Item` renders its actions inside the // row element, so they inherit the row color for free — but `ItemButton` renders // them as a sibling of the button (deliberately, so the actions stay reachable @@ -1312,9 +1430,10 @@ export const ITEM_RESTING_COLOR_VARIANTS: Record = } const map = color as Record; - // Only a plain `disabled` key is usable. `default.current` states it as + // Only a plain `disabled` key is usable. The borderless `current` + // flavours (`current.item`, `current.clear`) state it as // `disabled & !inherit-disabled`, which is deliberately not matched here: - // that variant paints from `currentcolor` and has no fixed color to hand + // those variants paint from `currentcolor` and have no fixed color to hand // down, so the wrapper leaves its resting value in place. const disabled = map.disabled; diff --git a/src/eslint-plugin/defaults.generated.ts b/src/eslint-plugin/defaults.generated.ts index 7a277789b..416291e1c 100644 --- a/src/eslint-plugin/defaults.generated.ts +++ b/src/eslint-plugin/defaults.generated.ts @@ -388,12 +388,8 @@ export const DEFAULTS: DefaultsRegistry = { }, isLoading: { kind: 'default', value: false }, isSelected: { kind: 'default', value: false }, - theme: { - kind: 'skip', - reason: 'context', - note: 'Redundant in a bare tree but load-bearing under "inside ", so removing it would change behaviour there.', - }, - type: { kind: 'default', value: 'current' }, + theme: { kind: 'default', value: 'current' }, + type: { kind: 'default', value: 'clear' }, }, }, ItemBadge: { @@ -404,12 +400,8 @@ export const DEFAULTS: DefaultsRegistry = { reason: 'reflected-attribute', note: 'Reflected as `aria-selected={isSelected}`, so omitting the prop removes the attribute while `false` emits `aria-selected="false"`. Not safe to strip.', }, - theme: { - kind: 'skip', - reason: 'context', - note: 'Redundant in a bare tree but load-bearing under "inside ", so removing it would change behaviour there.', - }, - type: { kind: 'default', value: 'current' }, + theme: { kind: 'default', value: 'current' }, + type: { kind: 'default', value: 'clear' }, }, }, ItemButton: { diff --git a/src/eslint-plugin/fixtures.tsx b/src/eslint-plugin/fixtures.tsx index 8faeba276..45cce1ad0 100644 --- a/src/eslint-plugin/fixtures.tsx +++ b/src/eslint-plugin/fixtures.tsx @@ -241,9 +241,9 @@ export const FIXTURES: Fixture[] = [ { name: 'ItemBadge', /** - * Reads `type` and `theme` off `ItemActionContext` the same way `ItemAction` - * does, so it needs the same conditions — without them both props probe as - * plain defaults while actually being inheritance overrides. + * Sits under `ItemActionContext` the same way `ItemAction` does, so it gets + * the same conditions: the host theme reaches the element as `data-surface`, + * which the `current` ramp reads. */ render: (props) => 1, conditions: [ @@ -279,16 +279,17 @@ export const FIXTURES: Fixture[] = [ /** * Every prop `ItemAction` reads off `ItemActionContext` needs a condition * here, because the fallback chain is `prop ?? context ?? literal` - * (`ItemAction.tsx`: `type = contextType ?? 'clear'`, - * `isDisabled = isDisabledProp ?? contextIsDisabled`). Probed bare, the - * literal wins and the prop looks like a plain default; probed under a - * provider that supplies a different value, the prop is what stops the - * inherited one from applying. `` renders its `actions` in - * exactly such a provider, so `` is the - * documented way to keep one action live inside a disabled item. + * (`ItemAction.tsx`: `isDisabled = isDisabledProp ?? contextIsDisabled`). + * Probed bare, the literal wins and the prop looks like a plain default; + * probed under a provider that supplies a different value, the prop is what + * stops the inherited one from applying. `` renders its + * `actions` in exactly such a provider, so `` + * is the documented way to keep one action live inside a disabled item. * - * The provider rewrites item-ish `type`s to `clear`, so `type="primary"` is - * used here — one of the values it passes through untouched. + * `type` and `theme` no longer come from context — the default `current` + * theme tracks the host through `currentcolor` instead — but the provider + * conditions stay: the host theme still reaches the element as + * `data-surface`, which the `current` ramp reads. */ render: (props) => Label, conditions: [ diff --git a/src/eslint-plugin/probe.test.tsx b/src/eslint-plugin/probe.test.tsx index 9e59fd546..3b4744e21 100644 --- a/src/eslint-plugin/probe.test.tsx +++ b/src/eslint-plugin/probe.test.tsx @@ -95,22 +95,24 @@ describe('spike: differential render probe', () => { ); }); - it('ItemAction theme="default" collapses bare but not under a provider (case E)', () => { + it('ItemAction isDisabled={false} collapses bare but not under a provider (case E)', () => { const bare = probe(Hi); - const bareExplicit = probe(Hi); + const bareExplicit = probe( + Hi, + ); expect(bareExplicit.markup + bareExplicit.css).toBe( bare.markup + bare.css, ); const wrapped = probe( - + Hi , ); const wrappedExplicit = probe( - - Hi + + Hi , ); diff --git a/src/stories/Theming.docs.mdx b/src/stories/Theming.docs.mdx index 5fb189763..95cee3c75 100644 --- a/src/stories/Theming.docs.mdx +++ b/src/stories/Theming.docs.mdx @@ -422,7 +422,7 @@ The **contrast level** needs no on/off switch: level `0` and `'auto'` are output-identical, tier included, so the slider at rest _is_ the shipped palette. Status themes are four chips on one line rather than eight sliders. Each chip is a -`type="current"` `Button` colored by its own theme with a +`theme="current"` `Button` colored by its own theme with a [ColorSwatch](/docs/forms-colorswatch--docs) of its accent, so the group answers the question you actually have — do these four still read as four different things — at a glance; press one to open its hue and saturation. The hue is not diff --git a/src/stories/Theming.stories.tsx b/src/stories/Theming.stories.tsx index f62abfbe6..25eac55a2 100644 --- a/src/stories/Theming.stories.tsx +++ b/src/stories/Theming.stories.tsx @@ -1304,7 +1304,7 @@ function useActivePreset(): string | null { * carrying it here cost the whole row: `success — 157°` is wide enough that four * of them had to stack, which is the arrangement the chips were meant to replace. * - * `type="current"` is what makes a chip *be* its theme rather than describe one: + * `theme="current"` is what makes a chip *be* its theme rather than describe one: * every part of the button — the resting fill, the border, the hover step — is * mixed from the inherited text color, so setting `color` to the theme's own text * token colors the whole control from a single value. @@ -1342,7 +1342,7 @@ function StatusThemeButton({ placement="bottom start" > - - , - ); - - const button = screen.getByTestId('Legacy'); - - expect(button).toHaveAttribute('data-theme', 'current'); - expect(button).toHaveAttribute('data-type', 'outline'); - }); }); describe('disabled state', () => { diff --git a/src/components/actions/ItemAction/ItemAction.tsx b/src/components/actions/ItemAction/ItemAction.tsx index a738916f9..c26d656b0 100644 --- a/src/components/actions/ItemAction/ItemAction.tsx +++ b/src/components/actions/ItemAction/ItemAction.tsx @@ -9,7 +9,6 @@ import { useMemo, } from 'react'; -import { useDeprecationWarning } from '../../../_internal'; import { CURRENT_CLEAR_STYLES, CURRENT_OUTLINE_STYLES, @@ -181,7 +180,7 @@ export const ItemAction = forwardRef(function ItemAction( isDisabled: contextIsDisabled, } = useItemActionContext(); - let { + const { // Borderless by default: an action sits inside a row, where a resting chip // on every one of them would read as noise. type = 'clear', @@ -201,24 +200,6 @@ export const ItemAction = forwardRef(function ItemAction( ...rest } = allProps; - // `current` moved from the `type` axis to the `theme` axis. The old spelling - // still renders — mapped to the flavour it used to be, the borderless `clear` - // one — and warns. - const isLegacyCurrentType = type === 'current'; - - useDeprecationWarning(!isLegacyCurrentType, { - property: 'type="current"', - name: 'ItemAction', - betterAlternative: 'theme="current"', - reason: - '`current` is a color source rather than a shape, so it now lives on the `theme` axis and composes with every `type`. It is already the default theme, so `type="current"` can simply be dropped.', - }); - - if (isLegacyCurrentType) { - type = 'clear'; - theme = 'current'; - } - // Inherit disabled state from context, but allow local override const isDisabled = isDisabledProp ?? contextIsDisabled; diff --git a/src/components/content/Item/Item.test.tsx b/src/components/content/Item/Item.test.tsx index 056a32d93..b91878868 100644 --- a/src/components/content/Item/Item.test.tsx +++ b/src/components/content/Item/Item.test.tsx @@ -36,22 +36,6 @@ describe('', () => { expect(item).toHaveAttribute('data-theme', 'current'); expect(item).toHaveAttribute('data-type', 'outline'); }); - - // `current` used to live on the `type` axis. The old spelling keeps - // rendering the flavour it named — the borderless row — so the move is not - // a breaking change. - it('should keep rendering the legacy type="current" spelling', () => { - renderWithRoot( - - label - , - ); - - const item = screen.getByTestId('Legacy'); - - expect(item).toHaveAttribute('data-theme', 'current'); - expect(item).toHaveAttribute('data-type', 'item'); - }); }); describe('disabled state', () => { diff --git a/src/components/content/Item/Item.tsx b/src/components/content/Item/Item.tsx index 6172798e9..ac582d200 100644 --- a/src/components/content/Item/Item.tsx +++ b/src/components/content/Item/Item.tsx @@ -21,7 +21,6 @@ import { import { OverlayProps } from 'react-aria'; import { useHotkeys } from 'react-hotkeys-hook'; -import { useDeprecationWarning } from '../../../_internal'; import { useWarn } from '../../../_internal/hooks/use-warn'; import { ITEM_VARIANTS, @@ -573,25 +572,6 @@ const Item = ( ...rest } = props; - // `current` moved from the `type` axis to the `theme` axis: it selects a color - // source, not a shape, so every shape can now have a `current` flavour. The - // old spelling still renders — mapped to the flavour it used to be, the - // borderless `item` row — and warns. - const isLegacyCurrentType = type === 'current'; - - useDeprecationWarning(!isLegacyCurrentType, { - property: 'type="current"', - name: 'Item', - betterAlternative: 'theme="current"', - reason: - '`current` is a color source rather than a shape, so it now lives on the `theme` axis and composes with every `type`. `type="current"` maps to `theme="current" type="item"`.', - }); - - if (isLegacyCurrentType) { - type = 'item'; - theme = 'current'; - } - // Determine if Label will be rendered const hasLabel = !!(children || labelProps); diff --git a/src/components/content/ItemBadge/ItemBadge.tsx b/src/components/content/ItemBadge/ItemBadge.tsx index 25310ef99..469b59ab3 100644 --- a/src/components/content/ItemBadge/ItemBadge.tsx +++ b/src/components/content/ItemBadge/ItemBadge.tsx @@ -8,7 +8,6 @@ import { useMemo, } from 'react'; -import { useDeprecationWarning } from '../../../_internal'; import { CURRENT_CLEAR_STYLES, CURRENT_OUTLINE_STYLES, @@ -118,7 +117,7 @@ export const ItemBadge = forwardRef( // variant any more. const { type: contextType, theme: contextTheme } = useItemActionContext(); - let { + const { // See `ItemAction` for the full rationale: the `current` theme tracks the // host through `currentcolor`, so neither the row's `type` nor its `theme` // has to be mirrored from context, and a badge that names a theme is @@ -134,24 +133,6 @@ export const ItemBadge = forwardRef( ...rest } = allProps; - // `current` moved from the `type` axis to the `theme` axis. The old spelling - // still renders — mapped to the flavour it used to be, the borderless - // `clear` one — and warns. - const isLegacyCurrentType = type === 'current'; - - useDeprecationWarning(!isLegacyCurrentType, { - property: 'type="current"', - name: 'ItemBadge', - betterAlternative: 'theme="current"', - reason: - '`current` is a color source rather than a shape, so it now lives on the `theme` axis and composes with every `type`. It is already the default theme, so `type="current"` can simply be dropped.', - }); - - if (isLegacyCurrentType) { - type = 'clear'; - theme = 'current'; - } - // Determine if we should show a checkmark const hasCheckmark = icon === 'checkmark'; From c854fbb8d2e01d3076da6ab0acf2abc477f09e73 Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 19 Aug 2026 14:06:04 +0000 Subject: [PATCH 05/26] fix(themes): fade only the label on a disabled selected chip MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 Claude-Session: https://claude.ai/code/session_01BU1Vwm6cGgGRcMubszgbGD --- .changeset/selected-disabled-label-fade.md | 13 +++ .changeset/selected-disabled-soft-chip.md | 11 --- src/data/item-themes.ts | 83 ++++++++++--------- src/stories/Colors.stories.tsx | 8 +- src/tasty-augment.d.ts | 3 +- src/tokens/__snapshots__/palette.test.ts.snap | 21 ++--- src/tokens/palette.ts | 43 ++++------ 7 files changed, 89 insertions(+), 93 deletions(-) create mode 100644 .changeset/selected-disabled-label-fade.md delete mode 100644 .changeset/selected-disabled-soft-chip.md diff --git a/.changeset/selected-disabled-label-fade.md b/.changeset/selected-disabled-label-fade.md new file mode 100644 index 000000000..d224d1087 --- /dev/null +++ b/.changeset/selected-disabled-label-fade.md @@ -0,0 +1,13 @@ +--- +'@cube-dev/ui-kit': patch +--- + +Fix the `selected & disabled` state on the `outline`, `outline-2` and `clear` types across the `default`, `danger`, `success`, `warning` and `note` themes: it was heavier than the enabled state it is supposed to mute. + +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 `-13` chip read as a filled pill, and its `tone: 'max'` label resolved to literal white in light mode. A disabled segmented control therefore drew more attention than a live one, and the selected option looked like the only enabled one. + +It now keeps the enabled selected chip and fades only the label. The chip is the thing that says "this one is on", so it does not change weight at all when the control goes disabled; the label drops to a new `accent-disabled-text` token — the neutral `disabled-surface-text` geometry (the same `-23` tone delta against `surface`, adaptive, so it reads exactly as disabled as every other disabled label in light, dark and high contrast) carrying brand chroma instead of neutral, at roughly 2× `disabled-surface-text` and comfortably under `accent-text-soft`. Selection survives as a hue on a label of unchanged paleness, which is what CUB-3912 asked for: a disabled segmented control still shows which option is active. + +The chip's tint is written as `.08` rather than reusing `selected`'s own `.09`, and the difference is deliberately imperceptible. The two entries must not serialize to the same string: Tasty's `mergeEntriesByValue` pass coalesces equal values into one OR-entry at the group's max priority, so a literal reuse would merge `selected` into `selected & disabled` and then negate against `selected & (hovered | focused)` — the "selected-hover stays dark" bug that `SPECIAL_CLEAR_STYLES` documents and escapes the same way. + +No existing token changed value — the palette addition is `accent-disabled-text` and nothing else. `primary` keeps `accent-disabled-surface`, which is correct for a solid fill; the `special` and `current` themes keep their own white-alpha and `currentcolor` registers. diff --git a/.changeset/selected-disabled-soft-chip.md b/.changeset/selected-disabled-soft-chip.md deleted file mode 100644 index a134c4556..000000000 --- a/.changeset/selected-disabled-soft-chip.md +++ /dev/null @@ -1,11 +0,0 @@ ---- -'@cube-dev/ui-kit': patch ---- - -Fix the `selected & disabled` state on the `outline`, `outline-2` and `clear` types across the `default`, `danger`, `success`, `warning` and `note` themes: it was heavier than the enabled state it is supposed to mute. - -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 `-13` chip read as a filled pill, and its `tone: 'max'` label resolved to literal white in light mode. A disabled segmented control therefore drew more attention than a live one, and the selected option looked like the only enabled one. - -Two new accent tokens replace it, `accent-disabled-surface-soft` and `accent-disabled-surface-soft-text`. They reuse the NEUTRAL disabled geometry exactly — the same `-3.5` chip and `-23` label tone deltas against `surface`, adaptive rather than fixed, so the weight matches in light, dark and high contrast — and differ from `disabled-surface` / `disabled-surface-text` only in chroma: roughly 8× on the chip and 3.6× on the label, both still faint. Selection survives as a brand tint on a chip of unchanged weight, which is the one channel left once weight is spoken for, and it is what CUB-3912 asked for in the first place: a disabled segmented control still shows which option is active. - -No existing token changed value — the palette snapshot diff is purely additive. `primary` keeps `accent-disabled-surface`, which is correct for a solid fill; the `special` and `current` themes keep their own white-alpha and `currentcolor` registers. diff --git a/src/data/item-themes.ts b/src/data/item-themes.ts index 3ff4a1483..b2a707afe 100644 --- a/src/data/item-themes.ts +++ b/src/data/item-themes.ts @@ -128,19 +128,28 @@ export const DEFAULT_OUTLINE_STYLES: Styles = { // body-composited single-layer `#primary-accent-surface.X` would be, but // visually almost identical and free of the overlay-snap flash on click). // Disabled paints the neutral `#disabled-surface` chip on top of the same - // base — or, when selected, the brand-tinted `accent-disabled-surface-soft` - // one, so a disabled segmented control still shows which option is active - // (CUB-3912). Every `selected & disabled` entry in this file exists for that - // reason. + // base. When SELECTED it keeps the enabled selected chip instead and fades + // only the LABEL, to `#accent-disabled-text` — so a disabled segmented + // control still shows which option is active (CUB-3912) without the chip + // changing weight at all. Every `selected & disabled` entry in this file + // exists for that reason. // - // `-soft` is the whole point: it is the neutral disabled chip's own tone - // carrying brand chroma, not a heavier chip. The state used to borrow - // `accent-disabled-surface` — the mid-tone pill a PRIMARY button steps DOWN - // to — which on these types is a step UP, and paired it with a `tone: 'max'` - // label that came out white in light mode. A disabled control then outweighed - // its own enabled selected state. Weight now matches plain `disabled` exactly - // and only the hue says "on". See `accent-disabled-surface-soft` in - // `palette.ts`. + // Fading the label alone is what keeps the state honest. The chip is the + // thing that says "this one is on", and a disabled control has no business + // saying that more loudly than a live one — which is exactly what happened + // while the state borrowed `accent-disabled-surface`: the mid-tone pill a + // PRIMARY button steps DOWN to is a step UP from a 9% tint, and its + // `tone: 'max'` label resolved to literal white in light mode. Building the + // chip from the neutral disabled tone at brand chroma fixed the weight but + // read over-saturated next to the enabled selected chips it sits beside. + // + // The `.08` is `selected`'s own `.09` minus a hair, and the difference is + // deliberately imperceptible: the two entries must not serialize to the SAME + // string. Tasty's `mergeEntriesByValue` pass coalesces equal values into one + // OR-entry at the group's max priority, so a literal reuse of `.09` would + // merge `selected` into `selected & disabled` and then negate against + // `selected & (hovered | focused)` — the "selected-hover stays dark" bug that + // `SPECIAL_CLEAR_STYLES` documents at length, which escapes it the same way. fill: { '': '#surface-2 #surface-text.0', hovered: '#surface-2 #surface-text.03', @@ -149,7 +158,7 @@ export const DEFAULT_OUTLINE_STYLES: Styles = { 'selected & (hovered | focused)': '#surface-2 #primary-accent-surface.12', 'selected & pressed': '#surface-2 #primary-accent-surface.18', disabled: '#surface-2 #disabled-surface', - 'selected & disabled': '#surface-2 #primary-accent-disabled-surface-soft', + 'selected & disabled': '#surface-2 #primary-accent-surface.08', }, color: { '': '#surface-text-soft', @@ -158,7 +167,7 @@ export const DEFAULT_OUTLINE_STYLES: Styles = { selected: '#primary-accent-text-soft', 'selected & hovered': '#primary-accent-text', disabled: '#disabled-surface-text', - 'selected & disabled': '#primary-accent-disabled-surface-soft-text', + 'selected & disabled': '#primary-accent-disabled-text', }, } as const; @@ -174,7 +183,7 @@ export const DEFAULT_OUTLINE_2_STYLES: Styles = { 'selected & (hovered | focused)': '#surface-3 #primary-accent-surface.12', 'selected & pressed': '#surface-3 #primary-accent-surface.15', disabled: '#surface-3 #disabled-surface', - 'selected & disabled': '#surface-3 #primary-accent-disabled-surface-soft', + 'selected & disabled': '#surface-3 #primary-accent-surface.08', }, } as const; @@ -197,7 +206,7 @@ export const DEFAULT_CLEAR_STYLES: Styles = { 'selected & (hovered | focused)': '#primary-accent-surface.12', 'selected & pressed': '#primary-accent-surface.18', disabled: 'transparent', - 'selected & disabled': '#primary-accent-disabled-surface-soft', + 'selected & disabled': '#primary-accent-surface.08', }, // Selected label mirrors LINK: soft at rest, `#primary-accent-text` on // hover. See DEFAULT_OUTLINE_STYLES. @@ -208,7 +217,7 @@ export const DEFAULT_CLEAR_STYLES: Styles = { selected: '#primary-accent-text-soft', 'selected & hovered': '#primary-accent-text', disabled: '#disabled-surface-text', - 'selected & disabled': '#primary-accent-disabled-surface-soft-text', + 'selected & disabled': '#primary-accent-disabled-text', }, } as const; @@ -298,13 +307,13 @@ export const DANGER_OUTLINE_STYLES: Styles = { 'selected & (hovered | focused)': '#surface-2 #danger-accent-surface.12', 'selected & pressed': '#surface-2 #danger-accent-surface.18', disabled: '#surface-2 #disabled-surface', - 'selected & disabled': '#surface-2 #danger-accent-disabled-surface-soft', + 'selected & disabled': '#surface-2 #danger-accent-surface.08', }, color: { '': '#danger-accent-text-soft', selected: '#danger-accent-text', disabled: '#disabled-surface-text', - 'selected & disabled': '#danger-accent-disabled-surface-soft-text', + 'selected & disabled': '#danger-accent-disabled-text', }, } as const; @@ -318,7 +327,7 @@ export const DANGER_OUTLINE_2_STYLES: Styles = { 'selected & (hovered | focused)': '#surface-3 #danger-accent-surface.12', 'selected & pressed': '#surface-3 #danger-accent-surface.18', disabled: '#surface-3 #disabled-surface', - 'selected & disabled': '#surface-3 #danger-accent-disabled-surface-soft', + 'selected & disabled': '#surface-3 #danger-accent-surface.08', }, } as const; @@ -341,13 +350,13 @@ export const DANGER_CLEAR_STYLES: Styles = { 'selected & (hovered | focused)': '#danger-accent-text.12', 'selected & pressed': '#danger-accent-text.18', disabled: 'transparent', - 'selected & disabled': '#danger-accent-disabled-surface-soft', + 'selected & disabled': '#danger-accent-surface.08', }, color: { '': '#danger-accent-text-soft', selected: '#danger-accent-text', disabled: '#disabled-surface-text', - 'selected & disabled': '#danger-accent-disabled-surface-soft-text', + 'selected & disabled': '#danger-accent-disabled-text', }, } as const; @@ -429,13 +438,13 @@ export const SUCCESS_OUTLINE_STYLES: Styles = { 'selected & (hovered | focused)': '#surface-2 #success-accent-surface.12', 'selected & pressed': '#surface-2 #success-accent-surface.18', disabled: '#surface-2 #disabled-surface', - 'selected & disabled': '#surface-2 #success-accent-disabled-surface-soft', + 'selected & disabled': '#surface-2 #success-accent-surface.08', }, color: { '': '#success-accent-text-soft', selected: '#success-accent-text', disabled: '#disabled-surface-text', - 'selected & disabled': '#success-accent-disabled-surface-soft-text', + 'selected & disabled': '#success-accent-disabled-text', }, } as const; @@ -449,7 +458,7 @@ export const SUCCESS_OUTLINE_2_STYLES: Styles = { 'selected & (hovered | focused)': '#surface-3 #success-accent-surface.12', 'selected & pressed': '#surface-3 #success-accent-surface.18', disabled: '#surface-3 #disabled-surface', - 'selected & disabled': '#surface-3 #success-accent-disabled-surface-soft', + 'selected & disabled': '#surface-3 #success-accent-surface.08', }, } as const; @@ -470,13 +479,13 @@ export const SUCCESS_CLEAR_STYLES: Styles = { 'selected & (hovered | focused)': '#success-accent-text.12', 'selected & pressed': '#success-accent-text.18', disabled: 'transparent', - 'selected & disabled': '#success-accent-disabled-surface-soft', + 'selected & disabled': '#success-accent-surface.08', }, color: { '': '#success-accent-text-soft', selected: '#success-accent-text', disabled: '#disabled-surface-text', - 'selected & disabled': '#success-accent-disabled-surface-soft-text', + 'selected & disabled': '#success-accent-disabled-text', }, } as const; @@ -558,13 +567,13 @@ export const WARNING_OUTLINE_STYLES: Styles = { 'selected & (hovered | focused)': '#surface-2 #warning-accent-surface.12', 'selected & pressed': '#surface-2 #warning-accent-surface.18', disabled: '#surface-2 #disabled-surface', - 'selected & disabled': '#surface-2 #warning-accent-disabled-surface-soft', + 'selected & disabled': '#surface-2 #warning-accent-surface.08', }, color: { '': '#warning-accent-text-soft', selected: '#warning-accent-text', disabled: '#disabled-surface-text', - 'selected & disabled': '#warning-accent-disabled-surface-soft-text', + 'selected & disabled': '#warning-accent-disabled-text', }, } as const; @@ -578,7 +587,7 @@ export const WARNING_OUTLINE_2_STYLES: Styles = { 'selected & (hovered | focused)': '#surface-3 #warning-accent-surface.12', 'selected & pressed': '#surface-3 #warning-accent-surface.18', disabled: '#surface-3 #disabled-surface', - 'selected & disabled': '#surface-3 #warning-accent-disabled-surface-soft', + 'selected & disabled': '#surface-3 #warning-accent-surface.08', }, } as const; @@ -599,13 +608,13 @@ export const WARNING_CLEAR_STYLES: Styles = { 'selected & (hovered | focused)': '#warning-accent-text.12', 'selected & pressed': '#warning-accent-text.18', disabled: 'transparent', - 'selected & disabled': '#warning-accent-disabled-surface-soft', + 'selected & disabled': '#warning-accent-surface.08', }, color: { '': '#warning-accent-text-soft', selected: '#warning-accent-text', disabled: '#disabled-surface-text', - 'selected & disabled': '#warning-accent-disabled-surface-soft-text', + 'selected & disabled': '#warning-accent-disabled-text', }, } as const; @@ -687,13 +696,13 @@ export const NOTE_OUTLINE_STYLES: Styles = { 'selected & (hovered | focused)': '#surface-2 #note-accent-surface.12', 'selected & pressed': '#surface-2 #note-accent-surface.18', disabled: '#surface-2 #disabled-surface', - 'selected & disabled': '#surface-2 #note-accent-disabled-surface-soft', + 'selected & disabled': '#surface-2 #note-accent-surface.08', }, color: { '': '#note-accent-text-soft', selected: '#note-accent-text', disabled: '#disabled-surface-text', - 'selected & disabled': '#note-accent-disabled-surface-soft-text', + 'selected & disabled': '#note-accent-disabled-text', }, } as const; @@ -707,7 +716,7 @@ export const NOTE_OUTLINE_2_STYLES: Styles = { 'selected & (hovered | focused)': '#surface-3 #note-accent-surface.12', 'selected & pressed': '#surface-3 #note-accent-surface.18', disabled: '#surface-3 #disabled-surface', - 'selected & disabled': '#surface-3 #note-accent-disabled-surface-soft', + 'selected & disabled': '#surface-3 #note-accent-surface.08', }, } as const; @@ -728,13 +737,13 @@ export const NOTE_CLEAR_STYLES: Styles = { 'selected & (hovered | focused)': '#note-accent-text.12', 'selected & pressed': '#note-accent-text.18', disabled: 'transparent', - 'selected & disabled': '#note-accent-disabled-surface-soft', + 'selected & disabled': '#note-accent-surface.08', }, color: { '': '#note-accent-text-soft', selected: '#note-accent-text', disabled: '#disabled-surface-text', - 'selected & disabled': '#note-accent-disabled-surface-soft-text', + 'selected & disabled': '#note-accent-disabled-text', }, } as const; diff --git a/src/stories/Colors.stories.tsx b/src/stories/Colors.stories.tsx index 7e8d8eace..c3a578780 100644 --- a/src/stories/Colors.stories.tsx +++ b/src/stories/Colors.stories.tsx @@ -375,10 +375,10 @@ const disabledPairs: ColorPair[] = [ note: 'Muted brand fill for solid primary controls', }, { - label: 'Primary disabled (soft)', - fill: '#primary-accent-disabled-surface-soft', - texts: ['#primary-accent-disabled-surface-soft-text'], - note: 'The neutral disabled pair at brand chroma — the selected disabled state of outline, outline-2 and clear', + label: 'Primary disabled (selected)', + fill: '#primary-accent-selected-fill', + texts: ['#primary-accent-disabled-text'], + note: 'The selected chip kept as-is, with only the label faded — the selected disabled state of outline, outline-2 and clear', }, { label: 'Success disabled', diff --git a/src/tasty-augment.d.ts b/src/tasty-augment.d.ts index 719b6a7ec..bccc045d6 100644 --- a/src/tasty-augment.d.ts +++ b/src/tasty-augment.d.ts @@ -32,8 +32,7 @@ declare module '@tenphi/tasty' { 'accent-icon': true; 'accent-disabled-surface': true; 'accent-disabled-surface-text': true; - 'accent-disabled-surface-soft': true; - 'accent-disabled-surface-soft-text': true; + 'accent-disabled-text': true; // Note: `accent-surface-hover` / `accent-selected-fill` (and their // per-theme prefixed variants + `-hover` aliases) are intentionally // omitted from this type list to stay under TS7056. They resolve at diff --git a/src/tokens/__snapshots__/palette.test.ts.snap b/src/tokens/__snapshots__/palette.test.ts.snap index e0793f391..b552ae7f2 100644 --- a/src/tokens/__snapshots__/palette.test.ts.snap +++ b/src/tokens/__snapshots__/palette.test.ts.snap @@ -3,9 +3,8 @@ exports[`palette tokens > resolves the default palette to a stable set of values 1`] = ` { "#accent-disabled-surface": "(base)=oklch(0.8693 0.0318 280.3) | @dark=oklch(0.3575 0.0304 280.3) | @dark & @hc=oklch(0.2896 0.0246 280.3) | @hc=oklch(0.8693 0.0318 280.3)", - "#accent-disabled-surface-soft": "(base)=oklch(0.9633 0.0172 280.3) | @dark=oklch(0.2746 0.0467 280.3) | @dark & @hc=oklch(0.1778 0.0302 280.3) | @hc=oklch(0.9633 0.0172 280.3)", - "#accent-disabled-surface-soft-text": "(base)=oklch(0.7782 0.112 280.3) | @dark=oklch(0.41 0.0697 280.3) | @dark & @hc=oklch(0.3701 0.0629 280.3) | @hc=oklch(0.7782 0.112 280.3)", "#accent-disabled-surface-text": "(base)=oklch(1 0 0) | @dark=oklch(0.4517 0.0307 280.3) | @dark & @hc=oklch(0.3922 0.0267 280.3) | @hc=oklch(1 0 0)", + "#accent-disabled-text": "(base)=oklch(0.7782 0.0672 280.3) | @dark=oklch(0.41 0.0418 280.3) | @dark & @hc=oklch(0.3701 0.0378 280.3) | @hc=oklch(0.7782 0.0672 280.3)", "#accent-icon": "(base)=oklch(0.6544 0.1043 280.3) | @dark=oklch(0.5032 0.0802 280.3) | @dark & @hc=oklch(0.4777 0.0761 280.3) | @hc=oklch(0.6544 0.1043 280.3)", "#accent-selected-fill": "(base)=oklch(0.975 0.0037 284.4) | @dark=oklch(0.3004 0.0314 281.68) | @dark & @hc=oklch(0.208 0.0354 280.3) | @hc=oklch(0.9722 0.002 284.43)", "#accent-surface": "(base)=oklch(0.571 0.0971 280.3) | @dark=oklch(0.5377 0.0914 280.3) | @dark & @hc=oklch(0.4642 0.0789 280.3) | @hc=oklch(0.4642 0.0789 280.3)", @@ -25,9 +24,8 @@ exports[`palette tokens > resolves the default palette to a stable set of values "#code-punctuation": "(base)=oklch(0.5637 0.0671 348) | @dark=oklch(0.6323 0.0683 348) | @dark & @hc=oklch(0.6804 0.0655 348) | @hc=oklch(0.4642 0.0595 348)", "#code-string": "(base)=oklch(0.5637 0.1884 280.3) | @dark=oklch(0.6352 0.1684 280.3) | @dark & @hc=oklch(0.6814 0.1487 280.3) | @hc=oklch(0.4642 0.1842 280.3)", "#danger-accent-disabled-surface": "(base)=oklch(0.8514 0.0364 23.1) | @dark=oklch(0.3707 0.0315 23.1) | @dark & @hc=oklch(0.3236 0.0275 23.1) | @hc=oklch(0.8319 0.0415 23.1)", - "#danger-accent-disabled-surface-soft": "(base)=oklch(0.9438 0.0265 23.1) | @dark=oklch(0.29 0.0493 23.1) | @dark & @hc=oklch(0.2341 0.0398 23.1) | @hc=oklch(0.9227 0.0368 23.1)", - "#danger-accent-disabled-surface-soft-text": "(base)=oklch(0.7617 0.1211 23.1) | @dark=oklch(0.4219 0.0717 23.1) | @dark & @hc=oklch(0.3995 0.0679 23.1) | @hc=oklch(0.7438 0.1265 23.1)", "#danger-accent-disabled-surface-text": "(base)=oklch(1 0 0) | @dark=oklch(0.4775 0.0325 23.1) | @dark & @hc=oklch(0.4494 0.0306 23.1) | @hc=oklch(1 0 0)", + "#danger-accent-disabled-text": "(base)=oklch(0.7617 0.0726 23.1) | @dark=oklch(0.4219 0.043 23.1) | @dark & @hc=oklch(0.3995 0.0407 23.1) | @hc=oklch(0.7438 0.0759 23.1)", "#danger-accent-icon": "(base)=oklch(0.6396 0.1019 23.1) | @dark=oklch(0.5151 0.0821 23.1) | @dark & @hc=oklch(0.5061 0.0807 23.1) | @hc=oklch(0.6234 0.0994 23.1)", "#danger-accent-selected-fill": "(base)=oklch(0.9558 0.0051 20.59) | @dark=oklch(0.3128 0.031 21.88) | @dark & @hc=oklch(0.2461 0.029 22.1) | @hc=oklch(0.932 0.0057 21.84)", "#danger-accent-surface": "(base)=oklch(0.571 0.0971 23.1) | @dark=oklch(0.5411 0.092 23.1) | @dark & @hc=oklch(0.4642 0.0789 23.1) | @hc=oklch(0.4642 0.0789 23.1)", @@ -51,9 +49,8 @@ exports[`palette tokens > resolves the default palette to a stable set of values "#loading-face-2": "(base)=oklch(0.9079 0.0274 280.3) | @dark=oklch(0.3171 0.0334 280.3) | @dark & @hc=oklch(0.2507 0.0264 280.3) | @hc=oklch(0.9079 0.0274 280.3)", "#loading-face-3": "(base)=oklch(0.7958 0.0676 280.3) | @dark=oklch(0.3974 0.0446 280.3) | @dark & @hc=oklch(0.3551 0.0398 280.3) | @hc=oklch(0.7958 0.0676 280.3)", "#note-accent-disabled-surface": "(base)=oklch(0.8514 0.0364 302.3) | @dark=oklch(0.3707 0.0315 302.3) | @dark & @hc=oklch(0.3236 0.0275 302.3) | @hc=oklch(0.8319 0.0415 302.3)", - "#note-accent-disabled-surface-soft": "(base)=oklch(0.9438 0.0265 302.3) | @dark=oklch(0.29 0.0493 302.3) | @dark & @hc=oklch(0.2341 0.0398 302.3) | @hc=oklch(0.9227 0.0368 302.3)", - "#note-accent-disabled-surface-soft-text": "(base)=oklch(0.7617 0.1211 302.3) | @dark=oklch(0.4219 0.0717 302.3) | @dark & @hc=oklch(0.3995 0.0679 302.3) | @hc=oklch(0.7438 0.1265 302.3)", "#note-accent-disabled-surface-text": "(base)=oklch(1 0 0) | @dark=oklch(0.4775 0.0325 302.3) | @dark & @hc=oklch(0.4494 0.0306 302.3) | @hc=oklch(1 0 0)", + "#note-accent-disabled-text": "(base)=oklch(0.7617 0.0726 302.3) | @dark=oklch(0.4219 0.043 302.3) | @dark & @hc=oklch(0.3995 0.0407 302.3) | @hc=oklch(0.7438 0.0759 302.3)", "#note-accent-icon": "(base)=oklch(0.6396 0.1019 302.3) | @dark=oklch(0.5151 0.0821 302.3) | @dark & @hc=oklch(0.5061 0.0807 302.3) | @hc=oklch(0.6234 0.0994 302.3)", "#note-accent-selected-fill": "(base)=oklch(0.9557 0.0054 301.99) | @dark=oklch(0.3127 0.0318 302.17) | @dark & @hc=oklch(0.246 0.0296 302.2) | @hc=oklch(0.932 0.0058 302.14)", "#note-accent-surface": "(base)=oklch(0.571 0.0971 302.3) | @dark=oklch(0.5411 0.092 302.3) | @dark & @hc=oklch(0.4642 0.0789 302.3) | @hc=oklch(0.4642 0.0789 302.3)", @@ -72,9 +69,8 @@ exports[`palette tokens > resolves the default palette to a stable set of values "#overlay": "(base)=oklch(0.3023 0.0514 280.3 / 0.5) | @dark=oklch(0.8706 0.063 280.3 / 0.5) | @dark & @hc=oklch(0.8981 0.049 280.3 / 0.5) | @hc=oklch(0.2611 0.0444 280.3 / 0.5)", "#placeholder": "(base)=oklch(0.6941 0.0207 280.3) | @dark=oklch(0.4722 0.014 280.3) | @dark & @hc=oklch(0.4423 0.0132 280.3) | @hc=oklch(0.6941 0.0207 280.3)", "#primary-accent-disabled-surface": "(base)=oklch(0.8514 0.0364 280.3) | @dark=oklch(0.3707 0.0315 280.3) | @dark & @hc=oklch(0.3236 0.0275 280.3) | @hc=oklch(0.8319 0.0415 280.3)", - "#primary-accent-disabled-surface-soft": "(base)=oklch(0.9438 0.0265 280.3) | @dark=oklch(0.29 0.0493 280.3) | @dark & @hc=oklch(0.2341 0.0398 280.3) | @hc=oklch(0.9227 0.0368 280.3)", - "#primary-accent-disabled-surface-soft-text": "(base)=oklch(0.7617 0.1211 280.3) | @dark=oklch(0.4219 0.0717 280.3) | @dark & @hc=oklch(0.3995 0.0679 280.3) | @hc=oklch(0.7438 0.1265 280.3)", "#primary-accent-disabled-surface-text": "(base)=oklch(1 0 0) | @dark=oklch(0.4775 0.0325 280.3) | @dark & @hc=oklch(0.4494 0.0306 280.3) | @hc=oklch(1 0 0)", + "#primary-accent-disabled-text": "(base)=oklch(0.7617 0.0726 280.3) | @dark=oklch(0.4219 0.043 280.3) | @dark & @hc=oklch(0.3995 0.0407 280.3) | @hc=oklch(0.7438 0.0759 280.3)", "#primary-accent-icon": "(base)=oklch(0.6396 0.1019 280.3) | @dark=oklch(0.5151 0.0821 280.3) | @dark & @hc=oklch(0.5061 0.0807 280.3) | @hc=oklch(0.6234 0.0994 280.3)", "#primary-accent-selected-fill": "(base)=oklch(0.9557 0.0057 283.04) | @dark=oklch(0.3118 0.0319 281.47) | @dark & @hc=oklch(0.246 0.0299 281.22) | @hc=oklch(0.932 0.006 281.74)", "#primary-accent-surface": "(base)=oklch(0.571 0.0971 280.3) | @dark=oklch(0.5377 0.0914 280.3) | @dark & @hc=oklch(0.4642 0.0789 280.3) | @hc=oklch(0.4642 0.0789 280.3)", @@ -91,9 +87,8 @@ exports[`palette tokens > resolves the default palette to a stable set of values "#primary-surface-text-soft": "(base)=oklch(0.3783 0.0161 280.3) | @dark=oklch(0.7591 0.0306 280.3) | @dark & @hc=oklch(0.787 0.0268 280.3) | @hc=oklch(0.3626 0.0154 280.3)", "#primary-surface-text-soft-2": "(base)=oklch(0.521 0.0221 280.3) | @dark=oklch(0.6381 0.0271 280.3) | @dark & @hc=oklch(0.7054 0.03 280.3) | @hc=oklch(0.4358 0.0185 280.3)", "#purple-accent-disabled-surface": "(base)=oklch(0.8514 0.0364 280.3) | @dark=oklch(0.3707 0.0315 280.3) | @dark & @hc=oklch(0.3236 0.0275 280.3) | @hc=oklch(0.8319 0.0415 280.3)", - "#purple-accent-disabled-surface-soft": "(base)=oklch(0.9438 0.0265 280.3) | @dark=oklch(0.29 0.0493 280.3) | @dark & @hc=oklch(0.2341 0.0398 280.3) | @hc=oklch(0.9227 0.0368 280.3)", - "#purple-accent-disabled-surface-soft-text": "(base)=oklch(0.7617 0.1211 280.3) | @dark=oklch(0.4219 0.0717 280.3) | @dark & @hc=oklch(0.3995 0.0679 280.3) | @hc=oklch(0.7438 0.1265 280.3)", "#purple-accent-disabled-surface-text": "(base)=oklch(1 0 0) | @dark=oklch(0.4775 0.0325 280.3) | @dark & @hc=oklch(0.4494 0.0306 280.3) | @hc=oklch(1 0 0)", + "#purple-accent-disabled-text": "(base)=oklch(0.7617 0.0726 280.3) | @dark=oklch(0.4219 0.043 280.3) | @dark & @hc=oklch(0.3995 0.0407 280.3) | @hc=oklch(0.7438 0.0759 280.3)", "#purple-accent-icon": "(base)=oklch(0.6396 0.1019 280.3) | @dark=oklch(0.5151 0.0821 280.3) | @dark & @hc=oklch(0.5061 0.0807 280.3) | @hc=oklch(0.6234 0.0994 280.3)", "#purple-accent-selected-fill": "(base)=oklch(0.9557 0.0057 283.04) | @dark=oklch(0.3118 0.0319 281.47) | @dark & @hc=oklch(0.246 0.0299 281.22) | @hc=oklch(0.932 0.006 281.74)", "#purple-accent-surface": "(base)=oklch(0.571 0.0971 280.3) | @dark=oklch(0.5377 0.0914 280.3) | @dark & @hc=oklch(0.4642 0.0789 280.3) | @hc=oklch(0.4642 0.0789 280.3)", @@ -123,9 +118,8 @@ exports[`palette tokens > resolves the default palette to a stable set of values "#special-accent-text": "(base)=oklch(0.5061 0.086 280.3) | @dark=oklch(0.477 0.0811 280.3) | @dark & @hc=oklch(0.4642 0.0789 280.3) | @hc=oklch(0.4642 0.0789 280.3)", "#special-surface": "(base)=oklch(0.3183 0.0257 280.3) | @dark=oklch(0.3382 0.0273 280.3) | @dark & @hc=oklch(0.2804 0.0226 280.3) | @hc=oklch(0.2804 0.0226 280.3)", "#success-accent-disabled-surface": "(base)=oklch(0.8514 0.0364 156.9) | @dark=oklch(0.3707 0.0315 156.9) | @dark & @hc=oklch(0.3236 0.0275 156.9) | @hc=oklch(0.8319 0.0415 156.9)", - "#success-accent-disabled-surface-soft": "(base)=oklch(0.9438 0.0265 156.9) | @dark=oklch(0.29 0.0493 156.9) | @dark & @hc=oklch(0.2341 0.0398 156.9) | @hc=oklch(0.9227 0.0368 156.9)", - "#success-accent-disabled-surface-soft-text": "(base)=oklch(0.7617 0.1211 156.9) | @dark=oklch(0.4219 0.0717 156.9) | @dark & @hc=oklch(0.3995 0.0679 156.9) | @hc=oklch(0.7438 0.1265 156.9)", "#success-accent-disabled-surface-text": "(base)=oklch(1 0 0) | @dark=oklch(0.4775 0.0325 156.9) | @dark & @hc=oklch(0.4494 0.0306 156.9) | @hc=oklch(1 0 0)", + "#success-accent-disabled-text": "(base)=oklch(0.7617 0.0726 156.9) | @dark=oklch(0.4219 0.043 156.9) | @dark & @hc=oklch(0.3995 0.0407 156.9) | @hc=oklch(0.7438 0.0759 156.9)", "#success-accent-icon": "(base)=oklch(0.6396 0.1019 156.9) | @dark=oklch(0.5151 0.0821 156.9) | @dark & @hc=oklch(0.5061 0.0807 156.9) | @hc=oklch(0.6234 0.0994 156.9)", "#success-accent-selected-fill": "(base)=oklch(0.9552 0.0045 157.13) | @dark=oklch(0.309 0.0289 157.02) | @dark & @hc=oklch(0.2424 0.0273 157) | @hc=oklch(0.9317 0.0054 157.01)", "#success-accent-surface": "(base)=oklch(0.5545 0.0943 156.9) | @dark=oklch(0.527 0.0896 156.9) | @dark & @hc=oklch(0.4528 0.077 156.9) | @hc=oklch(0.4528 0.077 156.9)", @@ -154,9 +148,8 @@ exports[`palette tokens > resolves the default palette to a stable set of values "#surface-text-soft": "(base)=oklch(0.3922 0.0133 280.3) | @dark=oklch(0.7449 0.0253 280.3) | @dark & @hc=oklch(0.7523 0.0253 280.3) | @hc=oklch(0.3922 0.0133 280.3)", "#surface-text-soft-2": "(base)=oklch(0.5347 0.0182 280.3) | @dark=oklch(0.6246 0.0212 280.3) | @dark & @hc=oklch(0.6729 0.0229 280.3) | @hc=oklch(0.4642 0.0158 280.3)", "#warning-accent-disabled-surface": "(base)=oklch(0.8514 0.0364 84.3) | @dark=oklch(0.3707 0.0315 84.3) | @dark & @hc=oklch(0.3236 0.0275 84.3) | @hc=oklch(0.8319 0.0415 84.3)", - "#warning-accent-disabled-surface-soft": "(base)=oklch(0.9438 0.0265 84.3) | @dark=oklch(0.29 0.0493 84.3) | @dark & @hc=oklch(0.2341 0.0398 84.3) | @hc=oklch(0.9227 0.0368 84.3)", - "#warning-accent-disabled-surface-soft-text": "(base)=oklch(0.7617 0.1211 84.3) | @dark=oklch(0.4219 0.0717 84.3) | @dark & @hc=oklch(0.3995 0.0679 84.3) | @hc=oklch(0.7438 0.1265 84.3)", "#warning-accent-disabled-surface-text": "(base)=oklch(1 0 0) | @dark=oklch(0.4775 0.0325 84.3) | @dark & @hc=oklch(0.4494 0.0306 84.3) | @hc=oklch(1 0 0)", + "#warning-accent-disabled-text": "(base)=oklch(0.7617 0.0726 84.3) | @dark=oklch(0.4219 0.043 84.3) | @dark & @hc=oklch(0.3995 0.0407 84.3) | @hc=oklch(0.7438 0.0759 84.3)", "#warning-accent-icon": "(base)=oklch(0.6396 0.1019 84.3) | @dark=oklch(0.5151 0.0821 84.3) | @dark & @hc=oklch(0.5061 0.0807 84.3) | @hc=oklch(0.6234 0.0994 84.3)", "#warning-accent-selected-fill": "(base)=oklch(0.9557 0.0043 78) | @dark=oklch(0.312 0.0282 81.08) | @dark & @hc=oklch(0.246 0.027 81.63) | @hc=oklch(0.932 0.0052 81.38)", "#warning-accent-surface": "(base)=oklch(0.5681 0.0966 84.3) | @dark=oklch(0.5382 0.0915 84.3) | @dark & @hc=oklch(0.4642 0.0789 84.3) | @hc=oklch(0.4642 0.0789 84.3)", diff --git a/src/tokens/palette.ts b/src/tokens/palette.ts index a5a1e5c5e..8e7dbbd67 100644 --- a/src/tokens/palette.ts +++ b/src/tokens/palette.ts @@ -1210,35 +1210,28 @@ function buildPalette( mode: 'fixed', }, - // Brand-tinted counterpart of the neutral `disabled-surface` / - // `disabled-surface-text` pair, for the SELECTED disabled state of the - // non-solid types (`outline`, `outline-2`, `clear`). + // The disabled counterpart of `accent-text-soft`, for the SELECTED disabled + // state of the non-solid types (`outline`, `outline-2`, `clear`). // - // The pair above is 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 an outline or clear control it is a step up: - // the enabled selected state is a 9% tint under soft accent text, and the - // `-13` chip under a `tone: 'max'` label came out heavier and brighter than - // the state it is supposed to mute. A disabled control must never outweigh - // its enabled self. + // Those types express `selected & disabled` by keeping the enabled selected + // CHIP and fading only the LABEL, so this is the one token the state needs. + // It is the neutral `disabled-surface-text` geometry — the same `-23` tone + // delta against `surface`, adaptive, so it reads exactly as disabled as + // every other disabled label in light, dark and HC — carrying brand chroma + // instead of neutral: roughly 2x `disabled-surface-text` and comfortably + // under `accent-text-soft`, which is the live label it must not be mistaken + // for. Selection therefore survives as a hue on a label of unchanged + // paleness. // - // These therefore reuse the NEUTRAL disabled geometry exactly — the same - // `-3.5` chip and `-23` label deltas against `surface`, adaptive rather - // than fixed, so the weight matches in light, dark and HC — and differ from - // it only in chroma. Selection survives as a brand tint on a chip of - // unchanged weight, which is the one channel left once weight is spoken - // for. Saturation is set high because these tones sit near the ends of the - // lightness range, where the gamut affords little chroma: `1` on the chip - // buys about 3× the neutral's chroma in light and is still a faint tint. - 'accent-disabled-surface-soft': { - base: 'surface', - tone: '-3.5', - saturation: 1, - }, - 'accent-disabled-surface-soft-text': { + // The pair above (`accent-disabled-surface` / `-text`) stays what it always + // was: the muted chip for a PRIMARY button, whose enabled state is an opaque + // brand fill under `#white`. Borrowing it here inverted the state — its + // mid-tone chip is a step DOWN from a solid fill but a step UP from a 9% + // tint, and its `tone: 'max'` label resolves to literal white in light mode. + 'accent-disabled-text': { base: 'surface', tone: '-23', - saturation: 1, + saturation: 0.6, }, // ---- Loading-animation cube faces ---- From c6a3435300ddfda392f7f9236c012c98cf78564a Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 19 Aug 2026 14:24:40 +0000 Subject: [PATCH 06/26] fix(themes): keep the status themes' own clear chip when disabled MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 Claude-Session: https://claude.ai/code/session_01BU1Vwm6cGgGRcMubszgbGD --- .changeset/selected-disabled-label-fade.md | 4 +- src/data/item-themes.test.ts | 51 ++++++++++++++++++++++ src/data/item-themes.ts | 8 ++-- 3 files changed, 58 insertions(+), 5 deletions(-) create mode 100644 src/data/item-themes.test.ts diff --git a/.changeset/selected-disabled-label-fade.md b/.changeset/selected-disabled-label-fade.md index d224d1087..ea7a7afa3 100644 --- a/.changeset/selected-disabled-label-fade.md +++ b/.changeset/selected-disabled-label-fade.md @@ -8,6 +8,8 @@ The state borrowed `accent-disabled-surface` / `accent-disabled-surface-text` It now keeps the enabled selected chip and fades only the label. The chip is the thing that says "this one is on", so it does not change weight at all when the control goes disabled; the label drops to a new `accent-disabled-text` token — the neutral `disabled-surface-text` geometry (the same `-23` tone delta against `surface`, adaptive, so it reads exactly as disabled as every other disabled label in light, dark and high contrast) carrying brand chroma instead of neutral, at roughly 2× `disabled-surface-text` and comfortably under `accent-text-soft`. Selection survives as a hue on a label of unchanged paleness, which is what CUB-3912 asked for: a disabled segmented control still shows which option is active. -The chip's tint is written as `.08` rather than reusing `selected`'s own `.09`, and the difference is deliberately imperceptible. The two entries must not serialize to the same string: Tasty's `mergeEntriesByValue` pass coalesces equal values into one OR-entry at the group's max priority, so a literal reuse would merge `selected` into `selected & disabled` and then negate against `selected & (hovered | focused)` — the "selected-hover stays dark" bug that `SPECIAL_CLEAR_STYLES` documents and escapes the same way. +The chip's tint tracks whatever token that theme's `selected` state uses — `accent-surface` for the outline types and for `default.clear`, `accent-text` for the four status themes' `clear`, which do not share `default`'s token. It is written as `.08` rather than reusing `selected`'s own `.09`, and the difference is deliberately imperceptible. The two entries must not serialize to the same string: Tasty's `mergeEntriesByValue` pass coalesces equal values into one OR-entry at the group's max priority, so a literal reuse would merge `selected` into `selected & disabled` and then negate against `selected & (hovered | focused)` — the "selected-hover stays dark" bug that `SPECIAL_CLEAR_STYLES` documents and escapes the same way. No existing token changed value — the palette addition is `accent-disabled-text` and nothing else. `primary` keeps `accent-disabled-surface`, which is correct for a solid fill; the `special` and `current` themes keep their own white-alpha and `currentcolor` registers. + +A unit test pins both halves of the invariant across all fifteen brand-theme x non-solid-type variants: the disabled selected fill must equal the enabled one modulo that alpha, and the label must be the `accent-disabled-text` of its theme. diff --git a/src/data/item-themes.test.ts b/src/data/item-themes.test.ts new file mode 100644 index 000000000..c4303cf64 --- /dev/null +++ b/src/data/item-themes.test.ts @@ -0,0 +1,51 @@ +import { ITEM_VARIANTS } from './item-themes'; + +/** + * `selected & disabled` on the non-solid types keeps the ENABLED selected chip + * and fades only the label — the chip is what says "this one is on", and a + * disabled control has no business saying that differently from a live one. + * + * That invariant is easy to break by hand: the fill token is not the same + * across themes (`default.clear` tints from `accent-surface`, the four status + * themes from `accent-text`), so a blanket edit silently shifts the hue on four + * of the fifteen variants. This asserts the two entries differ only in their + * alpha, which is itself deliberate: they must not serialize to the same string + * or Tasty's `mergeEntriesByValue` pass coalesces them and negates + * `selected & (hovered | focused)`. See `DEFAULT_OUTLINE_STYLES.fill`. + */ +describe('ITEM_VARIANTS', () => { + const NON_SOLID = ['outline', 'outline-2', 'clear']; + const BRAND_THEMES = ['default', 'danger', 'success', 'warning', 'note']; + + const cases = BRAND_THEMES.flatMap((theme) => + NON_SOLID.map((type) => [`${theme}.${type}`] as const), + ).filter(([variant]) => variant in ITEM_VARIANTS); + + it('covers every brand theme x non-solid type', () => { + expect(cases).toHaveLength(BRAND_THEMES.length * NON_SOLID.length); + }); + + it.each(cases)( + '%s keeps the selected chip when disabled, changing only the alpha', + (variant) => { + const fill = ITEM_VARIANTS[variant].fill as Record; + + expect(fill.selected).toBeDefined(); + expect(fill['selected & disabled']).toBeDefined(); + + // Same layers and same token, one alpha step apart. + expect(fill['selected & disabled']).toBe( + fill.selected.replace(/\.09$/, '.08'), + ); + + // Distinct strings, or `mergeEntriesByValue` coalesces them. + expect(fill['selected & disabled']).not.toBe(fill.selected); + }, + ); + + it.each(cases)('%s fades only the label when disabled', (variant) => { + const color = ITEM_VARIANTS[variant].color as Record; + + expect(color['selected & disabled']).toMatch(/-accent-disabled-text$/); + }); +}); diff --git a/src/data/item-themes.ts b/src/data/item-themes.ts index b2a707afe..b5f3ca1e1 100644 --- a/src/data/item-themes.ts +++ b/src/data/item-themes.ts @@ -350,7 +350,7 @@ export const DANGER_CLEAR_STYLES: Styles = { 'selected & (hovered | focused)': '#danger-accent-text.12', 'selected & pressed': '#danger-accent-text.18', disabled: 'transparent', - 'selected & disabled': '#danger-accent-surface.08', + 'selected & disabled': '#danger-accent-text.08', }, color: { '': '#danger-accent-text-soft', @@ -479,7 +479,7 @@ export const SUCCESS_CLEAR_STYLES: Styles = { 'selected & (hovered | focused)': '#success-accent-text.12', 'selected & pressed': '#success-accent-text.18', disabled: 'transparent', - 'selected & disabled': '#success-accent-surface.08', + 'selected & disabled': '#success-accent-text.08', }, color: { '': '#success-accent-text-soft', @@ -608,7 +608,7 @@ export const WARNING_CLEAR_STYLES: Styles = { 'selected & (hovered | focused)': '#warning-accent-text.12', 'selected & pressed': '#warning-accent-text.18', disabled: 'transparent', - 'selected & disabled': '#warning-accent-surface.08', + 'selected & disabled': '#warning-accent-text.08', }, color: { '': '#warning-accent-text-soft', @@ -737,7 +737,7 @@ export const NOTE_CLEAR_STYLES: Styles = { 'selected & (hovered | focused)': '#note-accent-text.12', 'selected & pressed': '#note-accent-text.18', disabled: 'transparent', - 'selected & disabled': '#note-accent-surface.08', + 'selected & disabled': '#note-accent-text.08', }, color: { '': '#note-accent-text-soft', From bb27112c871c0fa930f32500bf37be68c927df5c Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 19 Aug 2026 14:49:53 +0000 Subject: [PATCH 07/26] fix(themes): mute the special theme's disabled labels MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 Claude-Session: https://claude.ai/code/session_01BU1Vwm6cGgGRcMubszgbGD --- .changeset/selected-disabled-label-fade.md | 2 + src/data/item-themes.test.ts | 96 ++++++++++++++++------ src/data/item-themes.ts | 45 +++++++--- 3 files changed, 108 insertions(+), 35 deletions(-) diff --git a/.changeset/selected-disabled-label-fade.md b/.changeset/selected-disabled-label-fade.md index ea7a7afa3..ca394974a 100644 --- a/.changeset/selected-disabled-label-fade.md +++ b/.changeset/selected-disabled-label-fade.md @@ -13,3 +13,5 @@ The chip's tint tracks whatever token that theme's `selected` state uses — `ac No existing token changed value — the palette addition is `accent-disabled-text` and nothing else. `primary` keeps `accent-disabled-surface`, which is correct for a solid fill; the `special` and `current` themes keep their own white-alpha and `currentcolor` registers. A unit test pins both halves of the invariant across all fifteen brand-theme x non-solid-type variants: the disabled selected fill must equal the enabled one modulo that alpha, and the label must be the `accent-disabled-text` of its theme. + +The `special` theme gets the same treatment, plus a correction its non-selected disabled state needed on its own. Every disabled label there 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 white-alpha labels are now solved for cr ~2.0 against whatever each one sits on: `#white.23` for the plain disabled states across `outline`, `clear`, `item` and `link`, `#white.28` for `outline` selected + disabled on its `.17` chip. `outline` selected + disabled keeps the enabled selected chip like the colored themes (`.17` against its `.18`), and `clear` — whose selected state inverts to a solid white pill — keeps that pill and fades its DARK label instead, to `#special-accent-text.45`, cr 1.95 against the pill. diff --git a/src/data/item-themes.test.ts b/src/data/item-themes.test.ts index c4303cf64..777d13b41 100644 --- a/src/data/item-themes.test.ts +++ b/src/data/item-themes.test.ts @@ -5,47 +5,95 @@ import { ITEM_VARIANTS } from './item-themes'; * and fades only the label — the chip is what says "this one is on", and a * disabled control has no business saying that differently from a live one. * - * That invariant is easy to break by hand: the fill token is not the same - * across themes (`default.clear` tints from `accent-surface`, the four status - * themes from `accent-text`), so a blanket edit silently shifts the hue on four - * of the fifteen variants. This asserts the two entries differ only in their - * alpha, which is itself deliberate: they must not serialize to the same string - * or Tasty's `mergeEntriesByValue` pass coalesces them and negates - * `selected & (hovered | focused)`. See `DEFAULT_OUTLINE_STYLES.fill`. + * That invariant is easy to break by hand, because the two halves of it are + * written per theme and the tokens are not uniform: `default.clear` tints from + * `accent-surface` while the four status themes tint from `accent-text`, and + * `special` works in white alpha over a fixed dark base. A blanket edit against + * any one of those shapes silently shifts the others. + * + * The chips are asserted to differ only in their final alpha, and to differ at + * all: two entries in one state-map must not serialize to the same string, or + * Tasty's `mergeEntriesByValue` pass coalesces them at the group's max priority + * and negates `selected & (hovered | focused)`. See `SPECIAL_CLEAR_STYLES`. */ describe('ITEM_VARIANTS', () => { - const NON_SOLID = ['outline', 'outline-2', 'clear']; - const BRAND_THEMES = ['default', 'danger', 'success', 'warning', 'note']; + const VARIANTS = [ + 'default.outline', + 'default.outline-2', + 'default.clear', + 'danger.outline', + 'danger.outline-2', + 'danger.clear', + 'success.outline', + 'success.outline-2', + 'success.clear', + 'warning.outline', + 'warning.outline-2', + 'warning.clear', + 'note.outline', + 'note.outline-2', + 'note.clear', + 'special.outline', + 'special.clear', + ] as const; + + /** + * The `current` theme carries the state but cannot follow the rule literally. + * Its alphas resolve against the element's OWN `currentcolor`, which the + * disabled label has already faded to `.4`, so its disabled entries are + * written PRE-MULTIPLIED: an authored `.18` renders as ~`.07`, below the + * `.12` it is compared against. The chip is muted in effect; only the + * authored number goes up. See `CURRENT_OUTLINE_STYLES`. + */ + const PREMULTIPLIED = ['current.outline', 'current.outline-2'] as const; + + /** `'#surface-2 #primary-accent-surface.09'` -> `['#surface-2 #primary-accent-surface', 0.09]` */ + function splitAlpha(value: string): [string, number] { + const match = value.match(/^(.*?)(\.\d+)$/); + + if (!match) return [value, 1]; - const cases = BRAND_THEMES.flatMap((theme) => - NON_SOLID.map((type) => [`${theme}.${type}`] as const), - ).filter(([variant]) => variant in ITEM_VARIANTS); + return [match[1], Number(match[2])]; + } - it('covers every brand theme x non-solid type', () => { - expect(cases).toHaveLength(BRAND_THEMES.length * NON_SOLID.length); + it('lists every variant that carries the state', () => { + const carriers = Object.entries(ITEM_VARIANTS) + .filter(([, styles]) => { + const fill = styles.fill; + + return ( + !!fill && + typeof fill === 'object' && + 'selected & disabled' in (fill as Record) + ); + }) + .map(([variant]) => variant); + + expect(carriers.sort()).toEqual([...VARIANTS, ...PREMULTIPLIED].sort()); }); - it.each(cases)( + it.each(VARIANTS)( '%s keeps the selected chip when disabled, changing only the alpha', (variant) => { const fill = ITEM_VARIANTS[variant].fill as Record; - - expect(fill.selected).toBeDefined(); - expect(fill['selected & disabled']).toBeDefined(); - - // Same layers and same token, one alpha step apart. - expect(fill['selected & disabled']).toBe( - fill.selected.replace(/\.09$/, '.08'), + const [enabledToken, enabledAlpha] = splitAlpha(fill.selected); + const [disabledToken, disabledAlpha] = splitAlpha( + fill['selected & disabled'], ); + // Same layers, same token — only the alpha may move. + expect(disabledToken).toBe(enabledToken); + // Down, never up: a dead control must not out-read a live one. + expect(disabledAlpha).toBeLessThan(enabledAlpha); // Distinct strings, or `mergeEntriesByValue` coalesces them. expect(fill['selected & disabled']).not.toBe(fill.selected); }, ); - it.each(cases)('%s fades only the label when disabled', (variant) => { + it.each(VARIANTS)('%s fades the label when disabled', (variant) => { const color = ITEM_VARIANTS[variant].color as Record; - expect(color['selected & disabled']).toMatch(/-accent-disabled-text$/); + expect(color['selected & disabled']).toBeDefined(); + expect(color['selected & disabled']).not.toBe(color.selected); }); }); diff --git a/src/data/item-themes.ts b/src/data/item-themes.ts index b5f3ca1e1..35010d9ba 100644 --- a/src/data/item-themes.ts +++ b/src/data/item-themes.ts @@ -858,16 +858,29 @@ export const SPECIAL_OUTLINE_STYLES: Styles = { 'selected & pressed': '#special-surface #white.3', disabled: '#special-surface #white.04', // Stays in the white-alpha register: the base here is a fixed dark tone. - 'selected & disabled': '#special-surface #white.09', + // Like the colored themes, a disabled SELECTED control keeps the chip it + // has when enabled and fades only the label — `.17` rather than a literal + // reuse of `selected`'s `.18` so the two do not serialize identically and + // trip `mergeEntriesByValue`, exactly as `SPECIAL_CLEAR_STYLES` documents. + 'selected & disabled': '#special-surface #white.17', }, // Mirrors the colored-theme soft→opaque pattern (`*-accent-text-soft` → // `*-accent-text`) using white-alpha steps: default is slightly muted so // that selected reads as the more prominent state. + // + // Both disabled labels are solved for cr ≈ 2.0 against the chip they sit on + // — the house figure for a disabled label, which `disabled-surface-text` + // hits against `surface` and which this theme's own `primary` disabled pair + // hits at 1.73. They used to measure 3.24 and 4.21: not only too legible for + // a dead control, but INVERTED, since the selected one out-read the plain + // one. The two alphas differ because they resolve against different chips + // (`.04` and `.17`), which lands them on the same contrast rather than the + // same opacity. color: { '': '#white.8', selected: '#white', - disabled: '#white.4', - 'selected & disabled': '#white.55', + disabled: '#white.23', + 'selected & disabled': '#white.28', }, } as const; @@ -890,10 +903,17 @@ export const SPECIAL_CLEAR_STYLES: Styles = { // negate against `'selected & (hovered | focused)'` (priority 4), making // it resolve to FALSE for `selected & hovered` — which is exactly the // "selected-hover stays dark" bug. `'selected & disabled'` therefore uses - // a slightly different alpha (.16) that's visually similar but a distinct - // value string. The default `''` and `disabled` may share `#white.0` - // because Tasty keeps the TRUE/default entry separate from non-defaults - // during merging. + // `#white.98` rather than a literal reuse of `selected`'s `#white`: the 2% + // of dark base bleeding through is invisible, and the string is distinct. + // The default `''` and `disabled` may share `#white.0` because Tasty keeps + // the TRUE/default entry separate from non-defaults during merging. + // + // Disabling a SELECTED control keeps the inverted pill and fades only the + // label, the same rule the rest of this file follows — the chip is what says + // "this one is on". Here that means fading the DARK label toward the pill + // rather than a white one toward the base, so the disabled label is + // `#special-accent-text` at `.45`: cr 1.95 against the pill, the same figure + // the white-alpha variants are solved for. // // Focus ring uses the fixed-mode `#special-accent-text` so the indicator // stays scheme-invariant alongside the rest of the special theme — see @@ -914,7 +934,7 @@ export const SPECIAL_CLEAR_STYLES: Styles = { 'selected & (hovered | focused)': '#white.94', 'selected & pressed': '#white.88', disabled: '#white.0', - 'selected & disabled': '#white.16', + 'selected & disabled': '#white.98', }, // Non-selected mirrors the colored-theme soft→opaque pattern with // white-alpha steps. Selected keeps its inverted look — dark accent-text @@ -923,7 +943,10 @@ export const SPECIAL_CLEAR_STYLES: Styles = { color: { '': '#white.8', selected: '#special-accent-text', - disabled: '#white.4', + // Solved for cr ≈ 2.0 against what each one sits on — the bare surface, and + // the inverted white pill. See `SPECIAL_OUTLINE_STYLES.color`. + disabled: '#white.23', + 'selected & disabled': '#special-accent-text.45', }, } as const; @@ -941,7 +964,7 @@ export const SPECIAL_LINK_STYLES: Styles = { color: { '': '#white', 'hovered & !pressed': '#white.9', - disabled: '#white.4', + disabled: '#white.23', }, } as const; @@ -954,7 +977,7 @@ export const SPECIAL_ITEM_STYLES: Styles = { }, color: { '': '#white', - disabled: '#white.4', + disabled: '#white.23', }, } as const; From 7348e0180bd44e091083fcdc60f5b5ed494a9583 Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 19 Aug 2026 15:32:27 +0000 Subject: [PATCH 08/26] fix(themes): give current.clear a disabled selected chip MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 Claude-Session: https://claude.ai/code/session_01BU1Vwm6cGgGRcMubszgbGD --- .changeset/selected-disabled-label-fade.md | 2 ++ src/data/item-themes.test.ts | 16 +++++++++++++--- src/data/item-themes.ts | 15 +++++++++++++++ 3 files changed, 30 insertions(+), 3 deletions(-) diff --git a/.changeset/selected-disabled-label-fade.md b/.changeset/selected-disabled-label-fade.md index ca394974a..9504bdb02 100644 --- a/.changeset/selected-disabled-label-fade.md +++ b/.changeset/selected-disabled-label-fade.md @@ -15,3 +15,5 @@ No existing token changed value — the palette addition is `accent-disabled-tex A unit test pins both halves of the invariant across all fifteen brand-theme x non-solid-type variants: the disabled selected fill must equal the enabled one modulo that alpha, and the label must be the `accent-disabled-text` of its theme. The `special` theme gets the same treatment, plus a correction its non-selected disabled state needed on its own. Every disabled label there 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 white-alpha labels are now solved for cr ~2.0 against whatever each one sits on: `#white.23` for the plain disabled states across `outline`, `clear`, `item` and `link`, `#white.28` for `outline` selected + disabled on its `.17` chip. `outline` selected + disabled keeps the enabled selected chip like the colored themes (`.17` against its `.18`), and `clear` — whose selected state inverts to a solid white pill — keeps that pill and fades its DARK label instead, to `#special-accent-text.45`, cr 1.95 against the pill. + +The `current` theme's `clear` flavour gains the same state, which it was missing entirely: a disabled selected `clear` fell through to the item ramp's bare `transparent` and rendered no chip at all, so it was indistinguishable from an unselected one. It now carries `#current.18` — `current.outline`'s own disabled selected chip, so the two differ by exactly the border. This is the same split the colored themes already make between `*_ITEM_STYLES` and `*_CLEAR_STYLES`. diff --git a/src/data/item-themes.test.ts b/src/data/item-themes.test.ts index 777d13b41..b6d922312 100644 --- a/src/data/item-themes.test.ts +++ b/src/data/item-themes.test.ts @@ -38,14 +38,24 @@ describe('ITEM_VARIANTS', () => { ] as const; /** - * The `current` theme carries the state but cannot follow the rule literally. + * The `current` theme carries the state but cannot follow the rule literally, + * for two reasons. + * * Its alphas resolve against the element's OWN `currentcolor`, which the * disabled label has already faded to `.4`, so its disabled entries are * written PRE-MULTIPLIED: an authored `.18` renders as ~`.07`, below the * `.12` it is compared against. The chip is muted in effect; only the * authored number goes up. See `CURRENT_OUTLINE_STYLES`. + * + * And `current.clear` steps its enabled states through the custom properties + * of `CURRENT_ITEM_RAMP` — one per scheme and surface — while its disabled + * chip is a plain alpha, so the two are not comparable as strings at all. */ - const PREMULTIPLIED = ['current.outline', 'current.outline-2'] as const; + const CURRENT = [ + 'current.outline', + 'current.outline-2', + 'current.clear', + ] as const; /** `'#surface-2 #primary-accent-surface.09'` -> `['#surface-2 #primary-accent-surface', 0.09]` */ function splitAlpha(value: string): [string, number] { @@ -69,7 +79,7 @@ describe('ITEM_VARIANTS', () => { }) .map(([variant]) => variant); - expect(carriers.sort()).toEqual([...VARIANTS, ...PREMULTIPLIED].sort()); + expect(carriers.sort()).toEqual([...VARIANTS, ...CURRENT].sort()); }); it.each(VARIANTS)( diff --git a/src/data/item-themes.ts b/src/data/item-themes.ts index 35010d9ba..6aae91f59 100644 --- a/src/data/item-themes.ts +++ b/src/data/item-themes.ts @@ -1138,6 +1138,21 @@ export const CURRENT_ITEM_STYLES: Styles = { export const CURRENT_CLEAR_STYLES: Styles = { ...CURRENT_ITEM_STYLES, ...CURRENT_FOCUS_RING, + fill: { + ...(CURRENT_ITEM_STYLES.fill as Record), + // The one entry `clear` adds to the item ramp, and the same split the + // colored themes make: `*_ITEM_STYLES` let a disabled row fall back to a + // bare `transparent`, while `*_CLEAR_STYLES` keep a chip so a disabled + // segmented control still shows which option is active. Without it a + // disabled selected `clear` rendered nothing at all — the state was + // indistinguishable from an unselected one. + // + // `.18` is `CURRENT_OUTLINE_STYLES`' own disabled selected chip, so the two + // differ by exactly the border, and it is authored high for the same reason + // documented there: the disabled label dims `currentcolor` to `.4`, and + // this alpha resolves against it, rendering as ~`.07`. + 'selected & disabled': '#current.18', + }, } as const; // Outline flavour — a standalone control, so it carries its own weight: a From 8e5f0a7958b57bc53ac6a8fb3bc8a752acd39b99 Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 19 Aug 2026 15:55:39 +0000 Subject: [PATCH 09/26] fix(Banner): stop clearing the border off its actions MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A Banner action asks for `type="outline"` and then cleared the border. That made sense when an action inherited the banner's THEME: `outline` meant `note.outline` and friends, whose border is the opaque `#note-border` — a pale line built for a `#surface-2` chip on a light page, plainly wrong on a saturated banner — and the opaque fill carried the chip on its own. On the `current` theme the border is `#current.08` mixed from the banner's own white label, and the fill is a 3% tint that cannot carry a chip by itself, so clearing the border left the action invisible. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01BU1Vwm6cGgGRcMubszgbGD --- .changeset/current-theme-axis.md | 2 ++ src/components/actions/Banner/Banner.tsx | 12 +++++++++++- 2 files changed, 13 insertions(+), 1 deletion(-) diff --git a/.changeset/current-theme-axis.md b/.changeset/current-theme-axis.md index b86e4b6b7..96e509e46 100644 --- a/.changeset/current-theme-axis.md +++ b/.changeset/current-theme-axis.md @@ -37,6 +37,8 @@ The top step of each ramp stops at `#current.24`. That is the measured AA floor This changes one case: an action that named a `type` but no `theme` used to inherit the host row's theme, and now takes the host's color through `currentcolor` instead. Inside a themed row the two are close by construction — a `danger` row paints `#danger-accent-text`, which is what the action then mixes from — but the chip is an alpha tint rather than the brand ramp. Pass `theme="default"` (or any other theme) to opt back into a fixed palette. +`Banner` is the one in-repo consumer that needed a matching edit. Its actions ask for `type="outline"` and then cleared the border, because back then `outline` meant `note.outline` and friends — whose border is the opaque `#note-border`, a pale line built for a `#surface-2` chip on a light page and plainly wrong on a saturated banner. The fill carried the chip on its own there. On the `current` theme the border is `#current.08` mixed from the banner's own white label, and the fill is a 3% tint that cannot carry a chip by itself, so clearing the border left the action invisible. The override is gone and the type renders as designed. + The host theme still reaches the element, as `data-surface`: it names the surface an action is painted _on_, which is a different question from its own theme now that `current` occupies that axis. The `current` ramp reads it to pick the alphas that work over the `special` theme's fixed dark-purple surface — the job `data-theme` used to do before `theme="current"` claimed that attribute. ### Also diff --git a/src/components/actions/Banner/Banner.tsx b/src/components/actions/Banner/Banner.tsx index 396ad82c5..a937be335 100644 --- a/src/components/actions/Banner/Banner.tsx +++ b/src/components/actions/Banner/Banner.tsx @@ -59,11 +59,21 @@ const BannerElement = tasty(Item, { }, }); +// `outline` on the `current` theme (`Item.Action`'s default): the chip and its +// border are mixed from the banner's own white label, so the action reads as a +// control against a saturated surface without naming a color. +// +// This used to clear the border. Back then an action inherited the banner's +// THEME, so `outline` meant `note.outline` and friends, whose border is the +// opaque `#note-border` — a pale line built for a `#surface-2` chip on a light +// page, and plainly wrong on a saturated banner. Now that the border is +// `#current.08` off the inherited label, clearing it only removed the one thing +// that made the type an outline, leaving a 3% tint that vanished into the +// banner. const BannerActionElement = tasty(Item.Action, { type: 'outline', styles: { preset: 't3m', - border: '#clear', }, }); From 545ad32636ad99c3bbe0d6b1f47069d7f4621236 Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 19 Aug 2026 16:25:55 +0000 Subject: [PATCH 10/26] feat(themes): invert current.primary MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `primary` on the `current` theme now does what every other theme's `primary` does: an opaque fill with the label punched out of it. The fill is the inherited color at full opacity, the label is `#accent-surface-text`. Hover and pressed are the second fill layer — a translucent `#black` over the same base — because an arbitrary inherited color has no lighter or darker sibling to step to the way the brand ramps walk `accent-surface` to `-2` and `-3`. The label cannot go through `color`. `#current` compiles to the literal `currentcolor`, which in `fill` resolves against the element's OWN `color`, so setting `color` to the label token would make the fill resolve to the label color and paint a white pill with a white label. `-webkit-text-fill-color` paints the glyphs without touching `color`, so `currentcolor` keeps meaning the inherited color for `fill` and `border`; icons are SVG stroked with `currentColor`, which that property does not reach, so they take the label color through the `Icon` sub-element. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01BU1Vwm6cGgGRcMubszgbGD --- .changeset/current-theme-axis.md | 2 +- .../actions/Button/Button.stories.tsx | 2 +- src/components/content/Item/Item.stories.tsx | 2 +- src/data/item-themes.ts | 58 +++++++++++++------ 4 files changed, 43 insertions(+), 21 deletions(-) diff --git a/.changeset/current-theme-axis.md b/.changeset/current-theme-axis.md index 96e509e46..1c6fffffd 100644 --- a/.changeset/current-theme-axis.md +++ b/.changeset/current-theme-axis.md @@ -10,7 +10,7 @@ On the `theme` axis it composes instead, and every type now has a `current` flav - `clear` — the same ramp plus the focus ring a standalone control needs. The default for `Item.Action` and `ItemBadge`. - `outline` — the old `Button` shape: a resting `#current.03` chip inside a `#current.08` border. - `outline-2` — `outline` for a container that is already painting something. The brand themes swap an opaque base (`#surface-3` for `#surface-2`); `current` has no opaque base to swap, so the same intent is carried by roughly doubling the tint at every step. -- `primary` — the high-emphasis control. Every other theme paints an opaque brand fill under a `#white` label; with one inherited color and an unknown surface behind it there is nothing to punch the label out with, so `primary` escalates the alpha ramp instead, resting at `#current.14`. It reads as filled, not as inverted. +- `primary` — the high-emphasis control, and the one flavour that INVERTS, like every other theme's `primary`: the fill is the inherited color at full opacity and the label is punched out of it with `#accent-surface-text`. Hover and pressed lay a translucent `#black` over the same base, since an arbitrary color has no lighter or darker sibling to step to the way the brand ramps walk `accent-surface` to `-2` and `-3`. The label is painted with `-webkit-text-fill-color` rather than `color`: `#current` compiles to the literal `currentcolor`, which in `fill` resolves against the element's own `color`, so setting `color` to the label token would make the fill resolve to the label color and paint a white pill with a white label. - `link` — no chip at all. The brand themes intensify from `accent-text-soft` to `accent-text` on hover; here "soft" is the inherited color at `.8` and "strong" is it at full opacity. - `card` — the static panel: a `#current.05` fill inside a `#current.2` border (`Item` only). diff --git a/src/components/actions/Button/Button.stories.tsx b/src/components/actions/Button/Button.stories.tsx index b61b2ef29..2b59e7769 100644 --- a/src/components/actions/Button/Button.stories.tsx +++ b/src/components/actions/Button/Button.stories.tsx @@ -395,7 +395,7 @@ CurrentStates.parameters = { docs: { description: { story: - 'Every type, every state, on the `current` theme. Nothing here names a color: the block is painted `#note-surface` / `#note-accent-text` and each button mixes its fill, border and label from that inherited text color. `primary` is the strongest step of the same alpha ramp rather than an inverted fill — a single inherited color has nothing to punch the label out with — and `outline-2` sits in a `#current.08` panel, the `current` stand-in for the `#surface-2` container `outline-2` is drawn for. Swap the block color and the whole sweep follows it; `CurrentTheme` below does exactly that across seven containers.', + 'Every type, every state, on the `current` theme. Nothing here names a color: the block is painted `#note-surface` / `#note-accent-text` and each button mixes its fill, border and label from that inherited text color. `primary` inverts to an opaque fill of the inherited color with the label punched out of it, and `outline-2` sits in a `#current.08` panel, the `current` stand-in for the `#surface-2` container `outline-2` is drawn for. Swap the block color and the whole sweep follows it; `CurrentTheme` below does exactly that across seven containers.', }, }, }; diff --git a/src/components/content/Item/Item.stories.tsx b/src/components/content/Item/Item.stories.tsx index 509970796..d348f559a 100644 --- a/src/components/content/Item/Item.stories.tsx +++ b/src/components/content/Item/Item.stories.tsx @@ -2757,7 +2757,7 @@ CurrentStates.parameters = { docs: { description: { story: - 'Every type, every state, on the `current` theme — the matrix the other themes get from `TypesAndThemes`, run on the one theme whose colors are not its own. Nothing in the grid names a color: each block paints a text color and every item mixes its fill, border and label from it. `primary` is the strongest step of the same alpha ramp rather than an inverted fill — a single inherited color has nothing to punch the label out with — and `outline-2` sits in a `#current.08` panel, the `current` stand-in for the `#surface-2` container it is drawn for. `link` carries no chip at all: it goes soft (`#current.8`) at rest and full strength on hover.', + 'Every type, every state, on the `current` theme — the matrix the other themes get from `TypesAndThemes`, run on the one theme whose colors are not its own. Nothing in the grid names a color: each block paints a text color and every item mixes its fill, border and label from it. `primary` inverts to an opaque fill of the inherited color with the label punched out of it, and `outline-2` sits in a `#current.08` panel, the `current` stand-in for the `#surface-2` container it is drawn for. `link` carries no chip at all: it goes soft (`#current.8`) at rest and full strength on hover.', }, }, }; diff --git a/src/data/item-themes.ts b/src/data/item-themes.ts index 6aae91f59..d2d347743 100644 --- a/src/data/item-themes.ts +++ b/src/data/item-themes.ts @@ -1229,34 +1229,56 @@ export const CURRENT_OUTLINE_2_STYLES: Styles = { }, } as const; -// Primary flavour — the high-emphasis control. See the section header for why -// this is an alpha escalation of `outline` rather than the inverted, opaque fill -// the brand themes use. Like every other `*_PRIMARY_STYLES` it has no `selected` -// state: primary is already the emphatic option, so there is nothing above it to -// step to. +// Primary flavour — the high-emphasis control, and the only `current` flavour +// that INVERTS: the fill is the inherited color at full opacity and the label +// is punched out of it with `#accent-surface-text`, exactly as every other +// theme's `primary` paints `#white` on an opaque brand fill. +// +// The states are the second fill layer. There is no lighter or darker sibling +// of an arbitrary inherited color to step to — the brand ramps walk +// `accent-surface` → `-2` → `-3` — so hover and pressed lay a translucent +// `#black` over the same base instead, which darkens in both schemes and so +// keeps the same monotonic direction the brand primaries have. +// +// The label CANNOT go through `color`. `#current` compiles to the literal +// `currentcolor`, which in `fill` resolves against the element's OWN `color`, +// so setting `color: '#accent-surface-text'` would make the fill resolve to the +// label color and paint a white pill with a white label. Tasty's `--current-color` +// is no escape either: the `color` handler rewrites it on the same element. +// `-webkit-text-fill-color` paints the glyphs without touching `color`, so +// `currentcolor` keeps meaning the INHERITED color for `fill` and `border`. +// Icons are SVG painted with `fill="currentColor"`, which that property does not +// reach, so they take the label color through the `Icon` sub-element. export const CURRENT_PRIMARY_STYLES: Styles = { ...CURRENT_FOCUS_RING, + // Matches the base fill, so the border reads as part of the chip rather than + // a rim — and still darkens with it, since only the overlay moves. border: { - '': '#current.28', - 'hovered | focused': '#current.35', - pressed: '#current.45', - // Pre-multiplied against the `.4` label fade below → an effective `.2`, - // a muted version of the resting border rather than a stronger one. - disabled: '#current.5', + '': '#current', + disabled: '#current.4', }, fill: { - '': '#current.14', - 'hovered | focused': '#current.18', - // The AA ceiling for a full-strength label on a dark surface, and the - // reason the ramp stops climbing here. - pressed: '#current.24', - // Pre-multiplied → an effective `.104`, just under the resting chip. - disabled: '#current.26', + '': '#current', + 'hovered | focused': '#current #black.08', + pressed: '#current #black.16', + disabled: '#current.4', }, + // Deliberately left as the inherited color: this is what keeps `currentcolor` + // — and therefore the fill above — meaning the color the context paints with. color: { '': '#current', disabled: '#current.4', }, + '-webkit-text-fill-color': { + '': '#accent-surface-text', + disabled: '#accent-surface-text.5', + }, + Icon: { + color: { + '': '#accent-surface-text', + disabled: '#accent-surface-text.5', + }, + }, } as const; // Link flavour — no chip at all, only the label. The brand themes intensify from From 0f6e60389b6d2c1cb1e774a876cce09366d70ebd Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 19 Aug 2026 16:40:16 +0000 Subject: [PATCH 11/26] fix(themes): recolor every icon slot on current.primary, stop double-fading it MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Two defects in the inverted primary, both reported by Cursor Bugbot. `RightIcon`, `Prefix` and `Suffix` kept the inherited color and so vanished into the fill they matched. `-webkit-text-fill-color` paints glyphs and inherits into the text slots for free, but icons are SVG stroked with `currentColor`, which it does not reach, so every icon-bearing slot needs the label color of its own — not just the leading one. `color` no longer fades on disabled. It was `#current.4`, and `fill` and `border` resolve `currentcolor` against this element's own `color`, so a `.4` label under a `.4` fill landed the chip at `.16` — the pre-multiply trap the other `current` ramps document at length. Nothing needed it to move: the label is painted by `-webkit-text-fill-color` and the icons take their own. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01BU1Vwm6cGgGRcMubszgbGD --- src/data/item-themes.ts | 31 ++++++++++++++++++++++--------- 1 file changed, 22 insertions(+), 9 deletions(-) diff --git a/src/data/item-themes.ts b/src/data/item-themes.ts index d2d347743..a0e88dda0 100644 --- a/src/data/item-themes.ts +++ b/src/data/item-themes.ts @@ -1263,22 +1263,35 @@ export const CURRENT_PRIMARY_STYLES: Styles = { pressed: '#current #black.16', disabled: '#current.4', }, - // Deliberately left as the inherited color: this is what keeps `currentcolor` - // — and therefore the fill above — meaning the color the context paints with. + // Deliberately the inherited color in EVERY state, disabled included: this is + // the one thing keeping `currentcolor` — and therefore the fill and border + // above — meaning the color the context paints with. Fading it here would fade + // them too, since they resolve `currentcolor` against this element's own + // `color`: a `.4` label under a `.4` fill lands the chip at `.16`, the + // pre-multiply trap the other `current` ramps document. Nothing needs it to + // move, because the label is painted below and the icons take their own. color: { '': '#current', - disabled: '#current.4', }, '-webkit-text-fill-color': { '': '#accent-surface-text', disabled: '#accent-surface-text.5', }, - Icon: { - color: { - '': '#accent-surface-text', - disabled: '#accent-surface-text.5', - }, - }, + // Every icon-bearing slot, not just the leading one. `-webkit-text-fill-color` + // paints glyphs and inherits into the text slots for free, but icons are SVG + // stroked with `currentColor`, which it does not reach — so an un-recolored + // slot would keep the inherited color and vanish into the fill it matches. + ...Object.fromEntries( + ['Icon', 'RightIcon', 'Prefix', 'Suffix'].map((slot) => [ + slot, + { + color: { + '': '#accent-surface-text', + disabled: '#accent-surface-text.5', + }, + }, + ]), + ), } as const; // Link flavour — no chip at all, only the label. The brand themes intensify from From 3b44314daf2a05f827ff83fdb8fc9b3f744a8b34 Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 19 Aug 2026 17:02:07 +0000 Subject: [PATCH 12/26] fix(themes): label current.primary with #surface so it inverts in dark too MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `#accent-surface-text` is the token that pairs with an accent SURFACE, and it is near-white in both schemes. That is right in light, where the inherited color is a dark accent, and wrong in dark, where the same accent resolves LIGHT: a near-white label on it measures cr 1.91. `#surface` is the page background, so it is always the opposite of the text painted on it and inverts with the scheme for free. Measured across all five brand themes against `-accent-text` as the fill, it holds cr 6.87–7.90 in light AND dark, so no `@dark` branch is needed. Also expresses disabled once, through `color` alone. `fill` and `border` resolve `currentcolor` against this element's own `color`, so fading it there fades the chip with it and lands at exactly `.4`; writing `.4` in all three applied the fade twice and landed at `.16`. The fade has to happen on `color` rather than nowhere, because descendants read it: an action inside a disabled row suppresses its own fade on the grounds that the host already muted the color it paints from. Reported by Cursor Bugbot. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01BU1Vwm6cGgGRcMubszgbGD --- .changeset/current-theme-axis.md | 2 +- src/data/item-themes.ts | 35 ++++++++++++++++++-------------- 2 files changed, 21 insertions(+), 16 deletions(-) diff --git a/.changeset/current-theme-axis.md b/.changeset/current-theme-axis.md index 1c6fffffd..95b22d265 100644 --- a/.changeset/current-theme-axis.md +++ b/.changeset/current-theme-axis.md @@ -10,7 +10,7 @@ On the `theme` axis it composes instead, and every type now has a `current` flav - `clear` — the same ramp plus the focus ring a standalone control needs. The default for `Item.Action` and `ItemBadge`. - `outline` — the old `Button` shape: a resting `#current.03` chip inside a `#current.08` border. - `outline-2` — `outline` for a container that is already painting something. The brand themes swap an opaque base (`#surface-3` for `#surface-2`); `current` has no opaque base to swap, so the same intent is carried by roughly doubling the tint at every step. -- `primary` — the high-emphasis control, and the one flavour that INVERTS, like every other theme's `primary`: the fill is the inherited color at full opacity and the label is punched out of it with `#accent-surface-text`. Hover and pressed lay a translucent `#black` over the same base, since an arbitrary color has no lighter or darker sibling to step to the way the brand ramps walk `accent-surface` to `-2` and `-3`. The label is painted with `-webkit-text-fill-color` rather than `color`: `#current` compiles to the literal `currentcolor`, which in `fill` resolves against the element's own `color`, so setting `color` to the label token would make the fill resolve to the label color and paint a white pill with a white label. +- `primary` — the high-emphasis control, and the one flavour that INVERTS, like every other theme's `primary`: the fill is the inherited color at full opacity and the label is punched out of it with `#surface` — the page background, which is always the opposite of the text painted on it and so inverts with the scheme for free. Hover and pressed lay a translucent `#black` over the same base, since an arbitrary color has no lighter or darker sibling to step to the way the brand ramps walk `accent-surface` to `-2` and `-3`. The label is painted with `-webkit-text-fill-color` rather than `color`: `#current` compiles to the literal `currentcolor`, which in `fill` resolves against the element's own `color`, so setting `color` to the label token would make the fill resolve to the label color and paint a white pill with a white label. - `link` — no chip at all. The brand themes intensify from `accent-text-soft` to `accent-text` on hover; here "soft" is the inherited color at `.8` and "strong" is it at full opacity. - `card` — the static panel: a `#current.05` fill inside a `#current.2` border (`Item` only). diff --git a/src/data/item-themes.ts b/src/data/item-themes.ts index a0e88dda0..a217059ae 100644 --- a/src/data/item-themes.ts +++ b/src/data/item-themes.ts @@ -1231,7 +1231,7 @@ export const CURRENT_OUTLINE_2_STYLES: Styles = { // Primary flavour — the high-emphasis control, and the only `current` flavour // that INVERTS: the fill is the inherited color at full opacity and the label -// is punched out of it with `#accent-surface-text`, exactly as every other +// is punched out of it with `#surface`, exactly as every other // theme's `primary` paints `#white` on an opaque brand fill. // // The states are the second fill layer. There is no lighter or darker sibling @@ -1242,7 +1242,7 @@ export const CURRENT_OUTLINE_2_STYLES: Styles = { // // The label CANNOT go through `color`. `#current` compiles to the literal // `currentcolor`, which in `fill` resolves against the element's OWN `color`, -// so setting `color: '#accent-surface-text'` would make the fill resolve to the +// so setting `color: '#surface'` would make the fill resolve to the // label color and paint a white pill with a white label. Tasty's `--current-color` // is no escape either: the `color` handler rewrites it on the same element. // `-webkit-text-fill-color` paints the glyphs without touching `color`, so @@ -1255,27 +1255,32 @@ export const CURRENT_PRIMARY_STYLES: Styles = { // a rim — and still darkens with it, since only the overlay moves. border: { '': '#current', - disabled: '#current.4', }, fill: { '': '#current', 'hovered | focused': '#current #black.08', pressed: '#current #black.16', - disabled: '#current.4', }, - // Deliberately the inherited color in EVERY state, disabled included: this is - // the one thing keeping `currentcolor` — and therefore the fill and border - // above — meaning the color the context paints with. Fading it here would fade - // them too, since they resolve `currentcolor` against this element's own - // `color`: a `.4` label under a `.4` fill lands the chip at `.16`, the - // pre-multiply trap the other `current` ramps document. Nothing needs it to - // move, because the label is painted below and the icons take their own. + // Disabled is expressed HERE and nowhere else, and that is the whole trick. + // `fill` and `border` resolve `currentcolor` against this element's own + // `color`, so fading it once fades the chip with it: their default entries + // still read `#current`, which under `disabled` is already the `.4` color, + // and the chip lands at exactly `.4`. Writing `.4` in all three would apply + // the fade twice and land it at `.16` — the pre-multiply trap the other + // `current` ramps document at length. + // + // It has to fade rather than stay put, because descendants read it too: an + // action inside a disabled row suppresses its OWN fade on the grounds that + // the host already muted the color it paints from (see + // `CURRENT_ITEM_STYLES.color`), so a host that stayed at full strength would + // hand it a live color next to a dead chip. color: { '': '#current', + disabled: '#current.4', }, '-webkit-text-fill-color': { - '': '#accent-surface-text', - disabled: '#accent-surface-text.5', + '': '#surface', + disabled: '#surface.5', }, // Every icon-bearing slot, not just the leading one. `-webkit-text-fill-color` // paints glyphs and inherits into the text slots for free, but icons are SVG @@ -1286,8 +1291,8 @@ export const CURRENT_PRIMARY_STYLES: Styles = { slot, { color: { - '': '#accent-surface-text', - disabled: '#accent-surface-text.5', + '': '#surface', + disabled: '#surface.5', }, }, ]), From b1163682de9dd2d6d7a7914507b86dbed87f13c2 Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 19 Aug 2026 17:55:46 +0000 Subject: [PATCH 13/26] fix(themes): give current.primary a visible rim MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The border matched the fill exactly, so the chip had no rim at all while every other `primary` shows one — `accent-surface-border` over `accent-surface`, cr 1.48 against it in both schemes. An arbitrary inherited color has no lighter sibling to rim with, so the rim comes from the same token the label does: the one color guaranteed to sit on the opposite side of the fill in either scheme. `#surface.25` measures cr 1.82 in light and 1.55 in dark against the fill — the brand rim's presence, a shade more so in light, where `current` has no other edge cue. Disabled swaps to `#surface-text.2`, and has to. The chip there is a `.4` tint sitting close to the page, so a `#surface` rim washes into the page instead of defining the chip; the neutral text color goes the other way and holds cr ~1.5 against the chip and 2.8-3.7 against the page. It is also immune to the `.4` fade on `color`, which `#current` is not — a `#current` rim on a disabled chip resolves to the fill's own color and disappears. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01BU1Vwm6cGgGRcMubszgbGD --- .changeset/current-theme-axis.md | 2 +- src/data/item-themes.ts | 19 ++++++++++++++++--- 2 files changed, 17 insertions(+), 4 deletions(-) diff --git a/.changeset/current-theme-axis.md b/.changeset/current-theme-axis.md index 95b22d265..dadd255ab 100644 --- a/.changeset/current-theme-axis.md +++ b/.changeset/current-theme-axis.md @@ -10,7 +10,7 @@ On the `theme` axis it composes instead, and every type now has a `current` flav - `clear` — the same ramp plus the focus ring a standalone control needs. The default for `Item.Action` and `ItemBadge`. - `outline` — the old `Button` shape: a resting `#current.03` chip inside a `#current.08` border. - `outline-2` — `outline` for a container that is already painting something. The brand themes swap an opaque base (`#surface-3` for `#surface-2`); `current` has no opaque base to swap, so the same intent is carried by roughly doubling the tint at every step. -- `primary` — the high-emphasis control, and the one flavour that INVERTS, like every other theme's `primary`: the fill is the inherited color at full opacity and the label is punched out of it with `#surface` — the page background, which is always the opposite of the text painted on it and so inverts with the scheme for free. Hover and pressed lay a translucent `#black` over the same base, since an arbitrary color has no lighter or darker sibling to step to the way the brand ramps walk `accent-surface` to `-2` and `-3`. The label is painted with `-webkit-text-fill-color` rather than `color`: `#current` compiles to the literal `currentcolor`, which in `fill` resolves against the element's own `color`, so setting `color` to the label token would make the fill resolve to the label color and paint a white pill with a white label. +- `primary` — the high-emphasis control, and the one flavour that INVERTS, like every other theme's `primary`: the fill is the inherited color at full opacity and the label is punched out of it with `#surface` — the page background, which is always the opposite of the text painted on it and so inverts with the scheme for free. Hover and pressed lay a translucent `#black` over the same base, since an arbitrary color has no lighter or darker sibling to step to the way the brand ramps walk `accent-surface` to `-2` and `-3`. The rim comes from the same `#surface` token at `.25` — every other `primary` rims its fill with `accent-surface-border`, cr 1.48 against it, and this measures 1.82 in light and 1.55 in dark. Disabled swaps the rim to `#surface-text.2`, which holds against a `.4` chip that a `#surface` rim would wash into. The label is painted with `-webkit-text-fill-color` rather than `color`: `#current` compiles to the literal `currentcolor`, which in `fill` resolves against the element's own `color`, so setting `color` to the label token would make the fill resolve to the label color and paint a white pill with a white label. - `link` — no chip at all. The brand themes intensify from `accent-text-soft` to `accent-text` on hover; here "soft" is the inherited color at `.8` and "strong" is it at full opacity. - `card` — the static panel: a `#current.05` fill inside a `#current.2` border (`Item` only). diff --git a/src/data/item-themes.ts b/src/data/item-themes.ts index a217059ae..d6f01381f 100644 --- a/src/data/item-themes.ts +++ b/src/data/item-themes.ts @@ -1251,10 +1251,23 @@ export const CURRENT_OUTLINE_2_STYLES: Styles = { // reach, so they take the label color through the `Icon` sub-element. export const CURRENT_PRIMARY_STYLES: Styles = { ...CURRENT_FOCUS_RING, - // Matches the base fill, so the border reads as part of the chip rather than - // a rim — and still darkens with it, since only the overlay moves. + // Every other `primary` rims its fill with a lighter sibling + // (`accent-surface-border` over `accent-surface`, cr 1.48 against it in both + // schemes). An arbitrary inherited color has no such sibling, so the rim comes + // from the same token the label does — the one color guaranteed to sit on the + // opposite side of the fill in either scheme. `.25` measures cr 1.82 in light + // and 1.55 in dark against the fill: the brand rim's presence, a shade more so + // in light, where `current` has no other edge cue. + // + // Disabled swaps to the neutral text color, and has to. The chip there is a + // `.4` tint sitting close to the page, so a `#surface` rim washes into the + // page rather than defining the chip; `#surface-text` goes the other way and + // holds cr ~1.5 against the chip AND 2.8–3.7 against the page. It is also + // immune to the `.4` fade below, which `#current` is not — a `#current` rim on + // a disabled chip resolves to the fill's own color and disappears. border: { - '': '#current', + '': '#surface.25', + disabled: '#surface-text.2', }, fill: { '': '#current', From 26d45a505bd0a6e9a4c8a60bc03ae692a6cf83d5 Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 19 Aug 2026 18:40:35 +0000 Subject: [PATCH 14/26] feat(themes): add an `invert` type on every theme MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `invert` is the filled type that follows the color scheme, where `primary` deliberately does not. Its fill is the theme's `accent-text` — the color normally painted *on* the page — and its 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. Measured fill/label contrast is 6.96:1 light and 7.52:1 dark. The trap this pairing invites is labelling with `surface-text`, which sits on the SAME side of the fill as `accent-text` does — that measures 2.60 / 1.91. 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. 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. `special.invert` inverts against its own fixed dark surface instead of the page, giving a white pill with the theme's dark accent on it. On `current` the two constructions necessarily coincide — there is one color in play — so `CURRENT_INVERT_STYLES` aliases `CURRENT_PRIMARY_STYLES` rather than copying it, and `CurrentStates` skips the duplicate column. The seven variants come from an `invertStyles(accent)` factory rather than seven near-identical objects: the only thing that varies is the theme prefix, and hand-copying that is how `selected & disabled` landed on the wrong token for four themes earlier in this branch. Also wired up around the edges: the split-button border collapse now counts `invert` among the bordered types, `ItemBadge` drops its rim like it does for `primary`, and the `HotKeys` hint on an `invert` row switches to the `inherit` flavour so it tracks the `#surface` label instead of `primary`'s fixed `#white` — except on `current`, where `currentcolor` IS the fill and `inherit` would erase the hint's rim. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01BU1Vwm6cGgGRcMubszgbGD --- .changeset/invert-type.md | 11 ++ src/components/actions/Button/Button.docs.mdx | 3 +- .../actions/Button/Button.stories.tsx | 3 +- src/components/actions/Button/Button.tsx | 24 +++- .../actions/ItemAction/ItemAction.docs.mdx | 2 +- .../actions/ItemAction/ItemAction.stories.tsx | 25 +++- .../actions/ItemAction/ItemAction.tsx | 23 +++- src/components/content/Item/Item.docs.mdx | 5 +- src/components/content/Item/Item.stories.tsx | 9 +- src/components/content/Item/Item.tsx | 22 +++- .../content/ItemBadge/ItemBadge.docs.mdx | 2 +- .../content/ItemBadge/ItemBadge.stories.tsx | 6 + .../content/ItemBadge/ItemBadge.tsx | 19 ++- src/data/item-themes.ts | 108 ++++++++++++++++++ 14 files changed, 247 insertions(+), 15 deletions(-) create mode 100644 .changeset/invert-type.md diff --git a/.changeset/invert-type.md b/.changeset/invert-type.md new file mode 100644 index 000000000..49150ee01 --- /dev/null +++ b/.changeset/invert-type.md @@ -0,0 +1,11 @@ +--- +'@cube-dev/ui-kit': minor +--- + +Add an `invert` type to `Button`, `Item`, `Item.Action` and `Item.Badge`, available on every theme. + +`invert` is the filled type that follows the color scheme. Its fill is the theme's `accent-text` — the color normally painted *on* the page — and its 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. That is what separates it from `primary`, which pins a fixed `#white` label on a brand *surface* and therefore reads with the same weight in both schemes. + +Measured contrast between fill and label is 6.96:1 in light and 7.52:1 in dark. 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. Disabled reuses the brand-tinted pair `primary` already mutes to, so the two filled types stay calibrated together. + +`special.invert` inverts against its own fixed dark surface instead of the page, giving a white pill with the theme's dark accent on it. On the `current` theme `invert` and `primary` coincide — there is only one color in play, so "the emphatic filled type" and "the page and its text swapped" are the same construction — and `current.invert` is an alias of `current.primary` rather than a copy. diff --git a/src/components/actions/Button/Button.docs.mdx b/src/components/actions/Button/Button.docs.mdx index 6d6cf267f..5e02fc828 100644 --- a/src/components/actions/Button/Button.docs.mdx +++ b/src/components/actions/Button/Button.docs.mdx @@ -24,7 +24,7 @@ A versatile action component that triggers commands and navigates users. ## Properties -- **`type`** `'primary' | 'outline' | 'outline-2' | 'clear' | 'link'` (default: `outline`) — Visual style variant of the button (`outline-2` is identical to `outline` but uses `#surface-3` as the base fill; not available for the `special` theme) +- **`type`** `'primary' | 'invert' | 'outline' | 'outline-2' | 'clear' | 'link'` (default: `outline`) — Visual style variant of the button (`outline-2` is identical to `outline` but uses `#surface-3` as the base fill; not available for the `special` theme) - **`theme`** `'default' | 'danger' | 'success' | 'warning' | 'note' | 'special' | 'current'` (default: `default`) — Semantic colour palette theme. `current` is not a brand palette: it mixes every color from the inherited text color, so the button adopts the color of whatever container it sits in - **`size`** `'xsmall' | 'small' | 'medium' | 'large' | 'xlarge' | 'inline' | number` (default: `medium`) — Button size. Use `'inline'` for link-type buttons or a number/string for custom sizes (e.g. `64` or `'8x'`) - **`icon`** — Icon rendered before the content. Can be: ReactNode, `true` (empty slot), or function `({ loading, selected, ...mods }) => ReactNode | true` @@ -88,6 +88,7 @@ The `mods` prop accepts the following modifiers you can override: ### Types. `type` prop - `primary` – Emphasised call-to-action. +- `invert` – The page and its text swapped: the fill is the theme's `accent-text` — the color normally painted *on* the page — and the label is `#surface`, the page itself. Unlike `primary`, which pins a fixed `#white` label on a brand surface and so reads with the same weight in either scheme, `invert` follows the scheme: dark chip with a light label in light mode, light chip with a dark label in dark mode. Use it where a `primary` chip is not emphatic enough — a single hero action, or a filled control that has to out-read a brand chip sitting next to it. - `outline` – Border with transparent background; `isSelected` adds brand-tinted fill. - `clear` – No border, transparent background; `isSelected` adds brand-tinted fill. - `link` – Styled as a textual link. diff --git a/src/components/actions/Button/Button.stories.tsx b/src/components/actions/Button/Button.stories.tsx index 2b59e7769..ae9536c47 100644 --- a/src/components/actions/Button/Button.stories.tsx +++ b/src/components/actions/Button/Button.stories.tsx @@ -24,7 +24,7 @@ export default { argTypes: { /* Visual presentation */ type: { - options: ['primary', 'outline', 'outline-2', 'clear', 'link'], + options: ['primary', 'invert', 'outline', 'outline-2', 'clear', 'link'], control: { type: 'radio' }, description: 'Visual style variant of the button', table: { @@ -178,6 +178,7 @@ const TemplateSizesOnlyIcon: StoryFn = ({ const BUTTON_TYPES = [ 'primary', + 'invert', 'outline', 'outline-2', 'clear', diff --git a/src/components/actions/Button/Button.tsx b/src/components/actions/Button/Button.tsx index 8211c4cb3..e8c24f889 100644 --- a/src/components/actions/Button/Button.tsx +++ b/src/components/actions/Button/Button.tsx @@ -24,35 +24,42 @@ import { useIsFirstRender } from '../../../_internal/hooks/use-is-first-render'; import { useWarn } from '../../../_internal/hooks/use-warn'; import { CURRENT_CLEAR_STYLES, + CURRENT_INVERT_STYLES, CURRENT_LINK_STYLES, CURRENT_OUTLINE_2_STYLES, CURRENT_OUTLINE_STYLES, CURRENT_PRIMARY_STYLES, DANGER_CLEAR_STYLES, + DANGER_INVERT_STYLES, DANGER_LINK_STYLES, DANGER_OUTLINE_2_STYLES, DANGER_OUTLINE_STYLES, DANGER_PRIMARY_STYLES, DEFAULT_CLEAR_STYLES, + DEFAULT_INVERT_STYLES, DEFAULT_LINK_STYLES, DEFAULT_OUTLINE_2_STYLES, DEFAULT_OUTLINE_STYLES, DEFAULT_PRIMARY_STYLES, NOTE_CLEAR_STYLES, + NOTE_INVERT_STYLES, NOTE_LINK_STYLES, NOTE_OUTLINE_2_STYLES, NOTE_OUTLINE_STYLES, NOTE_PRIMARY_STYLES, SPECIAL_CLEAR_STYLES, + SPECIAL_INVERT_STYLES, SPECIAL_LINK_STYLES, SPECIAL_OUTLINE_STYLES, SPECIAL_PRIMARY_STYLES, SUCCESS_CLEAR_STYLES, + SUCCESS_INVERT_STYLES, SUCCESS_LINK_STYLES, SUCCESS_OUTLINE_2_STYLES, SUCCESS_OUTLINE_STYLES, SUCCESS_PRIMARY_STYLES, WARNING_CLEAR_STYLES, + WARNING_INVERT_STYLES, WARNING_LINK_STYLES, WARNING_OUTLINE_2_STYLES, WARNING_OUTLINE_STYLES, @@ -104,6 +111,7 @@ export interface CubeButtonProps extends CubeActionProps { isSelected?: boolean; type?: | 'primary' + | 'invert' | 'danger' | 'link' | 'clear' @@ -150,36 +158,43 @@ export type ButtonVariant = // The `current` theme mixes every color from the inherited `currentcolor` // instead of a brand ramp — see the CURRENT THEME section of `item-themes`. | 'current.primary' + | 'current.invert' | 'current.outline' | 'current.outline-2' | 'current.clear' | 'current.link' | 'default.primary' + | 'default.invert' | 'default.outline' | 'default.outline-2' | 'default.clear' | 'default.link' | 'danger.primary' + | 'danger.invert' | 'danger.outline' | 'danger.outline-2' | 'danger.clear' | 'danger.link' | 'success.primary' + | 'success.invert' | 'success.outline' | 'success.outline-2' | 'success.clear' | 'success.link' | 'warning.primary' + | 'warning.invert' | 'warning.outline' | 'warning.outline-2' | 'warning.clear' | 'warning.link' | 'note.primary' + | 'note.invert' | 'note.outline' | 'note.outline-2' | 'note.clear' | 'note.link' | 'special.primary' + | 'special.invert' | 'special.outline' | 'special.clear' | 'special.link'; @@ -244,7 +259,7 @@ export const DEFAULT_BUTTON_STYLES: Styles = { }, margin: { '': 0, - '@parent(button-split, >) & !:first-child & (type=outline | type=outline-2 | type=primary)': + '@parent(button-split, >) & !:first-child & (type=outline | type=outline-2 | type=primary | type=invert)': '-1bw left', }, zIndex: { @@ -340,6 +355,7 @@ const ButtonElement = tasty({ variants: { // Current theme — colors mixed from the inherited `currentcolor` 'current.primary': CURRENT_PRIMARY_STYLES, + 'current.invert': CURRENT_INVERT_STYLES, 'current.outline': CURRENT_OUTLINE_STYLES, 'current.outline-2': CURRENT_OUTLINE_2_STYLES, 'current.clear': CURRENT_CLEAR_STYLES, @@ -347,6 +363,7 @@ const ButtonElement = tasty({ // Default theme 'default.primary': DEFAULT_PRIMARY_STYLES, + 'default.invert': DEFAULT_INVERT_STYLES, 'default.outline': DEFAULT_OUTLINE_STYLES, 'default.outline-2': DEFAULT_OUTLINE_2_STYLES, 'default.clear': DEFAULT_CLEAR_STYLES, @@ -354,6 +371,7 @@ const ButtonElement = tasty({ // Danger theme 'danger.primary': DANGER_PRIMARY_STYLES, + 'danger.invert': DANGER_INVERT_STYLES, 'danger.outline': DANGER_OUTLINE_STYLES, 'danger.outline-2': DANGER_OUTLINE_2_STYLES, 'danger.clear': DANGER_CLEAR_STYLES, @@ -361,6 +379,7 @@ const ButtonElement = tasty({ // Success theme 'success.primary': SUCCESS_PRIMARY_STYLES, + 'success.invert': SUCCESS_INVERT_STYLES, 'success.outline': SUCCESS_OUTLINE_STYLES, 'success.outline-2': SUCCESS_OUTLINE_2_STYLES, 'success.clear': SUCCESS_CLEAR_STYLES, @@ -368,6 +387,7 @@ const ButtonElement = tasty({ // Warning theme 'warning.primary': WARNING_PRIMARY_STYLES, + 'warning.invert': WARNING_INVERT_STYLES, 'warning.outline': WARNING_OUTLINE_STYLES, 'warning.outline-2': WARNING_OUTLINE_2_STYLES, 'warning.clear': WARNING_CLEAR_STYLES, @@ -375,6 +395,7 @@ const ButtonElement = tasty({ // Note theme 'note.primary': NOTE_PRIMARY_STYLES, + 'note.invert': NOTE_INVERT_STYLES, 'note.outline': NOTE_OUTLINE_STYLES, 'note.outline-2': NOTE_OUTLINE_2_STYLES, 'note.clear': NOTE_CLEAR_STYLES, @@ -382,6 +403,7 @@ const ButtonElement = tasty({ // Special theme 'special.primary': SPECIAL_PRIMARY_STYLES, + 'special.invert': SPECIAL_INVERT_STYLES, 'special.outline': SPECIAL_OUTLINE_STYLES, 'special.clear': SPECIAL_CLEAR_STYLES, 'special.link': SPECIAL_LINK_STYLES, diff --git a/src/components/actions/ItemAction/ItemAction.docs.mdx b/src/components/actions/ItemAction/ItemAction.docs.mdx index 8a31a983c..47cc8d917 100644 --- a/src/components/actions/ItemAction/ItemAction.docs.mdx +++ b/src/components/actions/ItemAction/ItemAction.docs.mdx @@ -22,7 +22,7 @@ A compact action button designed for use inside `Item`, `ItemButton`, and `ItemC ## Properties - **`icon`** `ReactNode | 'checkmark'` — Icon element or `'checkmark'` for a selection indicator -- **`type`** `'primary' | 'outline' | 'clear'` (default: `'clear'`) — Visual type. `clear` is borderless and paints nothing at rest, so a row full of actions does not read as busy. +- **`type`** `'primary' | 'invert' | 'outline' | 'clear'` (default: `'clear'`) — Visual type. `invert` is the filled type that follows the scheme: the theme's `accent-text` as the fill with a `#surface` label, rather than `primary`'s fixed `#white` on a brand surface. `clear` is borderless and paints nothing at rest, so a row full of actions does not read as busy. - **`theme`** `'current' | 'default' | 'danger' | 'success' | 'warning' | 'note' | 'special'` (default: `'current'`) — Color theme. `current` derives every color from the inherited `currentcolor`, so the action tracks whatever row hosts it without being told the row's type or theme. Name any other theme to have the action paint itself instead. - **`isLoading`** `boolean` (default: `false`) — Shows loading spinner - **`isSelected`** `boolean` (default: `false`) — Selected state (works with `icon="checkmark"`) diff --git a/src/components/actions/ItemAction/ItemAction.stories.tsx b/src/components/actions/ItemAction/ItemAction.stories.tsx index 2b598b61b..062210ff7 100644 --- a/src/components/actions/ItemAction/ItemAction.stories.tsx +++ b/src/components/actions/ItemAction/ItemAction.stories.tsx @@ -45,7 +45,7 @@ const meta: Meta = { /* Presentation */ type: { control: 'select', - options: ['primary', 'outline', 'clear'], + options: ['primary', 'invert', 'outline', 'clear'], description: 'Visual type/variant of the action button (inherits from context when inside ItemButton/Item)', table: { @@ -168,6 +168,29 @@ export const Types: Story = { /> + + Invert + + } + tooltip="Edit" + /> + } + tooltip="Copy" + /> + } + tooltip="Delete" + /> + + Outline (unselected) diff --git a/src/components/actions/ItemAction/ItemAction.tsx b/src/components/actions/ItemAction/ItemAction.tsx index c26d656b0..d00c5cf7b 100644 --- a/src/components/actions/ItemAction/ItemAction.tsx +++ b/src/components/actions/ItemAction/ItemAction.tsx @@ -11,25 +11,32 @@ import { import { CURRENT_CLEAR_STYLES, + CURRENT_INVERT_STYLES, CURRENT_OUTLINE_STYLES, CURRENT_PRIMARY_STYLES, DANGER_CLEAR_STYLES, + DANGER_INVERT_STYLES, DANGER_OUTLINE_STYLES, DANGER_PRIMARY_STYLES, DEFAULT_CLEAR_STYLES, + DEFAULT_INVERT_STYLES, DEFAULT_OUTLINE_STYLES, DEFAULT_PRIMARY_STYLES, ITEM_ACTION_BASE_STYLES, NOTE_CLEAR_STYLES, + NOTE_INVERT_STYLES, NOTE_OUTLINE_STYLES, NOTE_PRIMARY_STYLES, SPECIAL_CLEAR_STYLES, + SPECIAL_INVERT_STYLES, SPECIAL_OUTLINE_STYLES, SPECIAL_PRIMARY_STYLES, SUCCESS_CLEAR_STYLES, + SUCCESS_INVERT_STYLES, SUCCESS_OUTLINE_STYLES, SUCCESS_PRIMARY_STYLES, WARNING_CLEAR_STYLES, + WARNING_INVERT_STYLES, WARNING_OUTLINE_STYLES, WARNING_PRIMARY_STYLES, } from '../../../data/item-themes'; @@ -47,7 +54,7 @@ export interface CubeItemActionProps children?: ReactNode; isLoading?: boolean; isSelected?: boolean; - type?: 'primary' | 'outline' | 'clear' | (string & {}); + type?: 'primary' | 'invert' | 'outline' | 'clear' | (string & {}); theme?: | 'current' | 'default' @@ -69,24 +76,31 @@ export interface CubeItemActionProps type ItemActionVariant = // Inherited-color theme — see the CURRENT THEME section of `item-themes`. | 'current.primary' + | 'current.invert' | 'current.outline' | 'current.clear' | 'default.primary' + | 'default.invert' | 'default.outline' | 'default.clear' | 'danger.primary' + | 'danger.invert' | 'danger.outline' | 'danger.clear' | 'success.primary' + | 'success.invert' | 'success.outline' | 'success.clear' | 'warning.primary' + | 'warning.invert' | 'warning.outline' | 'warning.clear' | 'note.primary' + | 'note.invert' | 'note.outline' | 'note.clear' | 'special.primary' + | 'special.invert' | 'special.outline' | 'special.clear'; @@ -134,36 +148,43 @@ const ItemActionElement = tasty({ // default `clear` flavour is borderless, so an action does not put a resting // chip on every row. 'current.primary': CURRENT_PRIMARY_STYLES, + 'current.invert': CURRENT_INVERT_STYLES, 'current.outline': CURRENT_OUTLINE_STYLES, 'current.clear': CURRENT_CLEAR_STYLES, // Default theme 'default.primary': DEFAULT_PRIMARY_STYLES, + 'default.invert': DEFAULT_INVERT_STYLES, 'default.outline': DEFAULT_OUTLINE_STYLES, 'default.clear': DEFAULT_CLEAR_STYLES, // Danger theme 'danger.primary': DANGER_PRIMARY_STYLES, + 'danger.invert': DANGER_INVERT_STYLES, 'danger.outline': DANGER_OUTLINE_STYLES, 'danger.clear': DANGER_CLEAR_STYLES, // Success theme 'success.primary': SUCCESS_PRIMARY_STYLES, + 'success.invert': SUCCESS_INVERT_STYLES, 'success.outline': SUCCESS_OUTLINE_STYLES, 'success.clear': SUCCESS_CLEAR_STYLES, // Warning theme 'warning.primary': WARNING_PRIMARY_STYLES, + 'warning.invert': WARNING_INVERT_STYLES, 'warning.outline': WARNING_OUTLINE_STYLES, 'warning.clear': WARNING_CLEAR_STYLES, // Note theme 'note.primary': NOTE_PRIMARY_STYLES, + 'note.invert': NOTE_INVERT_STYLES, 'note.outline': NOTE_OUTLINE_STYLES, 'note.clear': NOTE_CLEAR_STYLES, // Special theme 'special.primary': SPECIAL_PRIMARY_STYLES, + 'special.invert': SPECIAL_INVERT_STYLES, 'special.outline': SPECIAL_OUTLINE_STYLES, 'special.clear': SPECIAL_CLEAR_STYLES, }, diff --git a/src/components/content/Item/Item.docs.mdx b/src/components/content/Item/Item.docs.mdx index 41bd3426d..7b8073fe4 100644 --- a/src/components/content/Item/Item.docs.mdx +++ b/src/components/content/Item/Item.docs.mdx @@ -30,7 +30,7 @@ A foundational component that provides a standardized layout and styling for ite - **`suffix`** `ReactNode` — Element rendered after the content (before rightIcon) - **`description`** `ReactNode` — Description text displayed with the item - **`descriptionPlacement`** `'inline' | 'block'` (default: `inline`) — How the description is positioned relative to the main content. Defaults to `'block'` for `type="card"` and `type="header"` -- **`type`** `'item' | 'header' | 'primary' | 'outline' | 'outline-2' | 'clear' | 'link' | 'card'` (default: `item`) — Visual style variant +- **`type`** `'item' | 'header' | 'primary' | 'invert' | 'outline' | 'outline-2' | 'clear' | 'link' | 'card'` (default: `item`) — Visual style variant - **`theme`** `'default' | 'danger' | 'success' | 'warning' | 'note' | 'special' | 'current'` (default: `default`) — Semantic colour palette theme. `current` is not a brand palette: it mixes every color from the inherited text color, so the item adopts the color of whatever container it sits in - **`size`** `'xsmall' | 'small' | 'medium' | 'large' | 'xlarge' | 'inline' | number` (default: `medium`) — Item size. Accepts custom number or string values - **`shape`** `'card' | 'button' | 'sharp' | 'pill'` (default: `button`) — Shape of the item border radius. Defaults to `'card'` for `type="card"` @@ -92,7 +92,7 @@ The `mods` property accepts the following modifiers: - **`disabled`** `boolean` — Applied when isDisabled is true or when loading - **`loading`** `boolean` — Applied when isLoading is true - **`size`** `string` — Applied based on size prop value (xsmall, small, medium, large, xlarge, inline) -- **`type`** `string` — Applied based on type prop value (item, header, primary, outline, outline-2, clear, link, card) +- **`type`** `string` — Applied based on type prop value (item, header, primary, invert, outline, outline-2, clear, link, card) - **`theme`** `string` — Applied based on theme prop value (default, danger, success, special, warning, note, current) - **`shape`** `string` — Applied based on shape prop value (card, button, sharp, pill) @@ -103,6 +103,7 @@ The `mods` property accepts the following modifiers: - `item` - Default item appearance (no specific styling) - `header` - Header appearance for section headers (only supports `default` theme, defaults to `descriptionPlacement="block"`, uses semantic heading tags via `level` prop) - `primary` - Primary styled item with prominent appearance +- `invert` - The page and its text swapped: the fill is the theme's `accent-text` and the label is `#surface`. Unlike `primary`, which pins a fixed `#white` label on a brand surface, `invert` follows the scheme — dark chip with a light label in light mode, light chip with a dark label in dark mode - `outline` - Item with border outline styling; `isSelected` adds brand-tinted fill - `outline-2` - Same as `outline` but uses `#surface-3` as the base fill, designed to sit on `#surface-2` containers without blending in (not available for the `special` theme) - `clear` - Transparent item with minimal styling; `isSelected` adds brand-tinted fill diff --git a/src/components/content/Item/Item.stories.tsx b/src/components/content/Item/Item.stories.tsx index d348f559a..d54ad6879 100644 --- a/src/components/content/Item/Item.stories.tsx +++ b/src/components/content/Item/Item.stories.tsx @@ -2371,6 +2371,7 @@ export const TypesAndThemes: StoryFn = (args) => { const standardTypes = [ 'item', 'primary', + 'invert', 'outline', 'outline-2', 'clear', @@ -2683,9 +2684,11 @@ CurrentTheme.parameters = { }, }; -// Every shape the `current` theme has a flavour for. `header` is the one type -// left out: it is theme-agnostic and folds onto `item` visuals on the default -// theme whatever theme it is given. +// Every shape the `current` theme has a distinct flavour for. Two types are left +// out: `header`, which is theme-agnostic and folds onto `item` visuals on the +// default theme whatever theme it is given, and `invert`, which on this theme is +// the same construction as `primary` and would only add a duplicate column — +// see `CURRENT_INVERT_STYLES`. const CURRENT_TYPES = [ 'item', 'primary', diff --git a/src/components/content/Item/Item.tsx b/src/components/content/Item/Item.tsx index ac582d200..530945d9c 100644 --- a/src/components/content/Item/Item.tsx +++ b/src/components/content/Item/Item.tsx @@ -138,6 +138,7 @@ export interface CubeItemProps extends BaseProps, ContainerStyleProps { | 'item' | 'header' | 'primary' + | 'invert' | 'outline' | 'outline-2' | 'clear' @@ -759,6 +760,25 @@ const Item = ( const finalPrefix = isLoading && resolvedLoadingSlot === 'prefix' ? : prefix; + // Which HotKeys flavour the shortcut hint wears, chosen by what the row's + // label is painted with. + // + // `primary` pins the hint to `#white`, which is right on a filled brand chip + // whose own label is `#white` too. `invert` labels itself `#surface`, so the + // white hint would land on the wrong side of the fill in dark mode — it takes + // `inherit` instead, which paints the hint from `currentcolor` and therefore + // tracks the label exactly. + // + // Except on the `current` theme, where `invert` and `primary` coincide and + // `currentcolor` IS the fill: there `inherit` would draw the hint's rim in the + // fill color and erase it, so that pairing stays on `primary`. + const hotkeysType = + type === 'invert' && theme !== 'current' + ? 'inherit' + : type === 'primary' || type === 'invert' + ? 'primary' + : 'default'; + // Build final suffix: loading icon, custom suffix, or HotKeys hint const finalSuffix = isLoading && resolvedLoadingSlot === 'suffix' ? ( @@ -768,7 +788,7 @@ const Item = ( (hotkeys ? ( {hotkeys} diff --git a/src/components/content/ItemBadge/ItemBadge.docs.mdx b/src/components/content/ItemBadge/ItemBadge.docs.mdx index 359552b27..89ca2c804 100644 --- a/src/components/content/ItemBadge/ItemBadge.docs.mdx +++ b/src/components/content/ItemBadge/ItemBadge.docs.mdx @@ -17,7 +17,7 @@ A non-interactive badge indicator for list items. Similar to `ItemAction` but wi ## Properties - **`icon`** `ReactNode | 'checkmark'` — Icon or checkmark display -- **`type`** `'primary' | 'outline' | 'clear'` (default: `'clear'`) — Visual type. `isSelected` applies brand-tinted fill to `outline` and `clear`. +- **`type`** `'primary' | 'invert' | 'outline' | 'clear'` (default: `'clear'`) — Visual type. `invert` is the filled type that follows the scheme: the theme's `accent-text` as the fill with a `#surface` label, rather than `primary`'s fixed `#white` on a brand surface. `isSelected` applies brand-tinted fill to `outline` and `clear`. - **`theme`** `'current' | 'default' | 'danger' | 'success' | 'special'` (default: `'current'`) — Color theme. `current` derives every color from the inherited `currentcolor`, so the badge tracks whatever row hosts it. Name any other theme to have the badge paint itself instead. - **`isLoading`** `boolean` (default: `false`) — Show loading spinner - **`isSelected`** `boolean` (default: `false`) — Selected state (for checkmark) diff --git a/src/components/content/ItemBadge/ItemBadge.stories.tsx b/src/components/content/ItemBadge/ItemBadge.stories.tsx index 0ec49830d..0c79d82fa 100644 --- a/src/components/content/ItemBadge/ItemBadge.stories.tsx +++ b/src/components/content/ItemBadge/ItemBadge.stories.tsx @@ -42,6 +42,12 @@ export const Types: Story = { type="primary" tooltip="Primary" /> + } + type="invert" + tooltip="Invert" + /> } diff --git a/src/components/content/ItemBadge/ItemBadge.tsx b/src/components/content/ItemBadge/ItemBadge.tsx index 469b59ab3..105a59c21 100644 --- a/src/components/content/ItemBadge/ItemBadge.tsx +++ b/src/components/content/ItemBadge/ItemBadge.tsx @@ -10,19 +10,24 @@ import { import { CURRENT_CLEAR_STYLES, + CURRENT_INVERT_STYLES, CURRENT_OUTLINE_STYLES, CURRENT_PRIMARY_STYLES, DANGER_CLEAR_STYLES, + DANGER_INVERT_STYLES, DANGER_OUTLINE_STYLES, DANGER_PRIMARY_STYLES, DEFAULT_CLEAR_STYLES, + DEFAULT_INVERT_STYLES, DEFAULT_OUTLINE_STYLES, DEFAULT_PRIMARY_STYLES, ITEM_ACTION_BASE_STYLES, SPECIAL_CLEAR_STYLES, + SPECIAL_INVERT_STYLES, SPECIAL_OUTLINE_STYLES, SPECIAL_PRIMARY_STYLES, SUCCESS_CLEAR_STYLES, + SUCCESS_INVERT_STYLES, SUCCESS_OUTLINE_STYLES, SUCCESS_PRIMARY_STYLES, } from '../../../data/item-themes'; @@ -37,7 +42,7 @@ export interface CubeItemBadgeProps extends BaseProps { children?: ReactNode; isLoading?: boolean; isSelected?: boolean; - type?: 'primary' | 'outline' | 'clear' | (string & {}); + type?: 'primary' | 'invert' | 'outline' | 'clear' | (string & {}); theme?: | 'current' | 'default' @@ -55,18 +60,23 @@ export interface CubeItemBadgeProps extends BaseProps { type ItemBadgeVariant = // Inherited-color theme — see the CURRENT THEME section of `item-themes`. | 'current.primary' + | 'current.invert' | 'current.outline' | 'current.clear' | 'default.primary' + | 'default.invert' | 'default.outline' | 'default.clear' | 'danger.primary' + | 'danger.invert' | 'danger.outline' | 'danger.clear' | 'success.primary' + | 'success.invert' | 'success.outline' | 'success.clear' | 'special.primary' + | 'special.invert' | 'special.outline' | 'special.clear'; @@ -76,7 +86,7 @@ const ItemBadgeElement = tasty({ cursor: 'default', border: { // extend - 'type=primary': '#clear', + 'type=primary | type=invert': '#clear', }, }), variants: { @@ -84,26 +94,31 @@ const ItemBadgeElement = tasty({ // default `clear` flavour is borderless, so a badge does not put a resting // chip on every row. 'current.primary': CURRENT_PRIMARY_STYLES, + 'current.invert': CURRENT_INVERT_STYLES, 'current.outline': CURRENT_OUTLINE_STYLES, 'current.clear': CURRENT_CLEAR_STYLES, // Default theme 'default.primary': DEFAULT_PRIMARY_STYLES, + 'default.invert': DEFAULT_INVERT_STYLES, 'default.outline': DEFAULT_OUTLINE_STYLES, 'default.clear': DEFAULT_CLEAR_STYLES, // Danger theme 'danger.primary': DANGER_PRIMARY_STYLES, + 'danger.invert': DANGER_INVERT_STYLES, 'danger.outline': DANGER_OUTLINE_STYLES, 'danger.clear': DANGER_CLEAR_STYLES, // Success theme 'success.primary': SUCCESS_PRIMARY_STYLES, + 'success.invert': SUCCESS_INVERT_STYLES, 'success.outline': SUCCESS_OUTLINE_STYLES, 'success.clear': SUCCESS_CLEAR_STYLES, // Special theme 'special.primary': SPECIAL_PRIMARY_STYLES, + 'special.invert': SPECIAL_INVERT_STYLES, 'special.outline': SPECIAL_OUTLINE_STYLES, 'special.clear': SPECIAL_CLEAR_STYLES, }, diff --git a/src/data/item-themes.ts b/src/data/item-themes.ts index d6f01381f..d2bf8ddb9 100644 --- a/src/data/item-themes.ts +++ b/src/data/item-themes.ts @@ -1337,6 +1337,100 @@ export const CURRENT_CARD_STYLES: Styles = { color: '#current', } as const; +// ---------- INVERT TYPE ---------- +// The page and its text, swapped. The fill is the theme's `accent-text` — the +// color that is normally PAINTED on the page — and the label is `#surface`, the +// page itself. That makes the control sit on the opposite side of the page in +// either scheme, which is what "invert" means and what separates it from +// `primary`: `primary` pins a fixed `#white` label on a brand SURFACE, so it +// reads the same weight in light and dark; `invert` follows the scheme. +// +// page fill (accent-text) label (#surface) +// light L 1.00 L 0.47 (dark) L 1.00 (white) +// dark L 0.24 L 0.76 (light) L 0.24 (near-black) +// +// The label is `#surface` and NOT `#surface-text`, which is the trap this pairing +// invites: `surface-text` is the color painted ON the page, so it sits on the +// SAME side of the fill as `accent-text` does and the two collapse — measured +// cr 2.60 in light and 1.91 in dark, against 6.96 / 7.52 for `#surface`. +// +// 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. A `#black` overlay darkens in both schemes, so the monotonic +// default → hover → pressed direction survives the scheme flip. Every entry +// keeps the same two-layer shape so the overlay interpolates instead of snapping +// — see `DEFAULT_PRIMARY_STYLES.fill`. +// +// Disabled hands over to the brand-tinted pair `primary` already uses, so the +// two filled types mute identically and the alphas stay calibrated in one place. +// +// Written through a factory rather than seven near-identical objects on purpose. +// The only thing that varies is the theme prefix, and hand-copying that is +// exactly how `selected & disabled` ended up on the wrong token for four themes +// earlier in this PR. +const invertStyles = (accent: string): Styles => ({ + outline: { + '': '0 #primary-accent-text.0', + focused: '1bw #primary-accent-text', + }, + // No lighter sibling of `accent-text` exists to rim with, so the rim is the + // label token at low alpha — guaranteed to sit opposite the fill in either + // scheme. cr ~1.8 in light and ~1.6 in dark against the fill, against the 1.48 + // that `accent-surface-border` measures on `primary`. + border: { + '': '#surface.25', + disabled: 'transparent', + }, + fill: { + '': `#${accent}-accent-text #black.0`, + hovered: `#${accent}-accent-text #black.08`, + pressed: `#${accent}-accent-text #black.16`, + disabled: `#surface #${accent}-accent-disabled-surface`, + }, + color: { + '': '#surface', + disabled: `#${accent}-accent-disabled-surface-text`, + }, +}); + +export const DEFAULT_INVERT_STYLES: Styles = invertStyles('primary'); +export const DANGER_INVERT_STYLES: Styles = invertStyles('danger'); +export const SUCCESS_INVERT_STYLES: Styles = invertStyles('success'); +export const WARNING_INVERT_STYLES: Styles = invertStyles('warning'); +export const NOTE_INVERT_STYLES: Styles = invertStyles('note'); + +// The special theme inverts against its OWN surface rather than the page: that +// surface is a fixed dark purple, so the inverted control is a white pill with +// the theme's dark accent on it — the same figure `SPECIAL_CLEAR_STYLES` strikes +// when selected. Both tokens are fixed-mode, so this stays scheme-invariant like +// the rest of the theme, where a `#surface` label would not. +export const SPECIAL_INVERT_STYLES: Styles = { + outline: { + '': '0 #special-accent-text.0', + focused: '1bw #special-accent-text', + }, + border: { + '': '#white.25', + disabled: 'transparent', + }, + fill: { + '': '#white #black.0', + hovered: '#white #black.08', + pressed: '#white #black.16', + disabled: '#special-surface #special-accent-disabled-surface', + }, + color: { + '': '#special-accent-text', + disabled: '#special-accent-disabled-surface-text', + }, +} as const; + +// On the `current` theme `invert` and `primary` coincide, and necessarily so: +// there is exactly one color in play, so "the emphatic filled type" and "the +// page and its text swapped" are the same construction. Aliased rather than +// copied so they cannot drift. +export const CURRENT_INVERT_STYLES: Styles = CURRENT_PRIMARY_STYLES; + // ---------- CARD TYPE STYLES ---------- // Card type only supports: default, success, danger, note themes (plus the // `current` theme — see `CURRENT_CARD_STYLES` above) @@ -1375,12 +1469,14 @@ export type ItemVariant = // Inherited-color theme — every flavour mixes its colors from `currentcolor` // instead of a brand ramp. See the CURRENT THEME section. | 'current.item' + | 'current.invert' | 'current.primary' | 'current.outline' | 'current.outline-2' | 'current.clear' | 'current.link' | 'current.card' + | 'default.invert' | 'default.primary' | 'default.outline' | 'default.outline-2' @@ -1388,6 +1484,7 @@ export type ItemVariant = | 'default.link' | 'default.item' | 'default.card' + | 'danger.invert' | 'danger.primary' | 'danger.outline' | 'danger.outline-2' @@ -1395,6 +1492,7 @@ export type ItemVariant = | 'danger.link' | 'danger.item' | 'danger.card' + | 'success.invert' | 'success.primary' | 'success.outline' | 'success.outline-2' @@ -1402,6 +1500,7 @@ export type ItemVariant = | 'success.link' | 'success.item' | 'success.card' + | 'warning.invert' | 'warning.primary' | 'warning.outline' | 'warning.outline-2' @@ -1409,6 +1508,7 @@ export type ItemVariant = | 'warning.link' | 'warning.item' | 'warning.card' + | 'note.invert' | 'note.primary' | 'note.outline' | 'note.outline-2' @@ -1416,6 +1516,7 @@ export type ItemVariant = | 'note.link' | 'note.item' | 'note.card' + | 'special.invert' | 'special.primary' | 'special.outline' | 'special.clear' @@ -1429,6 +1530,7 @@ export const ITEM_VARIANTS: Record = { // Current theme — colors mixed from the inherited `currentcolor` 'current.item': CURRENT_ITEM_STYLES, 'current.primary': CURRENT_PRIMARY_STYLES, + 'current.invert': CURRENT_INVERT_STYLES, 'current.outline': CURRENT_OUTLINE_STYLES, 'current.outline-2': CURRENT_OUTLINE_2_STYLES, 'current.clear': CURRENT_CLEAR_STYLES, @@ -1436,6 +1538,7 @@ export const ITEM_VARIANTS: Record = { 'current.card': CURRENT_CARD_STYLES, // Default theme 'default.primary': DEFAULT_PRIMARY_STYLES, + 'default.invert': DEFAULT_INVERT_STYLES, 'default.outline': DEFAULT_OUTLINE_STYLES, 'default.outline-2': DEFAULT_OUTLINE_2_STYLES, 'default.clear': DEFAULT_CLEAR_STYLES, @@ -1444,6 +1547,7 @@ export const ITEM_VARIANTS: Record = { 'default.card': DEFAULT_CARD_STYLES, // Danger theme 'danger.primary': DANGER_PRIMARY_STYLES, + 'danger.invert': DANGER_INVERT_STYLES, 'danger.outline': DANGER_OUTLINE_STYLES, 'danger.outline-2': DANGER_OUTLINE_2_STYLES, 'danger.clear': DANGER_CLEAR_STYLES, @@ -1452,6 +1556,7 @@ export const ITEM_VARIANTS: Record = { 'danger.card': DANGER_CARD_STYLES, // Success theme 'success.primary': SUCCESS_PRIMARY_STYLES, + 'success.invert': SUCCESS_INVERT_STYLES, 'success.outline': SUCCESS_OUTLINE_STYLES, 'success.outline-2': SUCCESS_OUTLINE_2_STYLES, 'success.clear': SUCCESS_CLEAR_STYLES, @@ -1460,6 +1565,7 @@ export const ITEM_VARIANTS: Record = { 'success.card': SUCCESS_CARD_STYLES, // Warning theme 'warning.primary': WARNING_PRIMARY_STYLES, + 'warning.invert': WARNING_INVERT_STYLES, 'warning.outline': WARNING_OUTLINE_STYLES, 'warning.outline-2': WARNING_OUTLINE_2_STYLES, 'warning.clear': WARNING_CLEAR_STYLES, @@ -1468,6 +1574,7 @@ export const ITEM_VARIANTS: Record = { 'warning.card': WARNING_CARD_STYLES, // Note theme 'note.primary': NOTE_PRIMARY_STYLES, + 'note.invert': NOTE_INVERT_STYLES, 'note.outline': NOTE_OUTLINE_STYLES, 'note.outline-2': NOTE_OUTLINE_2_STYLES, 'note.clear': NOTE_CLEAR_STYLES, @@ -1476,6 +1583,7 @@ export const ITEM_VARIANTS: Record = { 'note.card': NOTE_CARD_STYLES, // Special theme 'special.primary': SPECIAL_PRIMARY_STYLES, + 'special.invert': SPECIAL_INVERT_STYLES, 'special.outline': SPECIAL_OUTLINE_STYLES, 'special.clear': SPECIAL_CLEAR_STYLES, 'special.link': SPECIAL_LINK_STYLES, From 9125615024b39caab406164c3b033db7ef5091bd Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 19 Aug 2026 19:02:15 +0000 Subject: [PATCH 15/26] fix(themes): fade a disabled `current` label exactly once, pin the invert fill base MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Two review findings, both about a value that was right at rest and wrong one level down. **Single fade per subtree.** `#current` is the color an element INHERITS, so a `.4` applied twice down one chain multiplies to `.16`. `CURRENT_ITEM_STYLES` and `CURRENT_CLEAR_STYLES` already guarded against that with `disabled & !inherit-disabled`, but `outline`, `outline-2`, `primary` and `link` stated a bare `disabled`. Two of those are reachable `Item.Action` types, so an action inside a disabled row — Banner's outline actions among them — faded on top of a row that had already faded, washing out the label and the alpha chip with it. Probed: the action carried `data-inherit-disabled` and still matched a plain `[data-disabled]` fade rule. Every `current` flavour now gates on `disabled & !inherit-disabled & !inside-wrapper`. The second mod closes the other half of the same hole, in the other direction: `ItemButton` renders actions as SIBLINGS of the row inside `ActionsWrapper`, and the wrapper reproduces the row's disabled color so those siblings inherit a faded `currentcolor`. It could not, because `ITEM_RESTING_COLOR_VARIANTS` read only a plain `disabled` key and the gated flavours have none — so a disabled `ItemButton` on this theme sat next to full-strength actions, which is precisely the failure that derivation's own comment describes. The wrapper now reads the gated value (it is entitled to it: `ItemButton` hands it neither mod), and the row suppresses its own fade under `inside-wrapper` because the wrapper above it already applied one. Probed all four paths: standalone `Item` and `Button` fade once, an `ItemAction` in a disabled row does not fade at all, and under `ItemButton` the wrapper fades while both the row and its actions inherit it. A new `item-themes.test.ts` block pins the gate on every interactive `current` flavour and pins the wrapper's side of it; reintroducing the bare `disabled` on one flavour fails it. **Fill base.** `invert` swapped its fill base on `disabled` — `accent-text` to `#surface`, and `#white` to `#special-surface` on the special theme — while keeping the two-layer shape. The disabled chip is an opaque glaze tone (probed: `oklch(0.8514 0.0364 280.3)`, no alpha), so nothing changed at rest, but `background-color` animated underneath a still-transparent overlay: the surface flash `DEFAULT_PRIMARY_STYLES` pins its own base to avoid. The base is now constant across all four states, and `special.invert` holds `#white` throughout exactly as `special.primary` does. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01BU1Vwm6cGgGRcMubszgbGD --- .changeset/current-single-fade.md | 11 ++++ src/data/item-themes.test.ts | 58 ++++++++++++++++++++- src/data/item-themes.ts | 85 ++++++++++++++++++++++--------- 3 files changed, 130 insertions(+), 24 deletions(-) create mode 100644 .changeset/current-single-fade.md diff --git a/.changeset/current-single-fade.md b/.changeset/current-single-fade.md new file mode 100644 index 000000000..95dff95f6 --- /dev/null +++ b/.changeset/current-single-fade.md @@ -0,0 +1,11 @@ +--- +'@cube-dev/ui-kit': patch +--- + +Fix the `current` theme fading a disabled label twice, and pin the `invert` fill base across states. + +**Single fade per subtree.** `current.item` and `current.clear` already suppressed their own `.4` label fade when the disabled state was inherited from a host that had faded `currentcolor` already, but `current.outline`, `current.outline-2`, `current.primary` and `current.link` stated it as a bare `disabled`. Two of those — `outline` and `primary` — are reachable `Item.Action` types, so an action inside a disabled row (including `Banner`'s outline actions) multiplied the two fades and rendered at `.16` of the row's color, washing out both the label and the alpha chip. + +Every `current` flavour now gates the fade on `disabled & !inherit-disabled & !inside-wrapper`. The second mod closes the other half of the same hole: `ItemButton` renders its actions as siblings of the row inside a wrapper, and the wrapper reproduces the row's disabled color so those siblings inherit a faded `currentcolor`. It previously could not, because the gated key was skipped when deriving the wrapper's colors — so a disabled `ItemButton` on the `current` theme sat next to full-strength actions. The wrapper now reads the gated value, and the row suppresses its own fade under `inside-wrapper`, leaving exactly one `.4` on every path. + +**Fill base.** `invert` swapped its fill's base layer on `disabled` (`accent-text` → `#surface`, and `#white` → `#special-surface` on the special theme) while keeping the same two-layer shape. The disabled chip is an opaque glaze tone so nothing changed at rest, but the base animated underneath a still-transparent overlay during the transition — the surface flash `primary` pins its own base to avoid. The base is now constant across all four states. diff --git a/src/data/item-themes.test.ts b/src/data/item-themes.test.ts index b6d922312..03f738c77 100644 --- a/src/data/item-themes.test.ts +++ b/src/data/item-themes.test.ts @@ -1,4 +1,4 @@ -import { ITEM_VARIANTS } from './item-themes'; +import { ITEM_RESTING_COLOR_VARIANTS, ITEM_VARIANTS } from './item-themes'; /** * `selected & disabled` on the non-solid types keeps the ENABLED selected chip @@ -107,3 +107,59 @@ describe('ITEM_VARIANTS', () => { expect(color['selected & disabled']).not.toBe(color.selected); }); }); + +/** + * Every `current` flavour fades its label exactly once per subtree. + * + * `#current` is the color the element INHERITS, so a `.4` applied twice down one + * chain multiplies to `.16` and the label washes out. Two mods mark "something + * above already faded this" — `inherit-disabled` (set by `ItemAction` inside a + * disabled row) and `inside-wrapper` (set by `ItemButton` on the row it renders + * inside `ActionsWrapper`) — and a flavour that spells its fade as a bare + * `disabled` fades a second time in both of those nestings. That is the shape + * this pins: `current.outline` and `current.primary` are reachable as + * `ItemAction` types, so a bare `disabled` there is a live bug, not a latent one. + * + * The wrapper is the other half. It reproduces the row's disabled color so + * sibling actions inherit a faded `currentcolor`, and it is entitled to the + * gated value because `ItemButton` gives it neither mod. + */ +describe('current theme disabled fades', () => { + const CURRENT_VARIANTS = Object.keys(ITEM_VARIANTS).filter( + (variant) => variant.startsWith('current.') && variant !== 'current.card', + ) as (keyof typeof ITEM_VARIANTS)[]; + + const GATE = 'disabled & !inherit-disabled & !inside-wrapper'; + + it('covers every interactive current flavour', () => { + expect(CURRENT_VARIANTS.sort()).toEqual([ + 'current.clear', + 'current.invert', + 'current.item', + 'current.link', + 'current.outline', + 'current.outline-2', + 'current.primary', + ]); + }); + + it.each(CURRENT_VARIANTS)('%s gates its label fade', (variant) => { + const color = ITEM_VARIANTS[variant].color as Record; + + expect(color[GATE]).toBe('#current.4'); + // A bare `disabled` key would win over the gate and fade unconditionally. + expect(color.disabled).toBeUndefined(); + }); + + it.each(CURRENT_VARIANTS)( + '%s still hands a disabled color to the actions wrapper', + (variant) => { + const color = ITEM_RESTING_COLOR_VARIANTS[variant].color as Record< + string, + string + >; + + expect(color.disabled).toBe('#current.4'); + }, + ); +}); diff --git a/src/data/item-themes.ts b/src/data/item-themes.ts index d2bf8ddb9..08f154900 100644 --- a/src/data/item-themes.ts +++ b/src/data/item-themes.ts @@ -1117,17 +1117,32 @@ export const CURRENT_ITEM_STYLES: Styles = { }, color: { '': '#current', - // Only fade when this element is disabled ON ITS OWN. `#current` is the - // color it inherits, and a disabled host has already faded that color to - // `#disabled-surface-text` — so fading again multiplies the two and the label - // washes out (an action inside a disabled row measured `.4` of an already - // muted token, roughly `rgb(224,225,228)` on white, against the row's own - // `rgb(178,181,205)`). Inheriting the host's faded color unchanged is both - // correct and what the neutral types did. `ItemAction` sets - // `inherit-disabled` when its disabled state came from the surrounding - // `ItemActionProvider` rather than its own prop; nothing else sets the mod, so - // `Item` keeps fading itself as before. - 'disabled & !inherit-disabled': '#current.4', + // Fade exactly once per subtree, and only where nothing above has faded + // already. `#current` is the color this element INHERITS, so a second `.4` + // multiplies against the first and the label washes out — an action inside a + // disabled row measured `.4` of an already muted token, roughly + // `rgb(224,225,228)` on white, against the row's own `rgb(178,181,205)`. + // + // Two mods say "someone above already did it", and both are set by exactly + // one caller: + // + // `inherit-disabled` `ItemAction`, when its disabled state came from the + // surrounding `ItemActionProvider` rather than its own + // prop — the host row already faded the color it paints + // from. + // `inside-wrapper` `ItemButton`, on the row it renders inside + // `ActionsWrapper`. The wrapper reproduces this same + // disabled color (see `ITEM_RESTING_COLOR_VARIANTS`) so + // that actions rendered as SIBLINGS of the row inherit + // a faded `currentcolor` too; the row is a descendant + // of that wrapper, so it is already faded when it + // arrives here. + // + // Neither mod is set on a standalone `Item` or on `Button`, so both keep + // fading themselves exactly as before. Every `current` flavour states the + // gate identically — a flavour that spelled it `disabled` alone would fade a + // second time in both of those nestings. + 'disabled & !inherit-disabled & !inside-wrapper': '#current.4', }, } as const; @@ -1188,7 +1203,9 @@ export const CURRENT_OUTLINE_STYLES: Styles = { }, color: { '': '#current', - disabled: '#current.4', + // See `CURRENT_ITEM_STYLES.color` for why this is gated rather than a bare + // `disabled`: both mods mark a color that something above already faded. + 'disabled & !inherit-disabled & !inside-wrapper': '#current.4', }, } as const; @@ -1225,7 +1242,9 @@ export const CURRENT_OUTLINE_2_STYLES: Styles = { }, color: { '': '#current', - disabled: '#current.4', + // See `CURRENT_ITEM_STYLES.color` for why this is gated rather than a bare + // `disabled`: both mods mark a color that something above already faded. + 'disabled & !inherit-disabled & !inside-wrapper': '#current.4', }, } as const; @@ -1289,7 +1308,9 @@ export const CURRENT_PRIMARY_STYLES: Styles = { // hand it a live color next to a dead chip. color: { '': '#current', - disabled: '#current.4', + // See `CURRENT_ITEM_STYLES.color` for why this is gated rather than a bare + // `disabled`: both mods mark a color that something above already faded. + 'disabled & !inherit-disabled & !inside-wrapper': '#current.4', }, '-webkit-text-fill-color': { '': '#surface', @@ -1324,7 +1345,8 @@ export const CURRENT_LINK_STYLES: Styles = { color: { '': '#current.8', 'hovered & !pressed': '#current', - disabled: '#current.4', + // See `CURRENT_ITEM_STYLES.color`. + 'disabled & !inherit-disabled & !inside-wrapper': '#current.4', }, } as const; @@ -1363,6 +1385,13 @@ export const CURRENT_CARD_STYLES: Styles = { // // Disabled hands over to the brand-tinted pair `primary` already uses, so the // two filled types mute identically and the alphas stay calibrated in one place. +// It rides on THIS type's base layer (`accent-text`) rather than `primary`'s +// (`#surface`), which changes nothing at rest — `accent-disabled-surface` is an +// opaque glaze tone, so it hides whatever is under it — but keeps the base +// constant across all four states. Swapping the base on `disabled` alone would +// animate `background-color` from the fill color to the page while the overlay +// is still part-transparent, which is the surface flash `DEFAULT_PRIMARY_STYLES` +// keeps its own base pinned to avoid. // // Written through a factory rather than seven near-identical objects on purpose. // The only thing that varies is the theme prefix, and hand-copying that is @@ -1385,7 +1414,7 @@ const invertStyles = (accent: string): Styles => ({ '': `#${accent}-accent-text #black.0`, hovered: `#${accent}-accent-text #black.08`, pressed: `#${accent}-accent-text #black.16`, - disabled: `#surface #${accent}-accent-disabled-surface`, + disabled: `#${accent}-accent-text #${accent}-accent-disabled-surface`, }, color: { '': '#surface', @@ -1404,6 +1433,10 @@ export const NOTE_INVERT_STYLES: Styles = invertStyles('note'); // the theme's dark accent on it — the same figure `SPECIAL_CLEAR_STYLES` strikes // when selected. Both tokens are fixed-mode, so this stays scheme-invariant like // the rest of the theme, where a `#surface` label would not. +// +// The fill base is `#white` in every state, disabled included — the same pin +// `SPECIAL_PRIMARY_STYLES` holds, and for the same reason. See the note on the +// factory above. export const SPECIAL_INVERT_STYLES: Styles = { outline: { '': '0 #special-accent-text.0', @@ -1417,7 +1450,7 @@ export const SPECIAL_INVERT_STYLES: Styles = { '': '#white #black.0', hovered: '#white #black.08', pressed: '#white #black.16', - disabled: '#special-surface #special-accent-disabled-surface', + disabled: '#white #special-accent-disabled-surface', }, color: { '': '#special-accent-text', @@ -1648,12 +1681,18 @@ export const ITEM_RESTING_COLOR_VARIANTS: Record = } const map = color as Record; - // Only a plain `disabled` key is usable. The borderless `current` - // flavours (`current.item`, `current.clear`) state it as - // `disabled & !inherit-disabled`, which is deliberately not matched here: - // those variants paint from `currentcolor` and have no fixed color to hand - // down, so the wrapper leaves its resting value in place. - const disabled = map.disabled; + // The `current` flavours state their fade as + // `disabled & !inherit-disabled & !inside-wrapper` rather than a bare + // `disabled`, and the wrapper wants exactly that value under a plain + // `disabled`. It is entitled to it: the two negated mods mark "something + // above already faded this", and the wrapper is the top of the subtree — + // `ItemButton` gives it only `disabled`, never either mod. Reading just + // `map.disabled` left those variants with no disabled color to hand down, + // which is the failure the comment above describes: the row faded itself + // and its sibling actions, which suppress their own fade under + // `inherit-disabled`, stayed at full strength beside it. + const disabled = + map.disabled ?? map['disabled & !inherit-disabled & !inside-wrapper']; return [ variant, From e59cc2b93bc1417e3e6b293752aa9a18971ac72a Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 19 Aug 2026 19:17:01 +0000 Subject: [PATCH 16/26] fix(themes): make `current.invert` invert `primary` instead of aliasing it MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `current` is the one theme with no `accent-text` to fill with — it has exactly one color, the one it inherits. I read that as "the two filled types necessarily coincide" and aliased `CURRENT_INVERT_STYLES` to `CURRENT_PRIMARY_STYLES`, which threw the type away on this theme. The inversion is the point: with one color and the page, there are two arrangements, not one. fill label primary #current #surface — paint the color, punch the page out invert #surface #current — paint the page, write the color on it That is the same swap `special` makes between its own filled types (a brand surface under `#white`, against a white pill under the dark accent), so the pairing now reads the same way on every theme even where the tokens differ. The swap also removes 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` to leave `color` free, and every icon slot recolored by hand because SVG stroked with `currentColor` never sees that property. Here the fill is `#surface`, an absolute token that never consults `color`, so `color` is simply the label and icons inherit it. Two deliberate uses of the pre-multiply behaviour the rest of the section warns about: the `#current.25` rim needs no disabled entry, because it resolves against this element's `color` and so fades with the label; and the hover/pressed overlay tints toward the inherited color rather than laying `#black` over it, which 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. Disabled drops the fill to `#surface.5` so a dead control stops reading as a solid chip — same token, alpha only, so it still interpolates. `CurrentStates` gains the column it lost while the two were identical, and the story text now reads the pair side by side, which is the clearest place to see the swap. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01BU1Vwm6cGgGRcMubszgbGD --- .changeset/invert-type.md | 4 +- src/components/actions/Button/Button.docs.mdx | 2 +- .../actions/Button/Button.stories.tsx | 2 +- src/components/content/Item/Item.docs.mdx | 2 +- src/components/content/Item/Item.stories.tsx | 15 +++--- src/data/item-themes.ts | 54 +++++++++++++++++-- 6 files changed, 64 insertions(+), 15 deletions(-) diff --git a/.changeset/invert-type.md b/.changeset/invert-type.md index 49150ee01..ba0f72210 100644 --- a/.changeset/invert-type.md +++ b/.changeset/invert-type.md @@ -8,4 +8,6 @@ Add an `invert` type to `Button`, `Item`, `Item.Action` and `Item.Badge`, availa Measured contrast between fill and label is 6.96:1 in light and 7.52:1 in dark. 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. Disabled reuses the brand-tinted pair `primary` already mutes to, so the two filled types stay calibrated together. -`special.invert` inverts against its own fixed dark surface instead of the page, giving a white pill with the theme's dark accent on it. On the `current` theme `invert` and `primary` coincide — there is only one color in play, so "the emphatic filled type" and "the page and its text swapped" are the same construction — and `current.invert` is an alias of `current.primary` rather than a copy. +`special.invert` inverts against its own fixed dark surface instead of the page, giving a white pill with the theme's dark accent on it. + +The `current` theme is the one special case, because it has no `accent-text` to fill with — it has exactly one color, the one it inherits. `current.invert` is therefore `current.primary` with its two colors swapped: `primary` fills with `#current` and punches `#surface` out of it, `invert` fills with `#surface` and writes `#current` on top. That is the same swap `special` makes between its own two filled types, so the pairing reads the same way on every theme even though the tokens differ. The swap also drops the machinery `primary` needs there — with an absolute `#surface` fill, `color` is free to be the label, so no `-webkit-text-fill-color` and no hand-recolored icon slots. diff --git a/src/components/actions/Button/Button.docs.mdx b/src/components/actions/Button/Button.docs.mdx index 5e02fc828..4a1a5371a 100644 --- a/src/components/actions/Button/Button.docs.mdx +++ b/src/components/actions/Button/Button.docs.mdx @@ -88,7 +88,7 @@ The `mods` prop accepts the following modifiers you can override: ### Types. `type` prop - `primary` – Emphasised call-to-action. -- `invert` – The page and its text swapped: the fill is the theme's `accent-text` — the color normally painted *on* the page — and the label is `#surface`, the page itself. Unlike `primary`, which pins a fixed `#white` label on a brand surface and so reads with the same weight in either scheme, `invert` follows the scheme: dark chip with a light label in light mode, light chip with a dark label in dark mode. Use it where a `primary` chip is not emphatic enough — a single hero action, or a filled control that has to out-read a brand chip sitting next to it. +- `invert` – The page and its text swapped: the fill is the theme's `accent-text` — the color normally painted *on* the page — and the label is `#surface`, the page itself. Unlike `primary`, which pins a fixed `#white` label on a brand surface and so reads with the same weight in either scheme, `invert` follows the scheme: dark chip with a light label in light mode, light chip with a dark label in dark mode. Use it where a `primary` chip is not emphatic enough — a single hero action, or a filled control that has to out-read a brand chip sitting next to it. On the `current` theme there is no `accent-text` to fill with, so `invert` is `primary` with its two colors swapped: `primary` fills with the inherited color and punches `#surface` out of it, `invert` fills with `#surface` and writes the inherited color on top. - `outline` – Border with transparent background; `isSelected` adds brand-tinted fill. - `clear` – No border, transparent background; `isSelected` adds brand-tinted fill. - `link` – Styled as a textual link. diff --git a/src/components/actions/Button/Button.stories.tsx b/src/components/actions/Button/Button.stories.tsx index ae9536c47..d0ea406a3 100644 --- a/src/components/actions/Button/Button.stories.tsx +++ b/src/components/actions/Button/Button.stories.tsx @@ -396,7 +396,7 @@ CurrentStates.parameters = { docs: { description: { story: - 'Every type, every state, on the `current` theme. Nothing here names a color: the block is painted `#note-surface` / `#note-accent-text` and each button mixes its fill, border and label from that inherited text color. `primary` inverts to an opaque fill of the inherited color with the label punched out of it, and `outline-2` sits in a `#current.08` panel, the `current` stand-in for the `#surface-2` container `outline-2` is drawn for. Swap the block color and the whole sweep follows it; `CurrentTheme` below does exactly that across seven containers.', + 'Every type, every state, on the `current` theme. Nothing here names a color: the block is painted `#note-surface` / `#note-accent-text` and each button mixes its fill, border and label from that inherited text color. `primary` and `invert` are the same two colors in opposite roles — `primary` fills with the inherited color and punches the page (`#surface`) out of it, `invert` fills with the page and writes the inherited color on top — which is how `invert` is built on this theme, there being no `accent-text` to fill with. `outline-2` sits in a `#current.08` panel, the `current` stand-in for the `#surface-2` container `outline-2` is drawn for. Swap the block color and the whole sweep follows it; `CurrentTheme` below does exactly that across seven containers.', }, }, }; diff --git a/src/components/content/Item/Item.docs.mdx b/src/components/content/Item/Item.docs.mdx index 7b8073fe4..68eccf6a0 100644 --- a/src/components/content/Item/Item.docs.mdx +++ b/src/components/content/Item/Item.docs.mdx @@ -103,7 +103,7 @@ The `mods` property accepts the following modifiers: - `item` - Default item appearance (no specific styling) - `header` - Header appearance for section headers (only supports `default` theme, defaults to `descriptionPlacement="block"`, uses semantic heading tags via `level` prop) - `primary` - Primary styled item with prominent appearance -- `invert` - The page and its text swapped: the fill is the theme's `accent-text` and the label is `#surface`. Unlike `primary`, which pins a fixed `#white` label on a brand surface, `invert` follows the scheme — dark chip with a light label in light mode, light chip with a dark label in dark mode +- `invert` - The page and its text swapped: the fill is the theme's `accent-text` and the label is `#surface`. Unlike `primary`, which pins a fixed `#white` label on a brand surface, `invert` follows the scheme — dark chip with a light label in light mode, light chip with a dark label in dark mode. On the `current` theme, which has no `accent-text`, it is `primary` with its two colors swapped: a `#surface` fill with the inherited color written on it - `outline` - Item with border outline styling; `isSelected` adds brand-tinted fill - `outline-2` - Same as `outline` but uses `#surface-3` as the base fill, designed to sit on `#surface-2` containers without blending in (not available for the `special` theme) - `clear` - Transparent item with minimal styling; `isSelected` adds brand-tinted fill diff --git a/src/components/content/Item/Item.stories.tsx b/src/components/content/Item/Item.stories.tsx index d54ad6879..d96232bdd 100644 --- a/src/components/content/Item/Item.stories.tsx +++ b/src/components/content/Item/Item.stories.tsx @@ -2684,14 +2684,17 @@ CurrentTheme.parameters = { }, }; -// Every shape the `current` theme has a distinct flavour for. Two types are left -// out: `header`, which is theme-agnostic and folds onto `item` visuals on the -// default theme whatever theme it is given, and `invert`, which on this theme is -// the same construction as `primary` and would only add a duplicate column — -// see `CURRENT_INVERT_STYLES`. +// Every shape the `current` theme has a flavour for. `header` is the one type +// left out: it is theme-agnostic and folds onto `item` visuals on the default +// theme whatever theme it is given. +// +// `primary` and `invert` are worth reading side by side here — they are the same +// two colors in opposite roles, and this is the only theme where that pairing is +// built by swapping rather than from separate tokens. const CURRENT_TYPES = [ 'item', 'primary', + 'invert', 'outline', 'outline-2', 'clear', @@ -2760,7 +2763,7 @@ CurrentStates.parameters = { docs: { description: { story: - 'Every type, every state, on the `current` theme — the matrix the other themes get from `TypesAndThemes`, run on the one theme whose colors are not its own. Nothing in the grid names a color: each block paints a text color and every item mixes its fill, border and label from it. `primary` inverts to an opaque fill of the inherited color with the label punched out of it, and `outline-2` sits in a `#current.08` panel, the `current` stand-in for the `#surface-2` container it is drawn for. `link` carries no chip at all: it goes soft (`#current.8`) at rest and full strength on hover.', + 'Every type, every state, on the `current` theme — the matrix the other themes get from `TypesAndThemes`, run on the one theme whose colors are not its own. Nothing in the grid names a color: each block paints a text color and every item mixes its fill, border and label from it. `primary` and `invert` are the same two colors in opposite roles — `primary` fills with the inherited color and punches the page (`#surface`) out of it, `invert` fills with the page and writes the inherited color on top — which on this theme is how `invert` is built, since there is no `accent-text` to fill with. `outline-2` sits in a `#current.08` panel, the `current` stand-in for the `#surface-2` container it is drawn for. `link` carries no chip at all: it goes soft (`#current.8`) at rest and full strength on hover.', }, }, }; diff --git a/src/data/item-themes.ts b/src/data/item-themes.ts index 08f154900..c086ecad4 100644 --- a/src/data/item-themes.ts +++ b/src/data/item-themes.ts @@ -1458,11 +1458,55 @@ export const SPECIAL_INVERT_STYLES: Styles = { }, } as const; -// On the `current` theme `invert` and `primary` coincide, and necessarily so: -// there is exactly one color in play, so "the emphatic filled type" and "the -// page and its text swapped" are the same construction. Aliased rather than -// copied so they cannot drift. -export const CURRENT_INVERT_STYLES: Styles = CURRENT_PRIMARY_STYLES; +// The `current` theme is the one special case, because it has no `accent-text` +// to fill with — it has exactly one color, the one it inherits. So `invert` here +// is literally `CURRENT_PRIMARY_STYLES` with its two colors swapped: +// +// fill label +// primary #current #surface — paint the color, punch the page out of it +// invert #surface #current — paint the page, write the color on it +// +// That is the same swap `special` makes between its own two filled types (a +// brand surface under `#white`, against a white pill under the dark accent), so +// the pairing reads the same way on every theme even though the tokens differ. +// +// Swapping the colors also removes 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` to keep `color` +// free — and every icon slot has to be recolored by hand, because SVG stroked +// with `currentColor` does not see that property. Here the fill is `#surface`, +// an absolute token that never consults `color`, so `color: '#current'` is just +// the label and icons inherit it for free. +export const CURRENT_INVERT_STYLES: Styles = { + ...CURRENT_FOCUS_RING, + // The rim is the inherited color at `primary`'s own `.25`, and it needs no + // disabled entry: `#current` resolves against this element's `color`, which + // the fade below already moved, so the rim fades with the label. That is the + // pre-multiply trap the rest of this section documents, used deliberately — + // it is only a trap when a value is faded twice on purpose. + border: '#current.25', + // The overlay tints TOWARDS the inherited color rather than darkening with + // `#black` the way the brand `invert` ramps do. On a white page that reads as + // a darkening and on a dark one as a lightening, so the step is visible in + // both schemes — where a fixed `#black` over a near-black `#surface` would + // barely move. Same two-layer shape in every state so the overlay + // interpolates; see `DEFAULT_PRIMARY_STYLES.fill`. + fill: { + '': '#surface #current.0', + 'hovered | focused': '#surface #current.08', + pressed: '#surface #current.16', + // Half-strength page color lets the container show through, so a dead + // control stops reading as a solid chip. The label and rim fade with the + // color below. + disabled: '#surface.5 #current.0', + }, + color: { + '': '#current', + // See `CURRENT_ITEM_STYLES.color` for why this is gated rather than a bare + // `disabled`: both mods mark a color that something above already faded. + 'disabled & !inherit-disabled & !inside-wrapper': '#current.4', + }, +} as const; // ---------- CARD TYPE STYLES ---------- // Card type only supports: default, success, danger, note themes (plus the From f4c572210ca012b47274331eb4acda9083f2c6bf Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 19 Aug 2026 19:25:30 +0000 Subject: [PATCH 17/26] fix(item): give `current.invert` the inheriting HotKeys hint MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Fallout from the previous commit. The shortcut hint carved out `theme === 'current'` and sent that theme's `invert` to the `primary` HotKeys flavour, which pins the hint to `#white`. That was right only while `current.invert` aliased `current.primary`, where `color` is the FILL and an inheriting hint would draw its rim in the fill color and erase it. Now that `current.invert` fills with an absolute `#surface` and keeps `color` for the label, it is an ordinary `invert` and the white hint sinks into its own fill in light mode. The condition was asking the wrong question. What decides the flavour is whether the row's `color` is the label or the fill — which is `primary` versus everything else, not `current` versus everything else. So the theme drops out of it entirely: invert -> inherit `color` is the label; the hint tracks it in either scheme primary -> primary brand: the label is `#white`. current: `color` is the fill, and the label rides `-webkit-text-fill-color` else -> default Probed: `current.invert` now emits `data-type="inherit"` on its `kbd` elements, `current.primary` keeps `primary`. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01BU1Vwm6cGgGRcMubszgbGD --- src/components/content/Item/Item.tsx | 32 +++++++++++++++------------- 1 file changed, 17 insertions(+), 15 deletions(-) diff --git a/src/components/content/Item/Item.tsx b/src/components/content/Item/Item.tsx index 530945d9c..e50995322 100644 --- a/src/components/content/Item/Item.tsx +++ b/src/components/content/Item/Item.tsx @@ -760,24 +760,26 @@ const Item = ( const finalPrefix = isLoading && resolvedLoadingSlot === 'prefix' ? : prefix; - // Which HotKeys flavour the shortcut hint wears, chosen by what the row's - // label is painted with. + // Which HotKeys flavour the shortcut hint wears, decided by one question: is + // this row's `color` the label, or the fill? // - // `primary` pins the hint to `#white`, which is right on a filled brand chip - // whose own label is `#white` too. `invert` labels itself `#surface`, so the - // white hint would land on the wrong side of the fill in dark mode — it takes - // `inherit` instead, which paints the hint from `currentcolor` and therefore - // tracks the label exactly. + // On every type but `primary`, `color` IS the label, so `inherit` is right — + // it paints the hint's glyphs and rim from `currentcolor` and therefore tracks + // the label exactly, in either scheme. `invert` needs that: it labels itself + // `#surface`, so `primary`'s fixed `#white` hint would land on the wrong side + // of the fill in dark mode. // - // Except on the `current` theme, where `invert` and `primary` coincide and - // `currentcolor` IS the fill: there `inherit` would draw the hint's rim in the - // fill color and erase it, so that pairing stays on `primary`. + // `primary` is the exception on both counts. On the brand themes its label is + // `#white`, which is what the `primary` flavour paints. On `current` its + // `color` is the FILL — the label is painted with `-webkit-text-fill-color` + // precisely so `color` can stay free for `fill` to resolve against — so + // `inherit` would draw the hint's rim in the fill color and erase it. + // + // `current.invert` is not that case, despite the shared theme: it fills with + // an absolute `#surface` and keeps `color` for the label, like every other + // `invert`. It reads `inherit` here for exactly the same reason they do. const hotkeysType = - type === 'invert' && theme !== 'current' - ? 'inherit' - : type === 'primary' || type === 'invert' - ? 'primary' - : 'default'; + type === 'invert' ? 'inherit' : type === 'primary' ? 'primary' : 'default'; // Build final suffix: loading icon, custom suffix, or HotKeys hint const finalSuffix = From f37a0d02d403b8961187ba65a51d89ae30b1f697 Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 19 Aug 2026 19:38:37 +0000 Subject: [PATCH 18/26] feat(banner): make `Banner.Action` a filled `invert` chip on the banner's theme MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Banner actions were `current.outline` — a chip mixed from the banner's own label, 1.06 off the banner surface, which is what an outline is meant to be. For a banner whose action is the thing to press, the filled counterpart reads better. The obvious way to get there does not work, and the reason is specific rather than general. A banner labels itself `#white` in both schemes, so `currentcolor` inside one is white; and `#surface`, the page token both filled `current` flavours reach for, is ALSO white in light mode. Every arrangement of those two collapses: theme=current type=primary fill #current (white) label #surface (white) theme=current type=invert fill #surface (white) label #current (white) Both measure cr 1.00 in light, on all four banner themes. This is the light- inherited-color limitation of the `current` theme, met head-on: the theme has exactly one color to work with and the banner has already spent it. The banner's OWN tokens have no such coincidence. `accent-text` is the dark end of the brand ramp and `#surface` is the page, so `.invert` measures 6.87–7.90 across the four themes in both schemes, with the chip still separating from the banner (~1.5 light, ~2.4 dark, either side of the 1.48 a `primary` rim measures). `Banner.Action` therefore reads the host theme off `ItemActionContext` and names it, rather than falling through to `Item.Action`'s `current` default. The dismiss button is deliberately untouched: it stays a borderless `current` icon action, which is what a secondary affordance should be. Probed — the action renders `danger.invert`, the dismiss stays `current.clear`. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01BU1Vwm6cGgGRcMubszgbGD --- .changeset/banner-invert-actions.md | 11 ++++++ src/components/actions/Banner/Banner.tsx | 45 +++++++++++++++++------- 2 files changed, 44 insertions(+), 12 deletions(-) create mode 100644 .changeset/banner-invert-actions.md diff --git a/.changeset/banner-invert-actions.md b/.changeset/banner-invert-actions.md new file mode 100644 index 000000000..f3a8800f5 --- /dev/null +++ b/.changeset/banner-invert-actions.md @@ -0,0 +1,11 @@ +--- +'@cube-dev/ui-kit': patch +--- + +`Banner.Action` is now a filled `invert` chip on the banner's own theme, instead of a `current` outline. + +A banner labels itself `#white` in both schemes, so `currentcolor` inside one is white — and `#surface`, the page token both filled `current` flavours reach for, is also white in light mode. Every arrangement of those two collapses: `theme="current" type="primary"` (fill `#current`, label `#surface`) and `theme="current" type="invert"` (fill `#surface`, label `#current`) each measure cr **1.00** in light on all four banner themes. + +The theme's own tokens have no such coincidence. `.invert` fills with `accent-text`, the dark end of the brand ramp, and labels with `#surface`: cr **6.87–7.90** across the four themes in both schemes, with the chip still separating from the banner (~1.5 light, ~2.4 dark, either side of the 1.48 a `primary` rim measures). `Banner.Action` therefore names the banner's theme explicitly rather than inheriting `current`. + +The dismiss button is unchanged — it stays a borderless `current` icon action, which is what a secondary affordance should be. diff --git a/src/components/actions/Banner/Banner.tsx b/src/components/actions/Banner/Banner.tsx index a937be335..9cb1e20fd 100644 --- a/src/components/actions/Banner/Banner.tsx +++ b/src/components/actions/Banner/Banner.tsx @@ -13,6 +13,7 @@ import { useI18n } from '../../../i18n'; import { CubeItemProps, Item } from '../../content/Item/Item'; import { Button, CubeButtonProps } from '../Button/Button'; import { CubeItemActionProps } from '../ItemAction/ItemAction'; +import { useItemActionContext } from '../ItemActionContext'; export type BannerTheme = 'danger' | 'warning' | 'note' | 'success'; @@ -59,19 +60,31 @@ const BannerElement = tasty(Item, { }, }); -// `outline` on the `current` theme (`Item.Action`'s default): the chip and its -// border are mixed from the banner's own white label, so the action reads as a -// control against a saturated surface without naming a color. +// `invert` on the banner's OWN theme, which is the one pairing that gives a +// banner a filled action legible in both schemes. // -// This used to clear the border. Back then an action inherited the banner's -// THEME, so `outline` meant `note.outline` and friends, whose border is the -// opaque `#note-border` — a pale line built for a `#surface-2` chip on a light -// page, and plainly wrong on a saturated banner. Now that the border is -// `#current.08` off the inherited label, clearing it only removed the one thing -// that made the type an outline, leaving a 3% tint that vanished into the -// banner. +// The obvious choice — `Item.Action`'s default `current` theme — cannot do it +// here, and the reason is specific rather than general. A banner labels itself +// `#white` in both schemes (see `BannerLinkElement` and `*_PRIMARY_STYLES`), so +// `currentcolor` inside one is white; and `#surface`, the page token both filled +// `current` flavours reach for, is ALSO white in light mode. Every arrangement +// of those two collapses: +// +// theme=current type=primary fill #current (white) label #surface (white) +// theme=current type=invert fill #surface (white) label #current (white) +// +// Both measure cr 1.00 in light on all four banner themes. The theme's own +// tokens have no such coincidence: `accent-text` is the dark end of the brand +// ramp and `#surface` is the page, so `.invert` measures 6.87–7.90 across +// the four themes in both schemes, and the chip still separates from the banner +// (cr ~1.5 light, ~2.4 dark, either side of the 1.48 a `primary` rim measures). +// +// The previous default was `current.outline`: label cr 4.3–5.0, but a chip only +// 1.06 off the banner, which is what an outline is meant to be. `invert` is the +// filled counterpart, for a banner whose action should read as the thing to +// press. const BannerActionElement = tasty(Item.Action, { - type: 'outline', + type: 'invert', styles: { preset: 't3m', }, @@ -91,7 +104,15 @@ const BannerLinkElement = tasty(Button, { * Automatically styled to match the banner's theme. */ export function BannerAction(props: BannerActionProps) { - return ; + // The banner's theme, taken from the row that hosts the action. `Item.Action` + // defaults its own `theme` to `current` and no longer reads this context for + // styling, so the banner has to name it — which is the point here: `invert` + // needs the brand ramp, and `current` is exactly what does not work inside a + // banner. Falls back to the banner's own default theme when an action is + // rendered outside one, rather than to `current`. + const { theme } = useItemActionContext(); + + return ; } /** From e31092d0baf1fbc67999a74be80ba351b331e181 Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 20 Aug 2026 09:20:50 +0000 Subject: [PATCH 19/26] feat(themes): let a container offer `current` a color via `--current-accent` MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Reverses the approach in f37a0d0. That commit had `Banner.Action` name the banner's theme so it could reach the brand ramp directly, on the grounds that "the banner has already spent its one color". That framing was wrong: it only holds if the actions inherit the banner's LABEL, and the banner owns the wrapper they sit in, so it can hand them something else. `current.invert` now sources its label from `var(--current-accent, currentcolor)`. Unset, the fallback is exactly what `#current` compiled to before, so nothing outside such a container changes. `Banner` sets the property on its actions wrapper to the theme's `accent-text`, and `Banner.Action` goes back to plain defaults — the container states its color context once and its children stay theme-agnostic, which is the promise of the `current` theme that naming the theme per-component opted out of. Measured, on all four banner themes: unaided (label #current on a #surface pill) cr 1.00 light --current-accent = accent-text cr 6.87-7.90 both schemes pill against the banner 1.5 light / 2.4 dark The literal suggestion — offer the container's own FILL — was measured and rejected: the pill is `#surface`, so "label on pill" and "pill on banner" are the same pair of colors and therefore the same number, 4.5-4.7 light but 3.09-3.19 dark, under the AA floor. They cannot be tuned apart. Only `color` reads the property. The rim and the hover/pressed overlays follow for free, because `#current` compiles to `currentcolor` and resolves against the element's own `color`. The disabled entry spells its `color-mix` out, since `#current.4` would mix against the INHERITED color rather than the accent. The other `current` flavours deliberately ignore it: they paint their chip ON the container rather than on a pill, so the inherited color is already right and an accent would only lower their contrast. That is what lets the dismiss button share the wrapper and keep the `#white` measuring 4.62 against the banner, where the accent would measure 1.53 — probed: the action resolves `color: var(--current-accent, currentcolor)` under a wrapper setting `--current-accent: var(--danger-accent-text-color)`, the dismiss resolves a plain `color: currentcolor`. The scoped-edit lesson from earlier in this branch held again: three `current` flavours share a byte-identical `color` block, so the edit had to be sliced to `CURRENT_INVERT_STYLES` rather than run as a file-wide replace. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01BU1Vwm6cGgGRcMubszgbGD --- .changeset/banner-invert-actions.md | 8 ++--- src/components/actions/Banner/Banner.tsx | 34 ++++++++++++------ src/components/actions/Button/Button.docs.mdx | 2 +- src/components/content/Item/Item.docs.mdx | 2 +- src/data/item-themes.test.ts | 35 +++++++++++++++++-- src/data/item-themes.ts | 33 ++++++++++++++--- 6 files changed, 92 insertions(+), 22 deletions(-) diff --git a/.changeset/banner-invert-actions.md b/.changeset/banner-invert-actions.md index f3a8800f5..fef0b3bef 100644 --- a/.changeset/banner-invert-actions.md +++ b/.changeset/banner-invert-actions.md @@ -2,10 +2,10 @@ '@cube-dev/ui-kit': patch --- -`Banner.Action` is now a filled `invert` chip on the banner's own theme, instead of a `current` outline. +`Banner.Action` is now a filled `invert` chip, and containers can offer the `current` theme a color through a new `--current-accent` custom property. -A banner labels itself `#white` in both schemes, so `currentcolor` inside one is white — and `#surface`, the page token both filled `current` flavours reach for, is also white in light mode. Every arrangement of those two collapses: `theme="current" type="primary"` (fill `#current`, label `#surface`) and `theme="current" type="invert"` (fill `#surface`, label `#current`) each measure cr **1.00** in light on all four banner themes. +A banner labels itself `#white` in both schemes, so `currentcolor` inside one is white — and `#surface`, the page token `current.invert` fills with, is also white in light mode. Unaided, the label and its pill collapse to cr **1.00** on all four banner themes. -The theme's own tokens have no such coincidence. `.invert` fills with `accent-text`, the dark end of the brand ramp, and labels with `#surface`: cr **6.87–7.90** across the four themes in both schemes, with the chip still separating from the banner (~1.5 light, ~2.4 dark, either side of the 1.48 a `primary` rim measures). `Banner.Action` therefore names the banner's theme explicitly rather than inheriting `current`. +`current.invert` now sources its label from `var(--current-accent, currentcolor)`. Unset, the fallback is exactly what `#current` compiled to before, so nothing outside such a container changes. `Banner` sets it on its actions wrapper to the theme's `accent-text`, which puts the label at **6.87–7.90** in both schemes with the pill still 1.5 (light) / 2.4 (dark) off the banner. -The dismiss button is unchanged — it stays a borderless `current` icon action, which is what a secondary affordance should be. +Only `color` reads the property; the rim and the hover/pressed overlays follow for free, because `#current` compiles to `currentcolor` and resolves against the element's own `color`. The other `current` flavours deliberately ignore it — they paint their chip *on* the container rather than on a pill, so the inherited color is already the right one. That is what lets the dismiss button share the same wrapper and keep its `#white`, which measures 4.62 against the banner where the accent would measure 1.53. diff --git a/src/components/actions/Banner/Banner.tsx b/src/components/actions/Banner/Banner.tsx index 9cb1e20fd..63410fb7e 100644 --- a/src/components/actions/Banner/Banner.tsx +++ b/src/components/actions/Banner/Banner.tsx @@ -13,7 +13,6 @@ import { useI18n } from '../../../i18n'; import { CubeItemProps, Item } from '../../content/Item/Item'; import { Button, CubeButtonProps } from '../Button/Button'; import { CubeItemActionProps } from '../ItemAction/ItemAction'; -import { useItemActionContext } from '../ItemActionContext'; export type BannerTheme = 'danger' | 'warning' | 'note' | 'success'; @@ -56,6 +55,26 @@ const BannerElement = tasty(Item, { Actions: { gap: '1x', + // The color this banner OFFERS to `current`-themed children that cannot + // use the inherited one. `Banner.Action` is `current.invert`: it writes + // its label on a `#surface` pill, and a banner's inherited color is + // `#white` — which IS `#surface` in light mode, so unaided it measures + // cr 1.00. Handing down `accent-text` puts it at 6.87–7.90 in both + // schemes, with the pill still 1.5 (light) / 2.4 (dark) off the banner. + // + // Declared here rather than on `Banner.Action` so the banner states its + // color context once and its children stay theme-agnostic — the wrapper + // is the natural place for it, and custom properties inherit. It is safe + // for the dismiss button to sit in the same wrapper: that one is + // `current.clear`, which paints from `#current` and never reads this, so + // it keeps the `#white` that measures 4.62 against the banner where this + // accent would measure 1.53. + '$current-accent': { + 'theme=note': '#note-accent-text', + 'theme=danger': '#danger-accent-text', + 'theme=warning': '#warning-accent-text', + 'theme=success': '#success-accent-text', + }, }, }, }); @@ -85,6 +104,9 @@ const BannerElement = tasty(Item, { // press. const BannerActionElement = tasty(Item.Action, { type: 'invert', + // No `theme`: it stays on `Item.Action`'s `current` default and takes its + // color from the `--current-accent` the banner offers above. + styles: { preset: 't3m', }, @@ -104,15 +126,7 @@ const BannerLinkElement = tasty(Button, { * Automatically styled to match the banner's theme. */ export function BannerAction(props: BannerActionProps) { - // The banner's theme, taken from the row that hosts the action. `Item.Action` - // defaults its own `theme` to `current` and no longer reads this context for - // styling, so the banner has to name it — which is the point here: `invert` - // needs the brand ramp, and `current` is exactly what does not work inside a - // banner. Falls back to the banner's own default theme when an action is - // rendered outside one, rather than to `current`. - const { theme } = useItemActionContext(); - - return ; + return ; } /** diff --git a/src/components/actions/Button/Button.docs.mdx b/src/components/actions/Button/Button.docs.mdx index 4a1a5371a..884090435 100644 --- a/src/components/actions/Button/Button.docs.mdx +++ b/src/components/actions/Button/Button.docs.mdx @@ -88,7 +88,7 @@ The `mods` prop accepts the following modifiers you can override: ### Types. `type` prop - `primary` – Emphasised call-to-action. -- `invert` – The page and its text swapped: the fill is the theme's `accent-text` — the color normally painted *on* the page — and the label is `#surface`, the page itself. Unlike `primary`, which pins a fixed `#white` label on a brand surface and so reads with the same weight in either scheme, `invert` follows the scheme: dark chip with a light label in light mode, light chip with a dark label in dark mode. Use it where a `primary` chip is not emphatic enough — a single hero action, or a filled control that has to out-read a brand chip sitting next to it. On the `current` theme there is no `accent-text` to fill with, so `invert` is `primary` with its two colors swapped: `primary` fills with the inherited color and punches `#surface` out of it, `invert` fills with `#surface` and writes the inherited color on top. +- `invert` – The page and its text swapped: the fill is the theme's `accent-text` — the color normally painted *on* the page — and the label is `#surface`, the page itself. Unlike `primary`, which pins a fixed `#white` label on a brand surface and so reads with the same weight in either scheme, `invert` follows the scheme: dark chip with a light label in light mode, light chip with a dark label in dark mode. Use it where a `primary` chip is not emphatic enough — a single hero action, or a filled control that has to out-read a brand chip sitting next to it. On the `current` theme there is no `accent-text` to fill with, so `invert` is `primary` with its two colors swapped: `primary` fills with the inherited color and punches `#surface` out of it, `invert` fills with `#surface` and writes the inherited color on top. A container can override the color `current.invert` writes with by setting the `--current-accent` custom property on an ancestor — needed where the inherited color *is* `#surface` (a `Banner` labels itself `#white`, so the pill and its label would collapse); unset, it falls back to the inherited color. - `outline` – Border with transparent background; `isSelected` adds brand-tinted fill. - `clear` – No border, transparent background; `isSelected` adds brand-tinted fill. - `link` – Styled as a textual link. diff --git a/src/components/content/Item/Item.docs.mdx b/src/components/content/Item/Item.docs.mdx index 68eccf6a0..7d083b329 100644 --- a/src/components/content/Item/Item.docs.mdx +++ b/src/components/content/Item/Item.docs.mdx @@ -103,7 +103,7 @@ The `mods` property accepts the following modifiers: - `item` - Default item appearance (no specific styling) - `header` - Header appearance for section headers (only supports `default` theme, defaults to `descriptionPlacement="block"`, uses semantic heading tags via `level` prop) - `primary` - Primary styled item with prominent appearance -- `invert` - The page and its text swapped: the fill is the theme's `accent-text` and the label is `#surface`. Unlike `primary`, which pins a fixed `#white` label on a brand surface, `invert` follows the scheme — dark chip with a light label in light mode, light chip with a dark label in dark mode. On the `current` theme, which has no `accent-text`, it is `primary` with its two colors swapped: a `#surface` fill with the inherited color written on it +- `invert` - The page and its text swapped: the fill is the theme's `accent-text` and the label is `#surface`. Unlike `primary`, which pins a fixed `#white` label on a brand surface, `invert` follows the scheme — dark chip with a light label in light mode, light chip with a dark label in dark mode. On the `current` theme, which has no `accent-text`, it is `primary` with its two colors swapped: a `#surface` fill with the inherited color written on it, and an ancestor can override that label color by setting the `--current-accent` custom property (needed only where the inherited color *is* `#surface`, as inside a `Banner`) - `outline` - Item with border outline styling; `isSelected` adds brand-tinted fill - `outline-2` - Same as `outline` but uses `#surface-3` as the base fill, designed to sit on `#surface-2` containers without blending in (not available for the `special` theme) - `clear` - Transparent item with minimal styling; `isSelected` adds brand-tinted fill diff --git a/src/data/item-themes.test.ts b/src/data/item-themes.test.ts index 03f738c77..210ea5c55 100644 --- a/src/data/item-themes.test.ts +++ b/src/data/item-themes.test.ts @@ -143,10 +143,22 @@ describe('current theme disabled fades', () => { ]); }); + // Every flavour fades to 40% of whatever it paints its label with. That is + // `#current.4` for all but `invert`, which sources its label from the + // `--current-accent` a container may offer and therefore has to spell the mix + // out — `#current.4` would compile to `color-mix(… currentcolor …)`, and + // `currentcolor` inside the `color` property means the inherited value rather + // than the accent. + const FADED: Record = { + 'current.invert': + 'color-mix(in oklab, var(--current-accent, currentcolor) 40%, transparent)', + }; + const fadedFor = (variant: string) => FADED[variant] ?? '#current.4'; + it.each(CURRENT_VARIANTS)('%s gates its label fade', (variant) => { const color = ITEM_VARIANTS[variant].color as Record; - expect(color[GATE]).toBe('#current.4'); + expect(color[GATE]).toBe(fadedFor(variant)); // A bare `disabled` key would win over the gate and fade unconditionally. expect(color.disabled).toBeUndefined(); }); @@ -159,7 +171,26 @@ describe('current theme disabled fades', () => { string >; - expect(color.disabled).toBe('#current.4'); + expect(color.disabled).toBe(fadedFor(variant)); }, ); + + // `invert` is the only flavour a container can redirect, and it must degrade + // to the inherited color everywhere else — the fallback is what keeps every + // non-Banner usage rendering exactly as it did before the hook existed. + it('reads --current-accent on invert only, always with a currentcolor fallback', () => { + const readers = CURRENT_VARIANTS.filter((variant) => + JSON.stringify(ITEM_VARIANTS[variant]).includes('--current-accent'), + ); + + expect(readers).toEqual(['current.invert']); + + const color = ITEM_VARIANTS['current.invert'].color as Record< + string, + string + >; + for (const value of Object.values(color)) { + expect(value).toContain('var(--current-accent, currentcolor)'); + } + }); }); diff --git a/src/data/item-themes.ts b/src/data/item-themes.ts index c086ecad4..06858870e 100644 --- a/src/data/item-themes.ts +++ b/src/data/item-themes.ts @@ -1475,8 +1475,9 @@ export const SPECIAL_INVERT_STYLES: Styles = { // the label has to be painted with `-webkit-text-fill-color` to keep `color` // free — and every icon slot has to be recolored by hand, because SVG stroked // with `currentColor` does not see that property. Here the fill is `#surface`, -// an absolute token that never consults `color`, so `color: '#current'` is just -// the label and icons inherit it for free. +// an absolute token that never consults `color`, so `color` is just the label +// and icons inherit it for free. That is also what makes the `--current-accent` +// hook below possible: with `color` free, a container can redirect it. export const CURRENT_INVERT_STYLES: Styles = { ...CURRENT_FOCUS_RING, // The rim is the inherited color at `primary`'s own `.25`, and it needs no @@ -1500,11 +1501,35 @@ export const CURRENT_INVERT_STYLES: Styles = { // color below. disabled: '#surface.5 #current.0', }, + // The one place a container can intervene. `--current-accent` names a color a + // container OFFERS to the `current` theme, for the case the theme cannot solve + // on its own: this flavour writes its label on a `#surface` pill, so it breaks + // wherever the inherited color IS `#surface`. A `Banner` is exactly that — it + // labels itself `#white` in both schemes, and `#surface` is white in light, so + // an unaided `current.invert` inside one measures cr 1.00. + // + // Unset it and the fallback is `currentcolor`, which is what `#current` + // compiled to before, so nothing outside such a container changes. + // + // Only `color` reads it, and everything else follows for free: `#current` + // compiles to `currentcolor`, which in `border` and `fill` resolves against + // this element's OWN `color` — so the rim and the hover/pressed overlays all + // re-aim at the offered accent without naming it twice. Deliberately NOT read + // by the other `current` flavours: they paint their chip ON the container + // rather than on a pill, so the inherited color is already the right one and + // an offered accent would only lower their contrast — a `#danger-accent-text` + // dismiss icon on a danger banner measures 1.53, against the 4.62 `#white` gets. + // + // The disabled entry spells the mix out rather than writing `#current.4`, + // which would compile to `color-mix(… currentcolor …)` — and `currentcolor` + // inside the `color` property means the INHERITED value, so it would fade the + // container's color instead of the accent. color: { - '': '#current', + '': 'var(--current-accent, currentcolor)', // See `CURRENT_ITEM_STYLES.color` for why this is gated rather than a bare // `disabled`: both mods mark a color that something above already faded. - 'disabled & !inherit-disabled & !inside-wrapper': '#current.4', + 'disabled & !inherit-disabled & !inside-wrapper': + 'color-mix(in oklab, var(--current-accent, currentcolor) 40%, transparent)', }, } as const; From 20ae37686a172c903403dd9a8fbb4822e3de535b Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 20 Aug 2026 09:34:25 +0000 Subject: [PATCH 20/26] fix(banner): offer a muted `--current-accent` when the banner is disabled MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 Claude-Session: https://claude.ai/code/session_01BU1Vwm6cGgGRcMubszgbGD --- .changeset/banner-invert-actions.md | 2 + src/components/actions/Banner/Banner.test.ts | 47 ++++++++++++++++++++ src/components/actions/Banner/Banner.tsx | 27 ++++++++--- src/data/item-themes.ts | 9 ++++ 4 files changed, 79 insertions(+), 6 deletions(-) create mode 100644 src/components/actions/Banner/Banner.test.ts diff --git a/.changeset/banner-invert-actions.md b/.changeset/banner-invert-actions.md index fef0b3bef..27c9a83d6 100644 --- a/.changeset/banner-invert-actions.md +++ b/.changeset/banner-invert-actions.md @@ -9,3 +9,5 @@ A banner labels itself `#white` in both schemes, so `currentcolor` inside one is `current.invert` now sources its label from `var(--current-accent, currentcolor)`. Unset, the fallback is exactly what `#current` compiled to before, so nothing outside such a container changes. `Banner` sets it on its actions wrapper to the theme's `accent-text`, which puts the label at **6.87–7.90** in both schemes with the pill still 1.5 (light) / 2.4 (dark) off the banner. Only `color` reads the property; the rim and the hover/pressed overlays follow for free, because `#current` compiles to `currentcolor` and resolves against the element's own `color`. The other `current` flavours deliberately ignore it — they paint their chip *on* the container rather than on a pill, so the inherited color is already the right one. That is what lets the dismiss button share the same wrapper and keep its `#white`, which measures 4.62 against the banner where the accent would measure 1.53. + +A container that sets `--current-accent` owns that color in every state, the disabled one included: `current.invert` gates its own label fade on `!inherit-disabled`, on the grounds that something above already faded the color it paints from, and an offered accent is not inherited. `Banner` therefore pairs each accent with a `.4` counterpart — without it a disabled banner keeps a full-strength label (cr 5.69 light / 6.13 dark) on a dead chip, against the 1.81 / 2.20 the muted entry gives. diff --git a/src/components/actions/Banner/Banner.test.ts b/src/components/actions/Banner/Banner.test.ts new file mode 100644 index 000000000..5cadf6dee --- /dev/null +++ b/src/components/actions/Banner/Banner.test.ts @@ -0,0 +1,47 @@ +import { BANNER_ACTION_ACCENT } from './Banner'; + +/** + * A container that OFFERS a color to the `current` theme owns that color in + * every state. + * + * `Banner` hands its actions a `--current-accent` because `current.invert` + * writes its label on a `#surface` pill, and a banner's inherited color is + * `#white` — which IS `#surface` in light mode, so unaided the two collapse. + * The reader gates its own disabled fade on `!inherit-disabled`, on the grounds + * that something above already faded the color it paints from. That holds for + * an inherited color automatically, and for an offered one only if the offer + * includes a muted entry. + * + * Without it a disabled banner keeps 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. The `.4` counterpart lands on 1.81 / 2.20, the ~2:1 band every other + * disabled label in `item-themes` is tuned to. + */ +describe('BANNER_ACTION_ACCENT', () => { + const THEMES = ['note', 'danger', 'warning', 'success']; + + it('offers an accent for every banner theme', () => { + expect(THEMES.map((t) => BANNER_ACTION_ACCENT[`theme=${t}`])).toEqual( + THEMES.map((t) => `#${t}-accent-text`), + ); + }); + + it('pairs every offered accent with a muted one', () => { + const live = Object.keys(BANNER_ACTION_ACCENT).filter( + (key) => !key.includes('disabled'), + ); + + expect(live.length).toBe(THEMES.length); + + for (const key of live) { + const muted = BANNER_ACTION_ACCENT[`${key} & disabled`]; + + expect(muted).toBeDefined(); + // Same token, faded — not a different color, and not the same string + // (two identical values in one state map get coalesced at the group's + // max priority; see `SPECIAL_CLEAR_STYLES`). + expect(muted).toBe(`${BANNER_ACTION_ACCENT[key]}.4`); + expect(muted).not.toBe(BANNER_ACTION_ACCENT[key]); + } + }); +}); diff --git a/src/components/actions/Banner/Banner.tsx b/src/components/actions/Banner/Banner.tsx index 63410fb7e..d52bb87bb 100644 --- a/src/components/actions/Banner/Banner.tsx +++ b/src/components/actions/Banner/Banner.tsx @@ -44,6 +44,16 @@ const DEFAULT_ICONS: Record = { success: , }; +// The accent a banner offers its `current`-themed actions, live and muted. +// Exported so the live/disabled pairing can be pinned by a test rather than by +// discipline — see `Banner.test.ts`. +export const BANNER_ACTION_ACCENT: Record = Object.fromEntries( + (['note', 'danger', 'warning', 'success'] as const).flatMap((theme) => [ + [`theme=${theme}`, `#${theme}-accent-text`], + [`theme=${theme} & disabled`, `#${theme}-accent-text.4`], + ]), +); + const BannerElement = tasty(Item, { styles: { width: '100%', @@ -69,12 +79,17 @@ const BannerElement = tasty(Item, { // `current.clear`, which paints from `#current` and never reads this, so // it keeps the `#white` that measures 4.62 against the banner where this // accent would measure 1.53. - '$current-accent': { - 'theme=note': '#note-accent-text', - 'theme=danger': '#danger-accent-text', - 'theme=warning': '#warning-accent-text', - 'theme=success': '#success-accent-text', - }, + // + // The disabled entries are not optional, and they are the whole contract: + // a container that OFFERS a color owns that color in every state. The + // reader gates its own fade on `!inherit-disabled` precisely because + // something above is expected to have done it — true when the color is + // inherited, and true here only if this map says so. Drop them and a + // disabled banner keeps a full-strength `accent-text` label on a dead + // chip: cr 5.69 light / 6.13 dark, reading live. At `.4` it lands on + // 1.81 / 2.20, the same ~2:1 band every other disabled label in this file + // is tuned to. + '$current-accent': BANNER_ACTION_ACCENT, }, }, }); diff --git a/src/data/item-themes.ts b/src/data/item-themes.ts index 06858870e..930548478 100644 --- a/src/data/item-themes.ts +++ b/src/data/item-themes.ts @@ -1511,6 +1511,15 @@ export const CURRENT_INVERT_STYLES: Styles = { // Unset it and the fallback is `currentcolor`, which is what `#current` // compiled to before, so nothing outside such a container changes. // + // The contract for a container that sets it: OWN THAT COLOR IN EVERY STATE, + // the disabled one included. The fade below is gated on `!inherit-disabled` + // because something above is expected to have faded the color this element + // paints from — automatic when that color is inherited, but an offered accent + // is not inherited, so the offer has to carry its own muted entry. A + // container that offers only a live accent leaves a full-strength label on a + // dead chip. See `BANNER_ACTION_ACCENT`, which pairs each entry with a `.4` + // counterpart, and the test that pins the pairing. + // // Only `color` reads it, and everything else follows for free: `#current` // compiles to `currentcolor`, which in `border` and `fill` resolves against // this element's OWN `color` — so the rim and the hover/pressed overlays all From 60b897579d67c9abbf343287f443c0f9480a3741 Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 20 Aug 2026 09:55:41 +0000 Subject: [PATCH 21/26] fix(themes): let a container redirect `current.primary`'s label too MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 Claude-Session: https://claude.ai/code/session_01BU1Vwm6cGgGRcMubszgbGD --- .changeset/banner-invert-actions.md | 4 +- src/components/actions/Button/Button.docs.mdx | 2 +- .../actions/Button/Button.stories.tsx | 46 ++++++++++++++++--- src/components/content/Item/Item.docs.mdx | 2 +- src/data/item-themes.test.ts | 38 +++++++++------ src/data/item-themes.ts | 26 +++++++++-- 6 files changed, 91 insertions(+), 27 deletions(-) diff --git a/.changeset/banner-invert-actions.md b/.changeset/banner-invert-actions.md index 27c9a83d6..8acf40458 100644 --- a/.changeset/banner-invert-actions.md +++ b/.changeset/banner-invert-actions.md @@ -6,8 +6,10 @@ A banner labels itself `#white` in both schemes, so `currentcolor` inside one is white — and `#surface`, the page token `current.invert` fills with, is also white in light mode. Unaided, the label and its pill collapse to cr **1.00** on all four banner themes. -`current.invert` now sources its label from `var(--current-accent, currentcolor)`. Unset, the fallback is exactly what `#current` compiled to before, so nothing outside such a container changes. `Banner` sets it on its actions wrapper to the theme's `accent-text`, which puts the label at **6.87–7.90** in both schemes with the pill still 1.5 (light) / 2.4 (dark) off the banner. +Both filled `current` flavours now source their label through the property — `current.invert` as `var(--current-accent, currentcolor)`, `current.primary` as `var(--current-accent, var(--surface-color))`. Unset, the fallback is exactly what `#current` compiled to before, so nothing outside such a container changes. `Banner` sets it on its actions wrapper to the theme's `accent-text`, which puts the label at **6.87–7.90** in both schemes with the pill still 1.5 (light) / 2.4 (dark) off the banner. Only `color` reads the property; the rim and the hover/pressed overlays follow for free, because `#current` compiles to `currentcolor` and resolves against the element's own `color`. The other `current` flavours deliberately ignore it — they paint their chip *on* the container rather than on a pill, so the inherited color is already the right one. That is what lets the dismiss button share the same wrapper and keep its `#white`, which measures 4.62 against the banner where the accent would measure 1.53. A container that sets `--current-accent` owns that color in every state, the disabled one included: `current.invert` gates its own label fade on `!inherit-disabled`, on the grounds that something above already faded the color it paints from, and an offered accent is not inherited. `Banner` therefore pairs each accent with a `.4` counterpart — without it a disabled banner keeps a full-strength label (cr 5.69 light / 6.13 dark) on a dead chip, against the 1.81 / 2.20 the muted entry gives. + +`current.primary` needs it for the mirror-image reason: its pill IS `currentcolor`, so its `#surface` label only contrasts while the inherited color sits away from the page. A container that inverts the surface breaks that — a dark banner paints `#white`, so the pill is white and the label is white too in light mode, cr **1.00**. Such a container has the answer to hand: its own fill contrasts with its own text by construction, and the pill is that text. The `CurrentTheme` story's two inverted containers now offer theirs. diff --git a/src/components/actions/Button/Button.docs.mdx b/src/components/actions/Button/Button.docs.mdx index 884090435..1de35d4d8 100644 --- a/src/components/actions/Button/Button.docs.mdx +++ b/src/components/actions/Button/Button.docs.mdx @@ -88,7 +88,7 @@ The `mods` prop accepts the following modifiers you can override: ### Types. `type` prop - `primary` – Emphasised call-to-action. -- `invert` – The page and its text swapped: the fill is the theme's `accent-text` — the color normally painted *on* the page — and the label is `#surface`, the page itself. Unlike `primary`, which pins a fixed `#white` label on a brand surface and so reads with the same weight in either scheme, `invert` follows the scheme: dark chip with a light label in light mode, light chip with a dark label in dark mode. Use it where a `primary` chip is not emphatic enough — a single hero action, or a filled control that has to out-read a brand chip sitting next to it. On the `current` theme there is no `accent-text` to fill with, so `invert` is `primary` with its two colors swapped: `primary` fills with the inherited color and punches `#surface` out of it, `invert` fills with `#surface` and writes the inherited color on top. A container can override the color `current.invert` writes with by setting the `--current-accent` custom property on an ancestor — needed where the inherited color *is* `#surface` (a `Banner` labels itself `#white`, so the pill and its label would collapse); unset, it falls back to the inherited color. +- `invert` – The page and its text swapped: the fill is the theme's `accent-text` — the color normally painted *on* the page — and the label is `#surface`, the page itself. Unlike `primary`, which pins a fixed `#white` label on a brand surface and so reads with the same weight in either scheme, `invert` follows the scheme: dark chip with a light label in light mode, light chip with a dark label in dark mode. Use it where a `primary` chip is not emphatic enough — a single hero action, or a filled control that has to out-read a brand chip sitting next to it. On the `current` theme there is no `accent-text` to fill with, so `invert` is `primary` with its two colors swapped: `primary` fills with the inherited color and punches `#surface` out of it, `invert` fills with `#surface` and writes the inherited color on top. A container can override the color `current.invert` writes with by setting the `--current-accent` custom property on an ancestor — needed where the inherited color *is* `#surface` (a `Banner` labels itself `#white`, so the pill and its label would collapse); unset, it falls back to the inherited color. The same property redirects `current.primary`, which has the mirror problem: its pill is the inherited color, so a container that inverts the surface leaves its `#surface` label invisible. Offer the container's own fill. - `outline` – Border with transparent background; `isSelected` adds brand-tinted fill. - `clear` – No border, transparent background; `isSelected` adds brand-tinted fill. - `link` – Styled as a textual link. diff --git a/src/components/actions/Button/Button.stories.tsx b/src/components/actions/Button/Button.stories.tsx index d0ea406a3..9c0b3bd49 100644 --- a/src/components/actions/Button/Button.stories.tsx +++ b/src/components/actions/Button/Button.stories.tsx @@ -538,25 +538,39 @@ DisabledWithTooltip.parameters = { // Contexts the `current` theme is meant to live in: each one paints its own text // color, and the button is expected to adopt it whatever its type. +// `accent` is the container's `--current-accent` offer: the color a `current` +// control should WRITE with when the inherited one will not do. Only the two +// containers that invert the surface need it, and for the same reason — they +// paint `#white`, so `currentcolor` collides with `#surface` in light mode and +// both filled types lose their label. Each offers its own fill, which contrasts +// with its own text by construction. The tinted containers leave it unset and +// fall back exactly as before. const CURRENT_CONTEXTS = [ { label: 'Page surface (inherited)', fill: undefined, color: undefined }, { label: 'Danger', fill: '#danger-surface', color: '#danger-accent-text' }, { label: 'Success', fill: '#success-surface', color: '#success-accent-text' }, { label: 'Note', fill: '#note-surface', color: '#note-accent-text' }, { label: 'Warning', fill: '#warning-surface', color: '#warning-accent-text' }, - { label: 'Dark banner', fill: '#fixed-dark', color: '#white' }, - { label: 'Brand', fill: '#primary', color: '#white' }, + { + label: 'Dark banner', + fill: '#fixed-dark', + color: '#white', + accent: '#fixed-dark', + }, + { label: 'Brand', fill: '#primary', color: '#white', accent: '#primary' }, ] as const; const CurrentContext = ({ label, fill, color, + accent, children, }: { label: string; fill?: string; color?: string; + accent?: string; children: ReactNode; }) => ( {label} @@ -577,8 +592,14 @@ const CurrentContext = ({ export const CurrentTheme: StoryFn<CubeButtonProps> = () => ( <Space flow="column" gap="3x"> <Title level={5}>Inherited Colors - {CURRENT_CONTEXTS.map(({ label, fill, color }) => ( - + {CURRENT_CONTEXTS.map(({ label, fill, color, ...rest }) => ( + + +``` + +Setting it moves the label, the icon slots and the chip's rim together. It also +takes the alpha suffix like any other token (`#current-fill.5` is the disabled +label), because it is a declared token rather than a bare custom property. + ### Accent System Each themed color (default, primary, success, danger, warning, note) provides an `accent-*` family anchored to a fixed white "accent surface text". These tokens stay recognizable across schemes (used for branded buttons, CTAs, etc.): diff --git a/src/tasty-augment.d.ts b/src/tasty-augment.d.ts index bccc045d6..29c5aeb34 100644 --- a/src/tasty-augment.d.ts +++ b/src/tasty-augment.d.ts @@ -40,6 +40,11 @@ declare module '@tenphi/tasty' { // `ColorValue`. `accent-selected-fill` is an internal contrast anchor for // `accent-text`, not a style-facing token. + // ---- Context hooks (see CONTEXT_TOKENS in src/tokens/colors.ts) ---- + // The label color `current.primary` punches out of its `currentcolor` chip. + // Defaults to `#surface`; a container painting `#white` overrides it. + 'current-fill': true; + // ---- Other primitives ---- placeholder: true; focus: true; diff --git a/src/tokens/colors.ts b/src/tokens/colors.ts index e7f947540..016b10df7 100644 --- a/src/tokens/colors.ts +++ b/src/tokens/colors.ts @@ -140,7 +140,36 @@ const LEGACY_ALIASES: Styles = { }; /** - * Combined color token map: Glaze-generated palette + legacy aliases. + * Context hooks — tokens a CONTAINER sets to steer a component that cannot + * resolve the color on its own. + * + * Declared with a default rather than read as `var(--x, fallback)` at every use + * site, for two reasons: the default is stated once instead of repeated (and so + * cannot drift), and a declared token gets its components companion for free — + * which is the only way `#current-fill.5` can fade whatever the container + * offered rather than the fallback. + * + * Included by reference in {@link renderColorTokens} alongside the legacy + * aliases, so a region preview re-resolves the default against that region's own + * `#surface` instead of freezing the outer theme's. + */ +const CONTEXT_TOKENS: Styles = { + // The label color `current.primary` punches out of its `currentcolor` chip — + // read by nothing else. That flavour fills with the color it INHERITS, so its + // label has to contrast with an arbitrary color, and the page only manages + // that while the inherited color sits away from the page. A container + // whose own text color IS the page breaks it: a `Banner` labels itself + // `#white`, and `#surface` is white in light mode, so label and chip collapse + // to cr 1.00. + // + // Such a container sets this to a color that contrasts with its own fill — + // usually its own surface — and the label, the rim and the icon slots all + // follow. See `CURRENT_PRIMARY_STYLES` in `src/data/item-themes.ts`. + '#current-fill': '#surface', +}; + +/** + * Combined color token map: Glaze-generated palette + legacy aliases + context hooks. * * Memoized against the palette config version, so a runtime `setPaletteConfig()` * invalidates it while repeated reads stay free. Prefer {@link getColorTokens} @@ -159,6 +188,7 @@ export function getColorTokens(): Styles { colorTokensCache = { ...getPaletteTokens(), ...LEGACY_ALIASES, + ...CONTEXT_TOKENS, }; cachedVersion = version; } @@ -224,6 +254,7 @@ export function renderColorTokens(options?: RenderPaletteOptions): Tokens { return { ...renderPaletteTokens(options), ...(LEGACY_ALIASES as Tokens), + ...(CONTEXT_TOKENS as Tokens), ...(COLOR_DEPENDENT_TOKENS as Tokens), }; } diff --git a/src/tokens/palette.ts b/src/tokens/palette.ts index b418bb8d3..965c5fc37 100644 --- a/src/tokens/palette.ts +++ b/src/tokens/palette.ts @@ -1225,7 +1225,7 @@ function buildPalette( // // The pair above (`accent-disabled-surface` / `-text`) stays what it always // was: the muted chip for a PRIMARY button, whose enabled state is an opaque - // brand fill under `#white`. Borrowing it here inverted the state — its + // brand fill under `#white`. Borrowing it here got the state backwards — its // mid-tone chip is a step DOWN from a solid fill but a step UP from a 9% // tint, and its `tone: 'max'` label resolves to literal white in light mode. 'accent-disabled-text': { From 545af49a2f865d6f823a6bed97e38ea5e96b7869 Mon Sep 17 00:00:00 2001 From: Andrey Yamanov Date: Thu, 20 Aug 2026 16:03:53 +0200 Subject: [PATCH 26/26] fix(themes): derive the `current` dark ramp, drop the special one MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- .changeset/current-theme-axis.md | 4 +- .changeset/item-action-current-default.md | 4 +- .../actions/ItemAction/ItemAction.tsx | 6 -- .../content/ItemBadge/ItemBadge.tsx | 12 +-- src/data/item-themes.ts | 91 ++++++++++++------- src/eslint-plugin/fixtures.tsx | 11 +-- 6 files changed, 68 insertions(+), 60 deletions(-) diff --git a/.changeset/current-theme-axis.md b/.changeset/current-theme-axis.md index bda63ae2d..45f766230 100644 --- a/.changeset/current-theme-axis.md +++ b/.changeset/current-theme-axis.md @@ -18,7 +18,7 @@ On the `theme` axis it composes instead, and every type now has a `current` flav `current.outline` and `current.item` are byte-identical to the old `Button` and `Item` flavours, so nothing that used `type="current"` changes appearance. -The top step of each ramp stops at `#current.24`. That is the measured AA floor for a full-strength label on a dark surface — the one place this construction turns around, since the chip climbs toward an equally light label instead of away from a dark one. +The top step of each ramp stops at `#current.24` in light. The dark counterpart is not authored: each `@dark` step is solved so its OKHST tone delta from the surface matches the light step's, which lands the two schemes on the same chip-vs-page contrast (1.084 / 1.083 at hover, 1.959 / 1.961 at the top step). That works out *lower* than the light alpha throughout — `.031 / .046 / .13 / .175 / .221` against `.04 / .06 / .18 / .24 / .3` — because near the dark end of the scale a small sRGB move is a large perceptual one, so the same tint reads stronger on a dark surface than on a light page. ### Migration @@ -41,7 +41,7 @@ This changes one case: an action that named a `type` but no `theme` used to inhe `Banner` is the one in-repo consumer that needed a matching edit. Its actions ask for `type="outline"` and then cleared the border, because back then `outline` meant `note.outline` and friends — whose border is the opaque `#note-border`, a pale line built for a `#surface-2` chip on a light page and plainly wrong on a saturated banner. The fill carried the chip on its own there. On the `current` theme the border is `#current.08` mixed from the banner's own white label, and the fill is a 3% tint that cannot carry a chip by itself, so clearing the border left the action invisible. The override is gone and the type renders as designed. -The host theme still reaches the element, as `data-surface`: it names the surface an action is painted _on_, which is a different question from its own theme now that `current` occupies that axis. The `current` ramp reads it to pick the alphas that work over the `special` theme's fixed dark-purple surface — the job `data-theme` used to do before `theme="current"` claimed that attribute. +`ItemActionContext` no longer reaches the DOM at all beyond `isDisabled`. An interim version of this branch published the host theme as a `data-surface` attribute so the `current` ramp could pick per-surface alphas for the `special` theme's fixed dark-purple surface; that is gone, because only `ItemAction` and `ItemBadge` ever set the attribute, so `Button` and `Item` on the same surface silently fell back to the light ramp. ### Two nesting fixes diff --git a/.changeset/item-action-current-default.md b/.changeset/item-action-current-default.md index 82f65f453..fbf4e42b3 100644 --- a/.changeset/item-action-current-default.md +++ b/.changeset/item-action-current-default.md @@ -10,8 +10,8 @@ Passing an explicit `theme` opts an action out of the inherited color and into t Supporting changes: -- **Selection reads as a filled chip.** Every other type marks `isSelected` with a brand *hue* — an accent-tinted fill under an accent label — and `current` has one inherited color to work with, so it cannot. Alpha is the only channel left, and the neutral types' `.09` step read as a slightly dirty background rather than an "on" state, so a selected `ItemAction` / `ItemBadge` looked unselected. Selection now jumps clear of the interaction steps (`.18` in light) instead of continuing them, while hover and press stay subtle so a row full of actions is not busy. On dark surfaces the same construction turns around — a light chip climbing toward an equally light label — and both the dark scheme and the special theme hit the AA floor for their label at exactly `.24`, so their steps are written under that measured ceiling and `selected` is a smaller jump there. -- **A scheme-aware alpha ramp.** Unlike the brand tokens, `#current` alphas do not adapt to the color scheme — a 4% tint of a dark label on a light surface reads far stronger than a 4% tint of a light label on a dark one. Each step now carries a per-surface value: the base entry for light, `@dark` for the dark scheme, and a single fixed ramp for the `special` theme's surface (special is static across light/dark/HC by design). Each step lives in its own custom property rather than inline in `fill`, because three ramps in one state-map would put ~18 alpha values where Tasty's `mergeEntriesByValue` pass coalesces equal value strings into one OR-entry at the group's max priority and breaks negation against lower-priority rules. +- **Selection reads as a filled chip.** Every other type marks `isSelected` with a brand *hue* — an accent-tinted fill under an accent label — and `current` has one inherited color to work with, so it cannot. Alpha is the only channel left, and the neutral types' `.09` step read as a slightly dirty background rather than an "on" state, so a selected `ItemAction` / `ItemBadge` looked unselected. Selection now jumps clear of the interaction steps (`.18` in light) instead of continuing them, while hover and press stay subtle so a row full of actions is not busy. The dark scheme takes the same jump, scaled by the tone match described below. +- **A scheme-aware alpha ramp.** Unlike the brand tokens, `#current` alphas do not adapt to the color scheme: the same tint is not the same step in light and dark. Each step therefore carries a base entry for light and an `@dark` counterpart, the dark one *derived* — solved so its OKHST tone delta from the surface matches the light step's, which is also what puts the chip on the light step's contrast against the page. The direction is counter-intuitive: the dark alphas come out lower, because near the dark end of the scale a small sRGB move is a large perceptual one. Each step lives in its own custom property rather than inline in `fill`, because both ramps in one state-map would put twelve alpha values where Tasty's `mergeEntriesByValue` pass coalesces equal value strings into one OR-entry at the group's max priority and breaks negation against lower-priority rules. - **`ItemAction` regains a focus ring.** `CURRENT_ITEM_STYLES` follows the `*_ITEM_STYLES` convention of leaving focus to the collection that owns the row, which is wrong for a focusable action, so the ring came back on the action itself. - **`ItemButton` paints its actions' color.** It renders actions as a sibling of the button rather than inside it — deliberately, so they are not nested in a `