diff --git a/.changeset/current-single-fade.md b/.changeset/current-single-fade.md new file mode 100644 index 000000000..ef6a617e2 --- /dev/null +++ b/.changeset/current-single-fade.md @@ -0,0 +1,9 @@ +--- +'@cube-dev/ui-kit': patch +--- + +Fix the `current` theme fading a disabled label twice. + +`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. diff --git a/.changeset/current-theme-axis.md b/.changeset/current-theme-axis.md new file mode 100644 index 000000000..45f766230 --- /dev/null +++ b/.changeset/current-theme-axis.md @@ -0,0 +1,56 @@ +--- +'@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, and the one flavour that fills opaquely, like every other theme's `primary`: the fill is the inherited color at full opacity and the label is punched out of it with the new `#current-fill` token, which defaults to `#surface` — the page background, which is always the opposite of the text painted on it and so follows 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 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. + + `#current-fill` is a real color token with a default, not a bare custom property, so it takes the alpha suffix (`#current-fill.5` is the disabled label) and a container overrides it with one declaration — `styles={{ '#current-fill': '#fixed-dark' }}` — moving the label, the icon slots and the rim together. It exists for the one container the `#surface` default is wrong for: a container whose own text color IS the page paints `#white`, which IS `#surface` in light mode, so an unaided label measures cr 1.00 against its own chip. Its own fill is the right value there, since it contrasts with its own text by construction. Ordinary containers set nothing. +- `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` 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 + +**`type="current"` is removed with no runtime fallback.** It resolves to no variant and falls back to base styles, the same as any other unknown type — there is no mapping and no deprecation warning. The spelling shipped one release ago and has no consumers outside the kit, so this is a clean break rather than a deprecation: + +| 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..abc820d03 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 ( @@ -229,13 +238,6 @@ const TypeStatesRow = ({ > Pressed </Button> - <Button - type={type} - theme={theme} - mods={{ ...BASE_MODS, hovered: true, pressed: true }} - > - Pressed&Hovered - </Button> <Button type={type} theme={theme} @@ -279,18 +281,6 @@ const TypeStatesRow = ({ > Pressed </Button> - <Button - type={type} - theme={theme} - mods={{ - ...BASE_MODS, - selected: true, - hovered: true, - pressed: true, - }} - > - Pressed&Hovered - </Button> <Button type={type} theme={theme} @@ -320,25 +310,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 +336,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 +386,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 one type that needs a second color — it fills with the inherited color at full strength, so its label and rim come from `#current-fill`, which defaults to the page (`#surface`). `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,27 +535,46 @@ 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. +// `currentFill` is the `#current-fill` token: the color `current.primary` +// punches its label out of the `currentcolor` chip with. Only the containers +// whose own text color IS the page need to set it — they paint `#white`, so the default +// (`#surface`) collides with the inherited color and the label vanishes into its +// own chip. Each sets its own fill, which contrasts with its own text by +// construction. The tinted containers leave it unset. 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', + currentFill: '#fixed-dark', + }, + { + label: 'Brand', + fill: '#primary', + color: '#white', + currentFill: '#primary', + }, ] as const; const CurrentContext = ({ label, fill, color, + currentFill, children, }: { label: string; fill?: string; color?: string; + /** `#current-fill` — the label color `current.primary` reads. */ + currentFill?: string; children: ReactNode; }) => ( <Space @@ -569,6 +584,7 @@ const CurrentContext = ({ border={fill ? undefined : true} fill={fill} color={color} + styles={currentFill ? { '#current-fill': currentFill } : undefined} > <Title level={6} color={color}> {label} @@ -577,25 +593,62 @@ 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 }) => ( - + {CURRENT_CONTEXTS.map(({ label, fill, color, ...rest }) => ( + - - - - - + ))} ))} @@ -616,7 +669,7 @@ export const CurrentType: StoryFn = () => ( @@ -630,7 +683,7 @@ export const CurrentType: StoryFn = () => ( {(['xsmall', 'small', 'medium', 'large', 'xlarge'] as const).map( (size) => ( - ), @@ -640,13 +693,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` fills with the inherited color at full strength and punches its label out of it with `#current-fill` — the one flavour a container may need to override, since that token defaults to the page and a container painting `#white` would collide with it; `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..8211c4cb3 100644 --- a/src/components/actions/Button/Button.tsx +++ b/src/components/actions/Button/Button.tsx @@ -23,7 +23,11 @@ import { 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, @@ -609,10 +629,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'); + }); + }); + 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..ba83862a8 100644 --- a/src/components/actions/ItemAction/ItemAction.tsx +++ b/src/components/actions/ItemAction/ItemAction.tsx @@ -10,7 +10,9 @@ import { } from 'react'; import { - CURRENT_ITEM_STYLES, + CURRENT_CLEAR_STYLES, + CURRENT_OUTLINE_STYLES, + CURRENT_PRIMARY_STYLES, DANGER_CLEAR_STYLES, DANGER_OUTLINE_STYLES, DANGER_PRIMARY_STYLES, @@ -45,8 +47,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 +67,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 +95,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 +130,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, @@ -168,24 +175,19 @@ export const ItemAction = forwardRef(function ItemAction( ) { const { type: contextType, - theme: contextTheme, disableActionsFocus, 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', + // 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, @@ -201,9 +203,9 @@ export const ItemAction = forwardRef(function ItemAction( 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,9 +327,7 @@ export const ItemAction = forwardRef(function ItemAction( return ( ``` -### 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..310f3f659 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,93 @@ 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. +// +// `primary` is worth a close look here: it is the one flavour that needs a color +// the theme does not inherit, so its label and rim come from `#current-fill`. +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 exception that proves the rule: it fills with the inherited color at full strength, so its label and rim cannot also come from that color and are read from `#current-fill` instead, which defaults to the page (`#surface`). `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..b91878868 100644 --- a/src/components/content/Item/Item.test.tsx +++ b/src/components/content/Item/Item.test.tsx @@ -10,6 +10,34 @@ 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'); + }); + }); + 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..0301ff095 100644 --- a/src/components/content/Item/Item.tsx +++ b/src/components/content/Item/Item.tsx @@ -143,7 +143,6 @@ export interface CubeItemProps extends BaseProps, ContainerStyleProps { | 'clear' | 'link' | 'card' - | 'current' | (string & {}); theme?: | 'default' @@ -152,6 +151,7 @@ export interface CubeItemProps extends BaseProps, ContainerStyleProps { | 'special' | 'warning' | 'note' + | 'current' | (string & {}); /** Keyboard shortcut that triggers the element when pressed */ hotkeys?: string; @@ -590,7 +590,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 +599,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 +622,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.'), ], }); @@ -757,6 +759,30 @@ const Item = ( const finalPrefix = isLoading && resolvedLoadingSlot === 'prefix' ? : prefix; + // Which HotKeys flavour the shortcut hint wears. `inherit` paints the hint's + // glyphs and rim from `currentcolor`, so it tracks whatever the row labels + // itself with; `primary` pins the hint to `#white`; `default` is a neutral + // `#dark.65` chip built for a page-colored row. + // + // The whole `current` theme wants `inherit`: every flavour there derives its + // label from the inherited color, and a fixed hint cannot follow that. The + // neutral `#dark.65`-on-`#dark.04` chip all but vanishes on a dark overlay, + // which is exactly where this theme is meant to be used. + // + // `current.primary` is included despite painting its label with + // `-webkit-text-fill-color` rather than `color`. The hint renders inside the + // `Suffix` slot, and `CURRENT_PRIMARY_STYLES` recolors that slot to the label + // color — so `currentcolor` there is the LABEL, not the fill. An earlier + // version of this comment claimed the opposite and sent it to `primary`, which + // put a `#white` hint on a light fill (measured cr 2.17 on a `#note` container + // in dark mode). + const hotkeysType = + theme === 'current' + ? 'inherit' + : type === 'primary' + ? 'primary' + : 'default'; + // Build final suffix: loading icon, custom suffix, or HotKeys hint const finalSuffix = isLoading && resolvedLoadingSlot === 'suffix' ? ( @@ -766,7 +792,7 @@ const Item = ( (hotkeys ? ( {hotkeys} 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..99d405cbc 100644 --- a/src/components/content/ItemBadge/ItemBadge.tsx +++ b/src/components/content/ItemBadge/ItemBadge.tsx @@ -9,7 +9,9 @@ import { } from 'react'; import { - CURRENT_ITEM_STYLES, + CURRENT_CLEAR_STYLES, + CURRENT_OUTLINE_STYLES, + CURRENT_PRIMARY_STYLES, DANGER_CLEAR_STYLES, DANGER_OUTLINE_STYLES, DANGER_PRIMARY_STYLES, @@ -35,8 +37,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 +53,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 +80,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 +112,17 @@ 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. - const { type: contextType, theme: contextTheme } = useItemActionContext(); + // which drives the `context` mod below. It does not pick the variant any + // more. + const { type: contextType } = 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', + // 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, @@ -207,12 +219,7 @@ export const ItemBadge = forwardRef( return ( { + 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, + * 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 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] { + const match = value.match(/^(.*?)(\.\d+)$/); + + if (!match) return [value, 1]; + + return [match[1], Number(match[2])]; + } + + 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, ...CURRENT].sort()); + }); + + it.each(VARIANTS)( + '%s keeps the selected chip when disabled, changing only the alpha', + (variant) => { + const fill = ITEM_VARIANTS[variant].fill as Record; + 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(VARIANTS)('%s fades the label when disabled', (variant) => { + const color = ITEM_VARIANTS[variant].color as Record; + + expect(color['selected & disabled']).toBeDefined(); + 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.item', + 'current.link', + 'current.outline', + 'current.outline-2', + 'current.primary', + ]); + }); + + // Every `current` flavour fades its label to 40% of the inherited color, and + // they all express it the same way. `current.primary` is no exception: its + // `color` IS the fill there, so this is what fades the chip too (see + // `CURRENT_PRIMARY_STYLES.color`). + const FADED = '#current.4'; + + it.each(CURRENT_VARIANTS)('%s gates its label fade', (variant) => { + const color = ITEM_VARIANTS[variant].color as Record; + + expect(color[GATE]).toBe(FADED); + // 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 + >; + + // `current.primary` is the exception, and deliberately: its `color` is the + // FILL, not the label, so reproducing it on the wrapper would hand sibling + // actions the chip color and they would vanish into it. The wrapper gets + // `#current-fill` — the same thing the `Actions` slot gets inside a plain + // `Item`. Everything else hands down its own faded label. + if (variant === 'current.primary') { + expect(color.disabled).toBe('#current-fill.5'); + return; + } + + expect(color.disabled).toBe(FADED); + }, + ); + + // `#current-fill` is read by `current.primary` and by nothing else. Every other + // flavour paints its chip ON the container, so the inherited color is already + // the right one to write with and an offered one would only lower contrast — a + // `#danger-accent-text` dismiss icon on a danger banner measures 1.53, against + // the 4.62 the inherited `#white` gets. + it('is read by current.primary only', () => { + const readers = CURRENT_VARIANTS.filter((variant) => + JSON.stringify(ITEM_VARIANTS[variant]).includes('#current-fill'), + ); + + expect(readers).toEqual(['current.primary']); + }); + + // Every slot that carries the label carries it identically, so a container + // that sets `#current-fill` moves all of them at once and cannot leave one + // behind. The rim is in the list on purpose: it is the same "opposite side of + // the fill" color, at `.25`. + it('current.primary paints label, rim and icon slots from it', () => { + const primary = ITEM_VARIANTS['current.primary'] as Record; + + expect(primary.fill['']).toContain('#current'); + expect(primary.border['']).toBe('#current-fill.25'); + expect(primary['-webkit-text-fill-color']['']).toBe('#current-fill'); + + for (const slot of ['Icon', 'RightIcon', 'Prefix', 'Suffix', 'Actions']) { + expect(primary[slot].color['']).toBe('#current-fill'); + expect(primary[slot].color.disabled).toBe('#current-fill.5'); + } + }); + + // `#current`-derived fades gate, because a disabled host has already muted + // what they resolve against. `#current-fill` is not inherited from that faded + // color — a container offers the live value only — so its fade is the reader's + // own job and takes a bare `disabled`. + it('gates the inherited fade and not the offered one', () => { + const primary = ITEM_VARIANTS['current.primary'] as Record; + + expect(primary.color[GATE]).toBe('#current.4'); + expect(primary.color.disabled).toBeUndefined(); + + expect(primary['-webkit-text-fill-color'].disabled).toBe('#current-fill.5'); + expect(primary['-webkit-text-fill-color'][GATE]).toBeUndefined(); + }); +}); diff --git a/src/data/item-themes.ts b/src/data/item-themes.ts index 5e7ec2172..9d49bdbaf 100644 --- a/src/data/item-themes.ts +++ b/src/data/item-themes.ts @@ -128,9 +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` 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. + // + // 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', @@ -139,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', + 'selected & disabled': '#surface-2 #primary-accent-surface.08', }, color: { '': '#surface-text-soft', @@ -148,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-text', + 'selected & disabled': '#primary-accent-disabled-text', }, } as const; @@ -164,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', + 'selected & disabled': '#surface-3 #primary-accent-surface.08', }, } as const; @@ -187,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', + 'selected & disabled': '#primary-accent-surface.08', }, // Selected label mirrors LINK: soft at rest, `#primary-accent-text` on // hover. See DEFAULT_OUTLINE_STYLES. @@ -198,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-text', + 'selected & disabled': '#primary-accent-disabled-text', }, } as const; @@ -288,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', + '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-text', + 'selected & disabled': '#danger-accent-disabled-text', }, } as const; @@ -308,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', + 'selected & disabled': '#surface-3 #danger-accent-surface.08', }, } as const; @@ -331,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', + 'selected & disabled': '#danger-accent-text.08', }, color: { '': '#danger-accent-text-soft', selected: '#danger-accent-text', disabled: '#disabled-surface-text', - 'selected & disabled': '#danger-accent-disabled-surface-text', + 'selected & disabled': '#danger-accent-disabled-text', }, } as const; @@ -419,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', + '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-text', + 'selected & disabled': '#success-accent-disabled-text', }, } as const; @@ -439,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', + 'selected & disabled': '#surface-3 #success-accent-surface.08', }, } as const; @@ -460,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', + 'selected & disabled': '#success-accent-text.08', }, color: { '': '#success-accent-text-soft', selected: '#success-accent-text', disabled: '#disabled-surface-text', - 'selected & disabled': '#success-accent-disabled-surface-text', + 'selected & disabled': '#success-accent-disabled-text', }, } as const; @@ -548,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', + '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-text', + 'selected & disabled': '#warning-accent-disabled-text', }, } as const; @@ -568,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', + 'selected & disabled': '#surface-3 #warning-accent-surface.08', }, } as const; @@ -589,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', + 'selected & disabled': '#warning-accent-text.08', }, color: { '': '#warning-accent-text-soft', selected: '#warning-accent-text', disabled: '#disabled-surface-text', - 'selected & disabled': '#warning-accent-disabled-surface-text', + 'selected & disabled': '#warning-accent-disabled-text', }, } as const; @@ -677,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', + '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-text', + 'selected & disabled': '#note-accent-disabled-text', }, } as const; @@ -697,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', + 'selected & disabled': '#surface-3 #note-accent-surface.08', }, } as const; @@ -718,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', + 'selected & disabled': '#note-accent-text.08', }, color: { '': '#note-accent-text-soft', selected: '#note-accent-text', disabled: '#disabled-surface-text', - 'selected & disabled': '#note-accent-disabled-surface-text', + 'selected & disabled': '#note-accent-disabled-text', }, } as const; @@ -839,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 the wrong way round, 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; @@ -871,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 white 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 @@ -895,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 @@ -904,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 selected white pill. See `SPECIAL_OUTLINE_STYLES.color`. + disabled: '#white.23', + 'selected & disabled': '#special-accent-text.45', }, } as const; @@ -922,7 +964,7 @@ export const SPECIAL_LINK_STYLES: Styles = { color: { '': '#white', 'hovered & !pressed': '#white.9', - disabled: '#white.4', + disabled: '#white.23', }, } as const; @@ -935,16 +977,20 @@ export const SPECIAL_ITEM_STYLES: Styles = { }, color: { '': '#white', - disabled: '#white.4', + disabled: '#white.23', }, } as const; -// ---------- CURRENT TYPE ---------- +// ---------- CURRENT THEME ---------- // Every color is derived from the *inherited* text color (`#current` → // `currentcolor`), so the element adopts whatever color its context paints -// with: a colored Alert, a dark banner, an image overlay, a chart tooltip. That -// makes the type theme-agnostic — a single style object registered under -// `default.current`, with the host component forcing the theme to `default`. +// with: a colored Alert, a dark banner, an image overlay, a chart tooltip. +// +// That makes it a THEME rather than a type. The other themes each pick a brand +// ramp and let `type` decide the shape (filled, outlined, borderless, textual); +// `current` picks the inherited color and lets `type` decide the same shapes. +// Every type therefore has a flavour here, and the `theme` axis is what a host +// component switches — `type` keeps meaning what it means everywhere else. // // `color: '#current'` compiles to `color: currentcolor`, which CSS resolves as // `color: inherit` — so the label stays fully opaque and, crucially, the @@ -952,13 +998,6 @@ export const SPECIAL_ITEM_STYLES: Styles = { // color rather than a faded one. The alpha steps are then mixed off that same // color, which is why the whole ramp tracks the context automatically. // -// There are two flavours, mirroring the two shapes the neutral types take: -// `CURRENT_ITEM_STYLES` follows `*_ITEM_STYLES` (borderless, invisible at rest, -// for list rows) and `CURRENT_BUTTON_STYLES` follows the standalone button types -// (a resting chip with a border). Both keep the monotonic-contrast pattern of -// their neutral counterparts: default < hover < pressed, and the same again one -// level up when selected. -// // IMPORTANT: every alpha step within one state-map must be a unique value // string — Tasty's `mergeEntriesByValue` pass coalesces equal values into one // OR-entry at the group's max priority, which then negates against @@ -969,83 +1008,122 @@ export const SPECIAL_ITEM_STYLES: Styles = { // `currentcolor`, so every other alpha is multiplied by .4 on that state. // Disabled `fill`/`border` are therefore written PRE-MULTIPLIED where they need // to stay visible. +// +// A note on what a single color cannot do: `primary` in every other theme is an +// opaque brand fill under a `#white` label, and the inherited color is the only +// thing on hand here — it can be the fill, but then nothing is left to punch the +// label out with. `current.primary` therefore takes its label from a second +// token, `#current-fill`, which defaults to the page and which a container can +// redirect when the page is the wrong answer. See `CURRENT_PRIMARY_STYLES`. // The alpha ramp for the item flavour, held in custom properties rather than // written inline in `fill`. Two reasons: // // 1. 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, so one ramp cannot serve -// both. Each step therefore carries a per-surface value — the base entry for -// the light scheme, `@dark` for the dark scheme, and `theme=special` for the -// special theme's dark-purple surface. Special is *static* (identical in -// light, dark and HC by design — see the SPECIAL section above), so it needs -// a single ramp rather than a light/dark pair. `theme=special` resolves -// against the element's own `data-theme`, which `ItemAction` sets from the -// surrounding `ItemActionProvider`. -// 2. Writing three ramps straight into one `fill` map would put ~18 alpha +// scheme, so one ramp cannot serve both. Each step therefore carries the base +// entry for the light scheme and an `@dark` counterpart. +// 2. Writing both ramps straight into one `fill` map would put twelve alpha // values in a single state-map, and Tasty's `mergeEntriesByValue` pass // coalesces any two equal value strings into one OR-entry at the group's max // priority, which then negates against lower-priority rules. Giving each -// step its own 3-entry map keeps every value string unique by construction — +// step its own 2-entry map keeps every value string unique by construction — // the constraint that `SPECIAL_OUTLINE_STYLES` documents the hard way. // -// The special steps run higher than the light ones because they resolve against -// a `#white.8` label: an authored `.15` nets roughly the `.12` that -// `SPECIAL_CLEAR_STYLES` uses on the same base. +// THE DARK STEPS ARE DERIVED, NOT AUTHORED. The same alpha is not the same step +// in both schemes, and the direction is the opposite of what it looks like: 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. Each `@dark` value is therefore 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 to three decimals: +// +// 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 +// +// Every dark value is the solved one rounded to the shortest alpha that keeps it +// within 2% of the target — `selected` is `.13` rather than the exact `.132` +// because tasty computes the mix percentage as `parseFloat(alpha) * 100`, and +// `.132` lands on `13.200000000000001%` in the emitted CSS. +// +// Measured against `#surface` / `#surface-text` in each scheme, which is the only +// tractable calibration: `current` paints from an arbitrary inherited color over +// an arbitrary container, so a single ramp cannot be exact for all of them. The +// neutral page pair is the common case, and matching it is what keeps the two +// schemes recognisably the same ramp. Re-derive with Glaze's `oklabToOkhsl` + +// `okhslToOkhst` if the neutral tokens move. +// +// Note that the label keeps its own margin throughout: the weakest dark step +// still measures cr 13.3 against a near-white label and the strongest 7.3, so +// nothing here approaches an AA floor. +// +// There is deliberately NO special-surface ramp. The `special` theme's fixed +// dark-purple surface used to get its own `surface=special` steps, read from a +// `data-surface` attribute — but only `ItemAction` and `ItemBadge` ever set it, +// so `Button` and `Item` on the same surface silently used the light ramp, and +// `theme` can no longer be `special` and `current` at once. A per-surface axis +// that three of five call sites miss is worse than not having one. // // SELECTED steps jump well clear of the interaction steps rather than continuing -// them. Every other type marks selection with a brand *hue* — an accent-tinted +// them. Every other theme marks selection with a brand *hue* — an accent-tinted // fill under an accent label — and `current` has exactly one color to work with, // so it cannot. Alpha is the only channel left, and a step that merely continued // the hover/press ramp (the original `.04 → .06 → .09`) read as a slightly dirty // background rather than an "on" state. Selection is a persistent state, not a // transient one, so it earns the bigger jump; hover and press stay subtle so an // unselected row full of actions does not look busy. -// Only the LIGHT ramp can spend freely. There the chip is a pale tint and the -// label stays opaque and dark, so contrast barely moves (.30 still measures -// 5.66:1). On a dark surface the same construction inverts: the chip is a light -// tint climbing toward an equally light label, so it swallows it. Both dark -// surfaces hit the AA floor (4.5:1) for their label at exactly `.24` — measured, -// not guessed — which is the ceiling every dark step below is written under, and -// why `selected` there is a smaller jump than in light. `SPECIAL_CLEAR_STYLES` -// escaped the same ceiling by INVERTING selected to a white pill with dark text; -// a single inherited color cannot do that. +// +// The jump is authored once, on the light ramp, and the dark one follows from the +// tone match below rather than being tuned against a contrast ceiling of its own. +// An earlier version of this comment claimed 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 is no +// ceiling to write under — which is why removing the special ramp also removes +// the reason the dark steps were shaped by hand. const CURRENT_ITEM_RAMP: Styles = { '$current-hover': { '': '#current.04', - '@dark': '#current.07', - 'theme=special': '#current.08', + '@dark': '#current.031', }, '$current-press': { '': '#current.06', - '@dark': '#current.11', - 'theme=special': '#current.12', + '@dark': '#current.046', }, '$current-selected': { '': '#current.18', - '@dark': '#current.16', - 'theme=special': '#current.17', + '@dark': '#current.13', }, '$current-selected-hover': { '': '#current.24', - '@dark': '#current.19', - 'theme=special': '#current.21', + '@dark': '#current.175', }, '$current-selected-press': { '': '#current.3', - '@dark': '#current.22', - 'theme=special': '#current.24', + '@dark': '#current.221', + }, +} as const; + +// The focus ring is the one color NOT taken from `#current`: every theme in this +// file uses `#primary-accent-text` (the special theme swapping in its fixed-mode +// counterpart), so the focus indicator stays the same wherever it appears. +const CURRENT_FOCUS_RING: Styles = { + outline: { + '': '0 #primary-accent-text.0', + focused: '1bw #primary-accent-text', }, } as const; // Item flavour — the `current` counterpart of `*_ITEM_STYLES`: no border, // nothing painted at rest, the fill appearing only on interaction. Used for -// list rows (`Item`, `ItemButton`) and, as the default type, the actions inside -// them — where a resting chip on every row would read as noise. Like the other -// `*_ITEM_STYLES` it leaves the focus ring to the base styles (the collection -// that owns the row indicates focus), and only steps the fill. `ItemAction` -// adds a ring of its own on top, since a focusable action is not a list row. +// list rows (`Item`, `ItemButton`), where a resting chip on every row would read +// as noise. Like the other `*_ITEM_STYLES` it leaves the focus ring to the base +// styles (the collection that owns the row indicates focus), and only steps the +// fill. export const CURRENT_ITEM_STYLES: Styles = { ...CURRENT_ITEM_RAMP, border: 'transparent', @@ -1060,36 +1138,68 @@ 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; + +// Clear flavour — the item ramp on a focusable control. `*_CLEAR_STYLES` and +// `*_ITEM_STYLES` differ by exactly this in every other theme too: same +// borderless shape, same interaction fill, plus the ring a standalone control +// needs. It is the default flavour for `ItemAction` and `ItemBadge`. +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; -// Button flavour — a standalone control, so it carries its own weight: a +// Outline flavour — a standalone control, so it carries its own weight: a // resting `#current.03` chip inside a `#current.08` border. `.03` is enough to // separate the button from a flat background without reading as a filled // surface, while leaving room for four distinguishable steps above it. // Disabled `fill`/`border` are pre-multiplied (`.06`/`.12` → an effective // `.024`/`.048`) so the chip stays a muted version of itself instead of // vanishing. -export const CURRENT_BUTTON_STYLES: Styles = { - // The focus ring is the one color NOT taken from `#current`: every type in - // this file uses `#primary-accent-text` (the special theme swapping in its - // fixed-mode counterpart), so the focus indicator stays the same wherever it - // appears. - outline: { - '': '0 #primary-accent-text.0', - focused: '1bw #primary-accent-text', - }, +export const CURRENT_OUTLINE_STYLES: Styles = { + ...CURRENT_FOCUS_RING, border: { '': '#current.08', 'hovered | focused': '#current.15', @@ -1114,12 +1224,197 @@ export const CURRENT_BUTTON_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; + +// Outline-2 flavour — `outline` for a container that is already painting +// something. In the brand themes the difference is the opaque base (`#surface-3` +// instead of `#surface-2`); `current` has no opaque base to swap, since every +// step is a translucent tint over whatever is behind it. The same intent — +// "stay legible one rung further up the surface ladder" — is therefore carried +// by a heavier tint at every step: roughly double `outline`'s resting chip, so +// the control still separates from a tinted or busy container. +export const CURRENT_OUTLINE_2_STYLES: Styles = { + ...CURRENT_FOCUS_RING, + border: { + '': '#current.14', + 'hovered | focused': '#current.22', + pressed: '#current.3', + selected: '#current.36', + 'selected & pressed': '#current.45', + disabled: '#current.2', + 'selected & disabled': '#current.5', + }, + fill: { + '': '#current.06', + 'hovered | focused': '#current.11', + pressed: '#current.15', + selected: '#current.17', + 'selected & (hovered | focused)': '#current.21', + // `.24` is the measured AA ceiling for a full-strength label on a dark + // surface (see the ramp comment above) — the top step stops there rather + // than continuing the interval. + 'selected & pressed': '#current.24', + disabled: '#current.1', + 'selected & disabled': '#current.26', + }, + 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; + +// Primary flavour — the high-emphasis control, and the only `current` flavour +// that fills opaquely: the fill is the inherited color at full opacity and the +// label is punched out of it with `#current-fill`, exactly as every other +// theme's `primary` paints `#white` on an opaque brand fill. +// +// `#current-fill` is the whole answer to the one problem this flavour has. The +// pill IS `currentcolor`, so the label has to contrast with an arbitrary color, +// and the page (`#surface`) only manages that while the inherited color sits +// away from the page. A container whose own text color IS the page breaks it: a +// dark banner paints `#white`, so the pill is white and a `#surface` label is +// white too in light mode — cr 1.00, the button reads as a blank chip. Such a +// container has the answer to hand — its own fill contrasts with its own text by +// construction, and the pill is that text — so it sets `#current-fill` and the +// label, the rim and the icon slots all follow. The token defaults to `#surface` +// (see `CONTEXT_TOKENS` in `src/tokens/colors.ts`), which is what this flavour +// always used, so nothing outside such a container moves. +// +// 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: '#current-fill'` 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, + // 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, and the one a container can + // redirect, so the rim cannot come apart from the label it edges. `.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-fill.25', + disabled: '#surface-text.2', + }, + // The resting entry carries a transparent second layer so every state has the + // same two-layer shape and the overlay interpolates instead of snapping in — + // the same pin `DEFAULT_PRIMARY_STYLES.fill` documents. Without it the resting + // rule emits no `background-image` at all, so hover has nothing to animate + // from. + fill: { + '': '#current #black.0', + 'hovered | focused': '#current #black.08', + pressed: '#current #black.16', + }, + // Disabled is expressed HERE and nowhere else, and that is the whole trick. + // `fill` resolves `currentcolor` against this element's own `color`, so fading + // it once fades the chip with it: the default entry still reads `#current`, + // which under `disabled` is already the `.4` color, and the chip lands at + // exactly `.4`. Writing `.4` in both 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', + // 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', + }, + // The label. Faded on a bare `disabled`, unlike the `#current`-derived values + // around it: the gate those carry protects an INHERITED value, since `#current` + // is already muted by a disabled host and fading it again would multiply. + // `#current-fill` is not inherited from that faded color — a container offers + // the live value only — so this has to do the fading itself. + '-webkit-text-fill-color': { + '': '#current-fill', + disabled: '#current-fill.5', + }, + // Every slot that paints from `currentColor` rather than from the glyph fill. + // `-webkit-text-fill-color` inherits into the text slots for free, but two + // kinds of descendant escape it and would otherwise keep `color` — which on + // this flavour is the FILL — and vanish into the chip they match: + // + // the icon slots, whose SVG is stroked with `currentColor`; + // `Actions`, because a nested `Item.Action` defaults to `theme="current"` + // and mixes its own label from the `currentcolor` it inherits. + // + // This is the one variant where `color` is not the label, so it is the one + // variant that has to hand the label down by hand. + ...Object.fromEntries( + ['Icon', 'RightIcon', 'Prefix', 'Suffix', 'Actions'].map((slot) => [ + slot, + { + // Icons are SVG stroked with `currentColor`, which + // `-webkit-text-fill-color` never reaches, so each slot repeats the + // label color — container override included. + color: { + '': '#current-fill', + disabled: '#current-fill.5', + }, + }, + ]), + ), +} as const; + +// Link flavour — no chip at all, only the label. The brand themes intensify from +// `accent-text-soft` at rest to `accent-text` on hover; with one color to work +// with, "soft" is that color at `.8` and "strong" is it at full opacity. +export const CURRENT_LINK_STYLES: Styles = { + ...CURRENT_FOCUS_RING, + border: 0, + fill: { + '': 'transparent', + }, + color: { + '': '#current.8', + 'hovered & !pressed': '#current', + // See `CURRENT_ITEM_STYLES.color`. + 'disabled & !inherit-disabled & !inside-wrapper': '#current.4', }, } as const; +// Card flavour — the `current` counterpart of `*_CARD_STYLES`: a static, +// non-interactive panel. The label stays at full opacity so the tint and border +// resolve against the inherited color rather than a faded one. +export const CURRENT_CARD_STYLES: Styles = { + border: '#current.2', + fill: '#current.05', + color: '#current', +} as const; + // ---------- CARD TYPE STYLES ---------- -// Card type only supports: default, success, danger, note themes +// Card type only supports: default, success, danger, note themes (plus the +// `current` theme — see `CURRENT_CARD_STYLES` above) export const DEFAULT_CARD_STYLES: Styles = { border: '#surface-text.20', @@ -1152,9 +1447,15 @@ export const NOTE_CARD_STYLES: Styles = { } as const; export type ItemVariant = - // The `current` type derives every color from the inherited `currentcolor`, - // so it has no per-theme flavours — see `CURRENT_ITEM_STYLES`. - | 'default.current' + // Inherited-color theme — every flavour mixes its colors from `currentcolor` + // instead of a brand ramp. See the CURRENT THEME section. + | 'current.item' + | 'current.primary' + | 'current.outline' + | 'current.outline-2' + | 'current.clear' + | 'current.link' + | 'current.card' | 'default.primary' | 'default.outline' | 'default.outline-2' @@ -1200,8 +1501,14 @@ export type ItemVariant = // below cannot drift apart: `ITEM_RESTING_COLOR_VARIANTS` is derived from this // object rather than restating the palette. export const ITEM_VARIANTS: Record = { - // 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 +1558,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 +1578,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 @@ -1302,6 +1605,15 @@ export function resolveItemVariant( // so it never carries `hovered` / `pressed` / `selected` and those entries could // never match there. `disabled` is different only because `ItemButton` passes it // down explicitly. +const ACTIONS_COLOR_OVERRIDES: Partial< + Record> +> = { + 'current.primary': { + '': '#current-fill', + disabled: '#current-fill.5', + }, +}; + export const ITEM_RESTING_COLOR_VARIANTS: Record = Object.fromEntries( Object.entries(ITEM_VARIANTS).map(([variant, styles]) => { @@ -1311,12 +1623,26 @@ export const ITEM_RESTING_COLOR_VARIANTS: Record = return [variant, { color }]; } - const map = color as Record; - // Only a plain `disabled` key is usable. `default.current` states it as - // `disabled & !inherit-disabled`, which is deliberately not matched here: - // that variant paints from `currentcolor` and has no fixed color to hand - // down, so the wrapper leaves its resting value in place. - const disabled = map.disabled; + // `current.primary` is the one variant whose `color` is not its label. It + // keeps `color` as the inherited fill so `#current` resolves in `fill`, + // and paints the label with `-webkit-text-fill-color`. Reproducing its + // `color` on the wrapper would hand sibling actions the CHIP color and + // they would vanish into it, so the override names the label instead — + // the same value the `Actions` slot gets inside a plain `Item`. + const map = (ACTIONS_COLOR_OVERRIDES[variant as ItemVariant] ?? + color) as Record; + // 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, diff --git a/src/eslint-plugin/defaults.generated.ts b/src/eslint-plugin/defaults.generated.ts index 85712acd0..ad332ea28 100644 --- a/src/eslint-plugin/defaults.generated.ts +++ b/src/eslint-plugin/defaults.generated.ts @@ -390,12 +390,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: { @@ -406,12 +402,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..5ca79d188 100644 --- a/src/eslint-plugin/fixtures.tsx +++ b/src/eslint-plugin/fixtures.tsx @@ -241,9 +241,8 @@ 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 — see `ItemAction` below for what they are for. */ render: (props) => 1, conditions: [ @@ -279,16 +278,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 at all — the default + * `current` theme tracks the host through `currentcolor` instead — so the + * provider conditions exist purely for `isDisabled`, the one prop still + * resolved that way. */ 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/Colors.stories.tsx b/src/stories/Colors.stories.tsx index 6ca8ce113..c3a578780 100644 --- a/src/stories/Colors.stories.tsx +++ b/src/stories/Colors.stories.tsx @@ -374,6 +374,12 @@ const disabledPairs: ColorPair[] = [ texts: ['#primary-accent-disabled-surface-text'], note: 'Muted brand fill for solid primary controls', }, + { + 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', fill: '#success-accent-disabled-surface', 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" > + +``` + +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.): @@ -310,6 +337,7 @@ Component themes are set via the `theme` prop or `theme=name` modifier: | `success` | Success / positive states | | `warning` | Warning / caution states | | `note` | Informational / note states | +| `current` | No palette of its own — every color is mixed from the inherited text color (`#current`), so the component adopts whatever its container paints with. Supported by `Button`, `Item` (and `ItemButton`, `Item.Action`, `ItemBadge`) | ## Recipes diff --git a/src/tasty-augment.d.ts b/src/tasty-augment.d.ts index 74075dfc2..29c5aeb34 100644 --- a/src/tasty-augment.d.ts +++ b/src/tasty-augment.d.ts @@ -32,6 +32,7 @@ declare module '@tenphi/tasty' { 'accent-icon': true; 'accent-disabled-surface': true; 'accent-disabled-surface-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 @@ -39,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; @@ -156,5 +162,9 @@ declare module '@tenphi/tasty' { success: true; warning: true; note: true; + // Not a brand ramp: `current` mixes every color from the inherited + // `currentcolor`, so an element adopts the color of whatever container it + // sits in. See the CURRENT THEME section of `src/data/item-themes.ts`. + current: true; } } diff --git a/src/tokens/__snapshots__/palette.test.ts.snap b/src/tokens/__snapshots__/palette.test.ts.snap index 470eacf4e..366f2f41d 100644 --- a/src/tokens/__snapshots__/palette.test.ts.snap +++ b/src/tokens/__snapshots__/palette.test.ts.snap @@ -4,6 +4,7 @@ exports[`palette tokens > resolves the default palette to a stable set of values { "#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-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)", @@ -24,6 +25,7 @@ exports[`palette tokens > resolves the default palette to a stable set of values "#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-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)", @@ -48,6 +50,7 @@ exports[`palette tokens > resolves the default palette to a stable set of values "#loading-face-3": "(base)=oklch(0.729 0.0248 280.3) | @dark=oklch(0.474 0.0161 280.3) | @dark & @hc=oklch(0.4821 0.0164 280.3) | @hc=oklch(0.6527 0.0222 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-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)", @@ -67,6 +70,7 @@ exports[`palette tokens > resolves the default palette to a stable set of values "#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-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)", @@ -84,6 +88,7 @@ exports[`palette tokens > resolves the default palette to a stable set of values "#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-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)", @@ -114,6 +119,7 @@ exports[`palette tokens > resolves the default palette to a stable set of values "#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-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)", @@ -143,6 +149,7 @@ exports[`palette tokens > resolves the default palette to a stable set of values "#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-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/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 f8a8c5f19..965c5fc37 100644 --- a/src/tokens/palette.ts +++ b/src/tokens/palette.ts @@ -1210,6 +1210,30 @@ function buildPalette( mode: 'fixed', }, + // The disabled counterpart of `accent-text-soft`, for the SELECTED disabled + // state of the non-solid types (`outline`, `outline-2`, `clear`). + // + // 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. + // + // 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 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': { + base: 'surface', + tone: '-23', + saturation: 0.6, + }, + // ---- Isometric cube faces ---- // The three shading steps of the kit's isometric cube artwork: the lit top // face, the mid side, and the shadowed side. Shared by `LoadingAnimation`