From d948c90d52d288e59469f1c8bdb2643152975da0 Mon Sep 17 00:00:00 2001 From: Preston Booth Date: Wed, 28 Oct 2026 10:23:23 -0600 Subject: [PATCH 1/4] feat(mosaic): page, surface and subtle color tokens Co-Authored-By: Claude Sonnet 5.5 --- .changeset/mosaic-color-tokens.md | 7 ++ packages/mosaic/ARCHITECTURE.md | 10 ++- .../action-bar/action-bar.styles.ts | 2 +- .../src/components/avatar/avatar.styles.ts | 2 +- .../mosaic/src/components/card/card.styles.ts | 4 +- .../components/checkbox/checkbox.styles.ts | 2 +- .../components/combobox/combobox.styles.ts | 2 +- .../copy-button/copy-button.styles.ts | 2 +- .../components/data-list/data-list.styles.ts | 2 +- .../dialog/__tests__/dialog.test.tsx | 2 +- .../src/components/dialog/dialog.styles.ts | 4 +- .../src/components/drawer/drawer.styles.ts | 2 +- .../mosaic/src/components/menu/menu.styles.ts | 2 +- .../phone-input/phone-input.styles.ts | 2 +- .../src/components/profile/profile.styles.ts | 2 +- .../src/components/radio/radio.styles.ts | 2 +- .../src/components/section/section.styles.ts | 2 +- .../segmented-control.styles.ts | 4 +- .../src/components/select/select.styles.ts | 2 +- .../src/components/switch/switch.styles.ts | 2 +- .../src/components/table/table.styles.ts | 12 ++-- .../src/components/toast/toast.styles.ts | 2 +- ...-profile-billing-history-section.styles.ts | 2 +- .../user-profile-backup-codes.styles.ts | 2 +- packages/mosaic/src/styles/skeleton.styles.ts | 2 +- .../src/styles/tooltip-surface.styles.ts | 2 +- packages/mosaic/src/tokens.stylex.ts | 65 ++++++++++--------- packages/swingset/src/stories/data-list.mdx | 2 +- packages/swingset/src/stories/icon-frame.mdx | 2 +- packages/swingset/src/stories/item.mdx | 2 +- .../swingset/src/stories/section.stories.tsx | 2 +- packages/swingset/src/stories/toast.mdx | 2 +- .../src/stories/tooltip.component.mdx | 2 +- 33 files changed, 88 insertions(+), 70 deletions(-) create mode 100644 .changeset/mosaic-color-tokens.md diff --git a/.changeset/mosaic-color-tokens.md b/.changeset/mosaic-color-tokens.md new file mode 100644 index 00000000000..f7200e79217 --- /dev/null +++ b/.changeset/mosaic-color-tokens.md @@ -0,0 +1,7 @@ +--- +'@clerk/mosaic': minor +--- + +Dark mode gives the profile its own shell color. `--cl-color-background` is replaced by `--cl-color-page`, the base layer (near-black in dark mode, white in light), which backs the profile shell. A new `--cl-color-surface` backs everything raised on it, such as cards, sections, menus, popovers and controls, and the gray ramp mixes into it. The surface is derived from the page (neutral mixed 5 percent into it in dark mode, white mixed 60 percent into it in light mode), so retinting `--cl-color-page` retints the shell, the surface and the ramp together, and light mode is unchanged with the default white page. `--cl-color-background-subtle` is renamed `--cl-color-surface-subtle`. + +Brand and the negative, positive and warning colors now share one set of derived tokens: `-foreground`, `-subtle`, `-border` and `-alpha-100|200|300`, so overriding a seed retints its companions. `-foreground` flips to dark text in dark mode where white would fail, and the dark negative is a little lighter. `--cl-color-brand-secondary` is removed. `--cl-color-neutral-subtle` is new. `--cl-color-border` and `--cl-color-border-subtle` are now neutral washes (12 and 8 percent), so they stay visible on the surface and faded fills, and `--cl-color-input-placeholder` follows `--cl-color-foreground-secondary`, which is slightly darker in light mode. diff --git a/packages/mosaic/ARCHITECTURE.md b/packages/mosaic/ARCHITECTURE.md index 4e892d1270e..db1b2ec1c77 100644 --- a/packages/mosaic/ARCHITECTURE.md +++ b/packages/mosaic/ARCHITECTURE.md @@ -18,14 +18,18 @@ Tokens are CSS custom properties declared through `stylex.defineVars` in `tokens // packages/mosaic/src/tokens.stylex.ts export const colorVars = stylex.defineVars({ '--cl-color-brand': 'light-dark(oklch(0.2046 0 0), oklch(0.9851 0 0))', - '--cl-color-brand-foreground': 'light-dark(oklch(1 0 0), oklch(0.2046 0 0))', + '--cl-color-brand-foreground': 'light-dark(oklch(1 0 0), var(--cl-color-surface))', // … }); ``` Each exported `*Vars` const in that file is one group (`colorVars`, `radiusVars`, …); the file is the list. `space` is also declared with `defineVars`, but its keys are not `--cl-*` names, so its steps compile to hashed custom properties that are internal, not part of the contract. -There is no gray scale. Every achromatic token (`--cl-color-foreground`, `-border`, `-background-subtle`, …) is `--cl-color-neutral` (black on light, white on dark) mixed into `--cl-color-background` at a fixed percentage per mode, so overriding neutral to a warm gray retints the whole ramp; in dark mode override `--cl-color-background` too, since the ramp mixes toward it. The `--cl--alpha-100|200|300` tokens in `colorVars` are washes (6, 8, and 12 percent of `--cl-color-neutral`, `-negative`, `-positive`, and `-warning` over `transparent`); overriding the solid color retints its washes. +There is no gray scale. Every achromatic token (`--cl-color-foreground`, `-border`, `-surface-subtle`, …) is `--cl-color-neutral` (black on light, white on dark) mixed into `--cl-color-surface` at a fixed percentage per mode, so overriding neutral to a warm gray retints the whole ramp. The exception is the borders: `--cl-color-border` (controls) is a 12 percent neutral wash and `--cl-color-border-subtle` (containers and dividers) an 8 percent one, in both modes, so they keep the same step over the surface and the faded surface alike. The `--cl--alpha-100|200|300` tokens in `colorVars` are washes (6, 8, and 12 percent of `--cl-color-neutral`, `-negative`, `-positive`, and `-warning` over `transparent`); overriding the solid color retints its washes. + +The seeds are `--cl-color-page`, `--cl-color-neutral`, `--cl-color-brand`, `--cl-color-negative`, `--cl-color-positive` and `--cl-color-warning`. Brand and the three statuses share one shape: the seed, `-foreground` (white in light mode, the surface color in dark mode, where the fills are light), `-subtle` (the 8 percent wash, 12 in dark mode) and `-border` (the 12 percent wash), so both work over the page, the surface or a faded fill and `-alpha-100|200|300` washes. Neutral has the same slots under its own names: `--cl-color-surface-subtle`, `--cl-color-neutral-subtle` (the same wash as the accent `-subtle`, for chips such as the neutral badge) and `--cl-color-border`. Only the seeds and the `-foreground` pairs are set by hand, so retinting a seed retints its companions. + +Three tiers carry the layering. `--cl-color-page` is the base: the shell of a settings surface such as the profile, and the darkest layer (`oklch(0.16 0 0)` in dark mode, white in light mode). `--cl-color-surface` is everything raised on it (cards, sections, menus, popovers, dialogs, controls) and the base the gray ramp mixes into. `--cl-color-surface-subtle` is the faded step on a surface, for footers, tiles and striped rows. The surface is derived, not set: in dark mode it is `--cl-color-neutral` mixed 5 percent into `--cl-color-page`, and in light mode it is white mixed 60 percent into `--cl-color-page`, so it is exactly white over a white page and still reads as near white over a gray one. So retinting `--cl-color-page` retints the shell, the surface and the whole ramp together; override `--cl-color-surface` directly only to change how far cards sit above the shell. All three are solid, never alpha, so they look the same over any backdrop. Light and dark come from CSS `light-dark()` on the default values, so there is no theme object and no re-render on theme change — the browser resolves it. @@ -51,7 +55,7 @@ A theme styles a part from CSS, against that class and its attributes: border-radius: 4px; } .cl-item[data-interactive] { - background-color: var(--cl-color-background); + background-color: var(--cl-color-surface); } ``` diff --git a/packages/mosaic/src/components/action-bar/action-bar.styles.ts b/packages/mosaic/src/components/action-bar/action-bar.styles.ts index 238cf4e7b1c..427a7f57507 100644 --- a/packages/mosaic/src/components/action-bar/action-bar.styles.ts +++ b/packages/mosaic/src/components/action-bar/action-bar.styles.ts @@ -30,7 +30,7 @@ export const styles = stylex.create({ paddingBlock: space['1'], paddingInline: space['1'], alignItems: 'center', - backgroundColor: colorVars['--cl-color-background'], + backgroundColor: colorVars['--cl-color-surface'], blockSize: space['10'], boxShadow: shadowVars['--cl-shadow-lg'], display: 'flex', diff --git a/packages/mosaic/src/components/avatar/avatar.styles.ts b/packages/mosaic/src/components/avatar/avatar.styles.ts index f6090d3bc97..2da226047e0 100644 --- a/packages/mosaic/src/components/avatar/avatar.styles.ts +++ b/packages/mosaic/src/components/avatar/avatar.styles.ts @@ -73,7 +73,7 @@ export const styles = stylex.create({ borderWidth: '1px', overflow: 'hidden', alignItems: 'center', - backgroundColor: colorVars['--cl-color-background'], + backgroundColor: colorVars['--cl-color-surface'], boxSizing: 'border-box', display: 'flex', insetBlockEnd: `calc(${space['2']} * -1)`, diff --git a/packages/mosaic/src/components/card/card.styles.ts b/packages/mosaic/src/components/card/card.styles.ts index 1a23a333151..6fb69152f01 100644 --- a/packages/mosaic/src/components/card/card.styles.ts +++ b/packages/mosaic/src/components/card/card.styles.ts @@ -26,7 +26,7 @@ export const root = stylex.create({ card: { borderRadius: radiusVars['--cl-radius-xl'], overflow: 'hidden', - backgroundColor: colorVars['--cl-color-background'], + backgroundColor: colorVars['--cl-color-surface'], boxShadow: shadowVars['--cl-shadow-lg'], }, flush: { @@ -38,7 +38,7 @@ export const root = stylex.create({ overlay: { borderRadius: radiusVars['--cl-radius-xl'], overflow: 'hidden', - backgroundColor: colorVars['--cl-color-background'], + backgroundColor: colorVars['--cl-color-surface'], boxShadow: shadowVars['--cl-shadow-lg'], }, }); diff --git a/packages/mosaic/src/components/checkbox/checkbox.styles.ts b/packages/mosaic/src/components/checkbox/checkbox.styles.ts index 38c9a7babc2..6cff123de9c 100644 --- a/packages/mosaic/src/components/checkbox/checkbox.styles.ts +++ b/packages/mosaic/src/components/checkbox/checkbox.styles.ts @@ -26,7 +26,7 @@ export const styles = stylex.create({ borderWidth: '1px', appearance: 'none', backgroundColor: { - default: colorVars['--cl-color-background'], + default: colorVars['--cl-color-surface'], ':checked': colorVars['--cl-color-brand'], ':indeterminate': colorVars['--cl-color-brand'], }, diff --git a/packages/mosaic/src/components/combobox/combobox.styles.ts b/packages/mosaic/src/components/combobox/combobox.styles.ts index c60fa5b9d1c..a8bb8e2cab0 100644 --- a/packages/mosaic/src/components/combobox/combobox.styles.ts +++ b/packages/mosaic/src/components/combobox/combobox.styles.ts @@ -20,7 +20,7 @@ export const styles = stylex.create({ borderRadius: radiusVars['--cl-radius-lg'], outline: 'none', overflow: 'hidden', - backgroundColor: colorVars['--cl-color-background'], + backgroundColor: colorVars['--cl-color-surface'], boxShadow: shadowVars['--cl-shadow-md'], color: colorVars['--cl-color-foreground'], opacity: { diff --git a/packages/mosaic/src/components/copy-button/copy-button.styles.ts b/packages/mosaic/src/components/copy-button/copy-button.styles.ts index 18c8307d6fa..f84d34f5ea8 100644 --- a/packages/mosaic/src/components/copy-button/copy-button.styles.ts +++ b/packages/mosaic/src/components/copy-button/copy-button.styles.ts @@ -10,7 +10,7 @@ export const styles = stylex.create({ paddingInline: space['1.5'], alignItems: 'center', backgroundColor: colorVars['--cl-color-foreground'], - color: colorVars['--cl-color-background'], + color: colorVars['--cl-color-surface'], // A copy while one is still showing queues behind the `limit`; only the newest is wanted. display: { default: 'flex', ':where([data-limited])': 'none' }, fontSize: typeScaleVars['--cl-text-sm-size'], diff --git a/packages/mosaic/src/components/data-list/data-list.styles.ts b/packages/mosaic/src/components/data-list/data-list.styles.ts index 87cb102bcf5..74a18a3e08c 100644 --- a/packages/mosaic/src/components/data-list/data-list.styles.ts +++ b/packages/mosaic/src/components/data-list/data-list.styles.ts @@ -9,7 +9,7 @@ export const list = stylex.create({ borderStyle: 'solid', borderWidth: '1px', paddingInline: space['3'], - backgroundColor: colorVars['--cl-color-background-subtle'], + backgroundColor: colorVars['--cl-color-surface-subtle'], display: 'flex', flexDirection: 'column', width: '100%', diff --git a/packages/mosaic/src/components/dialog/__tests__/dialog.test.tsx b/packages/mosaic/src/components/dialog/__tests__/dialog.test.tsx index 799e0edef0d..7a2587396d1 100644 --- a/packages/mosaic/src/components/dialog/__tests__/dialog.test.tsx +++ b/packages/mosaic/src/components/dialog/__tests__/dialog.test.tsx @@ -584,7 +584,7 @@ describe('popup surface', () => { // an atom from its property and value, so a probe with the popup's own values yields the very // atoms `styles.popup` declares. const probe = stylex.create({ - background: { backgroundColor: colorVars['--cl-color-background'] }, + background: { backgroundColor: colorVars['--cl-color-page'] }, radius: { borderRadius: radiusVars['--cl-radius-xl'] }, }); diff --git a/packages/mosaic/src/components/dialog/dialog.styles.ts b/packages/mosaic/src/components/dialog/dialog.styles.ts index 835010e2796..4f3ee4c1179 100644 --- a/packages/mosaic/src/components/dialog/dialog.styles.ts +++ b/packages/mosaic/src/components/dialog/dialog.styles.ts @@ -218,7 +218,7 @@ export const styles = stylex.create({ inset: 0, // Follows the popup's own radius. borderRadius: 'inherit', - backgroundColor: colorVars['--cl-color-background'], + backgroundColor: colorVars['--cl-color-surface'], content: '""', opacity: 'var(--_cl-stack-veil, 0)', pointerEvents: 'none', @@ -465,7 +465,7 @@ export const compactPlacements = stylex.create({ // shared: bottom-aligning there would drag a centered dialog down with it. alignSelf: { [SHEET]: 'end', default: null }, // The sheet's surface, so it runs on through the keyboard padding below; the card only fills it. - backgroundColor: { [SHEET]: colorVars['--cl-color-background'], default: null }, + backgroundColor: { [SHEET]: colorVars['--cl-color-surface'], default: null }, // No drop shadow on the screen edge, only the hairline ring, as on `Drawer`. The card inside // reads the two variables and drops its own shadow and fixed width. boxShadow: { [SHEET]: shadowVars['--cl-shadow-sm'], default: null }, diff --git a/packages/mosaic/src/components/drawer/drawer.styles.ts b/packages/mosaic/src/components/drawer/drawer.styles.ts index cd52390f708..f6f40cf5a8f 100644 --- a/packages/mosaic/src/components/drawer/drawer.styles.ts +++ b/packages/mosaic/src/components/drawer/drawer.styles.ts @@ -82,7 +82,7 @@ export const styles = stylex.create({ borderWidth: { default: null, '@media (forced-colors: active)': '1px' }, outline: 'none', overscrollBehavior: 'contain', - backgroundColor: colorVars['--cl-color-background'], + backgroundColor: colorVars['--cl-color-surface'], borderStartEndRadius: radiusVars['--cl-radius-2xl'], borderStartStartRadius: radiusVars['--cl-radius-2xl'], // No drop shadow — the sheet sits on the screen edge, so there is nothing for it to float over. diff --git a/packages/mosaic/src/components/menu/menu.styles.ts b/packages/mosaic/src/components/menu/menu.styles.ts index 5ea1d18f4ee..ee2373e7b89 100644 --- a/packages/mosaic/src/components/menu/menu.styles.ts +++ b/packages/mosaic/src/components/menu/menu.styles.ts @@ -25,7 +25,7 @@ export const popup = stylex.create({ base: { borderRadius: radiusVars['--cl-radius-lg'], outline: 'none', - backgroundColor: colorVars['--cl-color-background'], + backgroundColor: colorVars['--cl-color-surface'], boxShadow: shadowVars['--cl-shadow-md'], color: colorVars['--cl-color-foreground'], opacity: { diff --git a/packages/mosaic/src/components/phone-input/phone-input.styles.ts b/packages/mosaic/src/components/phone-input/phone-input.styles.ts index 2e8e571ca04..215888b2490 100644 --- a/packages/mosaic/src/components/phone-input/phone-input.styles.ts +++ b/packages/mosaic/src/components/phone-input/phone-input.styles.ts @@ -66,7 +66,7 @@ export const styles = stylex.create({ popup: { borderRadius: radiusVars['--cl-radius-lg'], overflow: 'hidden', - backgroundColor: colorVars['--cl-color-background'], + backgroundColor: colorVars['--cl-color-surface'], boxShadow: shadowVars['--cl-shadow-md'], color: colorVars['--cl-color-foreground'], }, diff --git a/packages/mosaic/src/components/profile/profile.styles.ts b/packages/mosaic/src/components/profile/profile.styles.ts index a7c1219e9cc..ff7739c9544 100644 --- a/packages/mosaic/src/components/profile/profile.styles.ts +++ b/packages/mosaic/src/components/profile/profile.styles.ts @@ -72,7 +72,7 @@ export const styles = stylex.create({ }, // `clip`, not `hidden`: a scroll container here would scroll the surface instead of the column. overflow: 'clip', - backgroundColor: colorVars['--cl-color-background'], + backgroundColor: colorVars['--cl-color-page'], blockSize: { [compact]: '100dvh', default: '45rem', diff --git a/packages/mosaic/src/components/radio/radio.styles.ts b/packages/mosaic/src/components/radio/radio.styles.ts index 5d04a7ea2ce..83017f3f927 100644 --- a/packages/mosaic/src/components/radio/radio.styles.ts +++ b/packages/mosaic/src/components/radio/radio.styles.ts @@ -30,7 +30,7 @@ export const styles = stylex.create({ borderWidth: '1px', appearance: 'none', backgroundColor: { - default: colorVars['--cl-color-background'], + default: colorVars['--cl-color-surface'], ':checked': colorVars['--cl-color-brand'], }, cursor: { default: 'pointer', ':disabled': 'not-allowed' }, diff --git a/packages/mosaic/src/components/section/section.styles.ts b/packages/mosaic/src/components/section/section.styles.ts index 3e36f28471f..0904539df9f 100644 --- a/packages/mosaic/src/components/section/section.styles.ts +++ b/packages/mosaic/src/components/section/section.styles.ts @@ -49,7 +49,7 @@ export const styles = stylex.create({ borderStyle: 'solid', borderWidth: '1px', paddingInline: space['4'], - backgroundColor: colorVars['--cl-color-background'], + backgroundColor: colorVars['--cl-color-surface'], display: 'flex', flexDirection: 'column', width: 'auto', diff --git a/packages/mosaic/src/components/segmented-control/segmented-control.styles.ts b/packages/mosaic/src/components/segmented-control/segmented-control.styles.ts index 14657cde42b..7e384c2263e 100644 --- a/packages/mosaic/src/components/segmented-control/segmented-control.styles.ts +++ b/packages/mosaic/src/components/segmented-control/segmented-control.styles.ts @@ -32,7 +32,7 @@ const scaleIn = stylex.keyframes({ const surface = { borderRadius: radiusVars['--cl-radius-md'], - backgroundColor: { default: colorVars['--cl-color-background'], [forcedColors]: 'Highlight' }, + backgroundColor: { default: colorVars['--cl-color-surface'], [forcedColors]: 'Highlight' }, boxShadow: shadowVars['--cl-shadow-sm'], forcedColorAdjust: 'none', } as const; @@ -44,7 +44,7 @@ export const styles = stylex.create({ padding: space['0.5'], borderRadius: `calc(${radiusVars['--cl-radius-md']} + ${space['0.5']})`, alignItems: 'center', - backgroundColor: colorVars['--cl-color-background-subtle'], + backgroundColor: colorVars['--cl-color-surface-subtle'], display: 'inline-flex', isolation: 'isolate', position: 'relative', diff --git a/packages/mosaic/src/components/select/select.styles.ts b/packages/mosaic/src/components/select/select.styles.ts index 2690ccfae9d..f92f6f30a69 100644 --- a/packages/mosaic/src/components/select/select.styles.ts +++ b/packages/mosaic/src/components/select/select.styles.ts @@ -40,7 +40,7 @@ export const popup = stylex.create({ borderStyle: 'solid', borderWidth: '1px', outline: 'none', - backgroundColor: colorVars['--cl-color-background'], + backgroundColor: colorVars['--cl-color-surface'], boxShadow: shadowVars['--cl-shadow-md'], color: colorVars['--cl-color-foreground'], opacity: { diff --git a/packages/mosaic/src/components/switch/switch.styles.ts b/packages/mosaic/src/components/switch/switch.styles.ts index ae19cb2bafe..d0ebb94b61a 100644 --- a/packages/mosaic/src/components/switch/switch.styles.ts +++ b/packages/mosaic/src/components/switch/switch.styles.ts @@ -6,7 +6,7 @@ import { switchInputMarker } from './switch.markers.stylex'; const thumbInset = '2px'; const trackOffHover = `color-mix(in oklab, ${colorVars['--cl-color-neutral']} 24%, transparent)`; const trackOnHover = `color-mix(in oklab, ${colorVars['--cl-color-brand']}, ${colorVars['--cl-color-brand-foreground']} 12%)`; -const thumbOff = `light-dark(${colorVars['--cl-color-background']}, ${colorVars['--cl-color-foreground']})`; +const thumbOff = `light-dark(${colorVars['--cl-color-surface']}, ${colorVars['--cl-color-foreground']})`; export const styles = stylex.create({ root: { diff --git a/packages/mosaic/src/components/table/table.styles.ts b/packages/mosaic/src/components/table/table.styles.ts index 1d4b3ad55a2..6d2036d8d67 100644 --- a/packages/mosaic/src/components/table/table.styles.ts +++ b/packages/mosaic/src/components/table/table.styles.ts @@ -34,7 +34,7 @@ export const styles = stylex.create({ borderStyle: 'solid', borderWidth: '1px', overflow: 'hidden', - backgroundColor: colorVars['--cl-color-background'], + backgroundColor: colorVars['--cl-color-surface'], width: '100%', }, viewport: { @@ -44,7 +44,7 @@ export const styles = stylex.create({ }, table: { '--_cl-table-row-tint': 'transparent', - '--_cl-table-surface': colorVars['--cl-color-background'], + '--_cl-table-surface': colorVars['--cl-color-surface'], borderCollapse: 'collapse', borderSpacing: 0, isolation: 'isolate', @@ -52,8 +52,8 @@ export const styles = stylex.create({ width: '100%', }, header: { - '--_cl-table-surface': colorVars['--cl-color-background-subtle'], - backgroundColor: colorVars['--cl-color-background-subtle'], + '--_cl-table-surface': colorVars['--cl-color-surface-subtle'], + backgroundColor: colorVars['--cl-color-surface-subtle'], }, row: { '--_cl-table-row-tint': { @@ -61,7 +61,7 @@ export const styles = stylex.create({ ':where([data-selected])': colorVars['--cl-color-neutral-alpha-100'], '@media (hover: hover)': { default: null, - ':where([data-actionable]:not([data-selected]):hover)': colorVars['--cl-color-background-subtle'], + ':where([data-actionable]:not([data-selected]):hover)': colorVars['--cl-color-surface-subtle'], }, }, backgroundColor: { @@ -69,7 +69,7 @@ export const styles = stylex.create({ ':where([data-selected])': colorVars['--cl-color-neutral-alpha-100'], '@media (hover: hover)': { default: null, - ':where([data-actionable]:not([data-selected]):hover)': colorVars['--cl-color-background-subtle'], + ':where([data-actionable]:not([data-selected]):hover)': colorVars['--cl-color-surface-subtle'], }, }, borderBlockStartColor: colorVars['--cl-color-border'], diff --git a/packages/mosaic/src/components/toast/toast.styles.ts b/packages/mosaic/src/components/toast/toast.styles.ts index 3017072d8f6..2bf2149f655 100644 --- a/packages/mosaic/src/components/toast/toast.styles.ts +++ b/packages/mosaic/src/components/toast/toast.styles.ts @@ -43,7 +43,7 @@ export const styles = stylex.create({ '@media (prefers-reduced-motion: reduce)': 'none', }, animationTimingFunction: easingVars['--cl-ease-default'], - backgroundColor: colorVars['--cl-color-background'], + backgroundColor: colorVars['--cl-color-surface'], boxShadow: shadowVars['--cl-shadow-lg'], color: colorVars['--cl-color-foreground'], display: { diff --git a/packages/mosaic/src/features/user-profile/user-profile-billing-history-section/user-profile-billing-history-section.styles.ts b/packages/mosaic/src/features/user-profile/user-profile-billing-history-section/user-profile-billing-history-section.styles.ts index 378fb3fb6bf..39a2306dbc3 100644 --- a/packages/mosaic/src/features/user-profile/user-profile-billing-history-section/user-profile-billing-history-section.styles.ts +++ b/packages/mosaic/src/features/user-profile/user-profile-billing-history-section/user-profile-billing-history-section.styles.ts @@ -72,7 +72,7 @@ export const styles = stylex.create({ borderWidth: '1px', paddingBlock: space['1'], paddingInline: space['2'], - backgroundColor: colorVars['--cl-color-background'], + backgroundColor: colorVars['--cl-color-surface'], color: colorVars['--cl-color-foreground'], fontSize: typeScaleVars['--cl-text-xs-size'], lineHeight: typeScaleVars['--cl-text-xs-leading'], diff --git a/packages/mosaic/src/features/user-profile/user-profile-mfa-section/user-profile-backup-codes.styles.ts b/packages/mosaic/src/features/user-profile/user-profile-mfa-section/user-profile-backup-codes.styles.ts index c7d08556c92..970f405190d 100644 --- a/packages/mosaic/src/features/user-profile/user-profile-mfa-section/user-profile-backup-codes.styles.ts +++ b/packages/mosaic/src/features/user-profile/user-profile-mfa-section/user-profile-backup-codes.styles.ts @@ -9,7 +9,7 @@ export const styles = stylex.create({ borderStyle: 'solid', borderWidth: '1px', overflow: 'hidden', - backgroundColor: colorVars['--cl-color-background-subtle'], + backgroundColor: colorVars['--cl-color-surface-subtle'], display: 'grid', gridTemplateColumns: 'repeat(2, minmax(0, 1fr))', listStyleType: 'none', diff --git a/packages/mosaic/src/styles/skeleton.styles.ts b/packages/mosaic/src/styles/skeleton.styles.ts index c3c9e64b1ac..617f207393f 100644 --- a/packages/mosaic/src/styles/skeleton.styles.ts +++ b/packages/mosaic/src/styles/skeleton.styles.ts @@ -3,7 +3,7 @@ import * as stylex from '@stylexjs/stylex'; import { colorVars, radiusVars } from '../tokens.stylex'; const highlight = - 'light-dark(color-mix(in oklab, var(--cl-color-background) 62%, transparent), color-mix(in oklab, var(--cl-color-neutral) 7%, transparent))'; + 'light-dark(color-mix(in oklab, var(--cl-color-surface) 62%, transparent), color-mix(in oklab, var(--cl-color-neutral) 7%, transparent))'; const translate = stylex.keyframes({ from: { transform: 'translateX(-100%)' }, diff --git a/packages/mosaic/src/styles/tooltip-surface.styles.ts b/packages/mosaic/src/styles/tooltip-surface.styles.ts index 597e37fc867..b0694471cdc 100644 --- a/packages/mosaic/src/styles/tooltip-surface.styles.ts +++ b/packages/mosaic/src/styles/tooltip-surface.styles.ts @@ -10,7 +10,7 @@ export const tooltipSurface = stylex.create({ paddingInline: space['1.5'], alignItems: 'center', backgroundColor: colorVars['--cl-color-foreground'], - color: colorVars['--cl-color-background'], + color: colorVars['--cl-color-surface'], display: 'flex', fontSize: typeScaleVars['--cl-text-sm-size'], lineHeight: typeScaleVars['--cl-text-sm-leading'], diff --git a/packages/mosaic/src/tokens.stylex.ts b/packages/mosaic/src/tokens.stylex.ts index 83f822c98a4..bf3269bef99 100644 --- a/packages/mosaic/src/tokens.stylex.ts +++ b/packages/mosaic/src/tokens.stylex.ts @@ -23,12 +23,13 @@ import * as stylex from '@stylexjs/stylex'; // Color Tokens // ============================================================================= -// Every achromatic token is `--cl-color-neutral` mixed into `--cl-color-background`, so -// retinting neutral (a warm gray, say) retints the whole ramp. The percentages reproduce -// the Figma gray scale: grays have zero chroma, so an oklab mix is linear in lightness. -// Dark mode needs its own percentage because its background is a 900, not black. +// Every achromatic token is `--cl-color-neutral` mixed into `--cl-color-surface`, so +// retinting neutral (a warm gray, say) retints the whole ramp. Each tier sits about one 1.09:1 +// contrast step from the one below it. Light mode mixes toward black, which holds that ratio at a +// fixed percentage; dark mode mixes toward white, so its percentages shrink as the tiers rise +// (surface 5, subtle 4). Borders are washes, so they keep the same step on any tier. const neutralMix = (light: number, dark: number) => - `light-dark(color-mix(in oklab, var(--cl-color-neutral) ${light}%, var(--cl-color-background)), color-mix(in oklab, var(--cl-color-neutral) ${dark}%, var(--cl-color-background)))`; + `light-dark(color-mix(in oklab, var(--cl-color-neutral) ${light}%, var(--cl-color-surface)), color-mix(in oklab, var(--cl-color-neutral) ${dark}%, var(--cl-color-surface)))`; const colorDefaults = { '--cl-color-neutral': 'light-dark(oklch(0 0 0), oklch(1 0 0))', @@ -36,47 +37,53 @@ const colorDefaults = { '--cl-color-neutral-alpha-200': 'color-mix(in oklab, var(--cl-color-neutral) 8%, transparent)', '--cl-color-neutral-alpha-300': 'color-mix(in oklab, var(--cl-color-neutral) 12%, transparent)', - '--cl-color-background': 'light-dark(oklch(1 0 0), oklch(0.2046 0 0))', - '--cl-color-background-subtle': neutralMix(3, 4.25), + '--cl-color-page': 'light-dark(oklch(1 0 0), oklch(0.16 0 0))', + '--cl-color-surface': + 'light-dark(color-mix(in oklab, oklch(1 0 0) 60%, var(--cl-color-page)), color-mix(in oklab, var(--cl-color-neutral) 5%, var(--cl-color-page)))', + '--cl-color-surface-subtle': neutralMix(3, 4), + '--cl-color-neutral-subtle': 'light-dark(var(--cl-color-neutral-alpha-200), var(--cl-color-neutral-alpha-300))', '--cl-color-foreground': neutralMix(79.5, 98), - '--cl-color-foreground-secondary': neutralMix(50, 64.25), + '--cl-color-foreground-secondary': neutralMix(55, 64.25), '--cl-color-foreground-disabled': neutralMix(28.5, 44), - '--cl-color-border': neutralMix(5.5, 8), - '--cl-color-border-subtle': neutralMix(3, 4.25), + '--cl-color-border': 'var(--cl-color-neutral-alpha-300)', + '--cl-color-border-subtle': 'var(--cl-color-neutral-alpha-200)', '--cl-color-brand': 'light-dark(oklch(0.2046 0 0), oklch(0.9851 0 0))', - '--cl-color-brand-secondary': 'light-dark(oklch(0.4017 0 0), oklch(0.7025 0 0))', - '--cl-color-brand-border': 'light-dark(oklch(0.8078 0 0), oklch(0.3904 0 0))', - '--cl-color-brand-foreground': 'light-dark(oklch(1 0 0), oklch(0.2046 0 0))', - - '--cl-color-negative': 'oklch(0.6252 0.2161 26.11)', - '--cl-color-negative-foreground': 'oklch(1 0 0)', - '--cl-color-negative-subtle': 'light-dark(oklch(0.9686 0.0154 27.28), oklch(0.2545 0.0256 26.99))', - '--cl-color-negative-border': 'light-dark(oklch(0.9071 0.0481 27.58), oklch(0.305 0.0524 25.9))', + '--cl-color-brand-foreground': 'light-dark(oklch(1 0 0), var(--cl-color-surface))', + '--cl-color-brand-subtle': 'light-dark(var(--cl-color-brand-alpha-200), var(--cl-color-brand-alpha-300))', + '--cl-color-brand-border': 'var(--cl-color-brand-alpha-300)', + '--cl-color-brand-alpha-100': 'color-mix(in oklab, var(--cl-color-brand) 6%, transparent)', + '--cl-color-brand-alpha-200': 'color-mix(in oklab, var(--cl-color-brand) 8%, transparent)', + '--cl-color-brand-alpha-300': 'color-mix(in oklab, var(--cl-color-brand) 12%, transparent)', + + '--cl-color-negative': 'light-dark(oklch(0.577 0.2161 26.11), oklch(0.68 0.2 26.11))', + '--cl-color-negative-foreground': 'light-dark(oklch(1 0 0), var(--cl-color-surface))', + '--cl-color-negative-subtle': 'light-dark(var(--cl-color-negative-alpha-200), var(--cl-color-negative-alpha-300))', + '--cl-color-negative-border': 'var(--cl-color-negative-alpha-300)', '--cl-color-negative-alpha-100': 'color-mix(in oklab, var(--cl-color-negative) 6%, transparent)', '--cl-color-negative-alpha-200': 'color-mix(in oklab, var(--cl-color-negative) 8%, transparent)', '--cl-color-negative-alpha-300': 'color-mix(in oklab, var(--cl-color-negative) 12%, transparent)', '--cl-color-positive': 'light-dark(oklch(0.6082 0.1799 145.47), oklch(0.7227 0.192 149.58))', - '--cl-color-positive-foreground': 'oklch(1 0 0)', - '--cl-color-positive-subtle': 'light-dark(oklch(0.9679 0.0135 145.47), oklch(0.2673 0.0226 150.07))', - '--cl-color-positive-border': 'light-dark(oklch(0.9058 0.0428 145.21), oklch(0.3293 0.047 149.58))', + '--cl-color-positive-foreground': 'light-dark(oklch(1 0 0), var(--cl-color-surface))', + '--cl-color-positive-subtle': 'light-dark(var(--cl-color-positive-alpha-200), var(--cl-color-positive-alpha-300))', + '--cl-color-positive-border': 'var(--cl-color-positive-alpha-300)', '--cl-color-positive-alpha-100': 'color-mix(in oklab, var(--cl-color-positive) 6%, transparent)', '--cl-color-positive-alpha-200': 'color-mix(in oklab, var(--cl-color-positive) 8%, transparent)', '--cl-color-positive-alpha-300': 'color-mix(in oklab, var(--cl-color-positive) 12%, transparent)', '--cl-color-warning': 'light-dark(oklch(0.7099 0.1888 44.94), oklch(0.7576 0.159 55.93))', - '--cl-color-warning-foreground': 'oklch(1 0 0)', - '--cl-color-warning-subtle': 'light-dark(oklch(0.9744 0.0136 46.23), oklch(0.2699 0.0187 52.63))', - '--cl-color-warning-border': 'light-dark(oklch(0.9261 0.0409 46.68), oklch(0.3374 0.0384 54.7))', + '--cl-color-warning-foreground': 'light-dark(oklch(1 0 0), var(--cl-color-surface))', + '--cl-color-warning-subtle': 'light-dark(var(--cl-color-warning-alpha-200), var(--cl-color-warning-alpha-300))', + '--cl-color-warning-border': 'var(--cl-color-warning-alpha-300)', '--cl-color-warning-alpha-100': 'color-mix(in oklab, var(--cl-color-warning) 6%, transparent)', '--cl-color-warning-alpha-200': 'color-mix(in oklab, var(--cl-color-warning) 8%, transparent)', '--cl-color-warning-alpha-300': 'color-mix(in oklab, var(--cl-color-warning) 12%, transparent)', - '--cl-color-input': 'var(--cl-color-background)', - '--cl-color-input-placeholder': neutralMix(28, 64), + '--cl-color-input': 'var(--cl-color-surface)', + '--cl-color-input-placeholder': 'var(--cl-color-foreground-secondary)', '--cl-color-ring': neutralMix(80, 90), } as const; @@ -146,7 +153,7 @@ export const targetVars = stylex.defineVars(targetDefaults); // pointer is elsewhere, the base once it reaches the region, then `hover` and `active` for the // thumb's own two. Each derives from `--cl-scrollbar-thumb` rather than baking its value in, so // they resolve at use time — overriding the base re-derives all three, while any one stays -// individually overridable. The base is itself mixed most of the way toward `--cl-color-background`, +// individually overridable. The base is itself mixed most of the way toward `--cl-color-surface`, // which keeps a 4px bar reading as a hairline rather than a hard rule; `idle` carries on in that // direction, and the other two step back toward `--cl-color-foreground`, deepening in light // mode and lightening in dark, since that token already carries both. @@ -169,8 +176,8 @@ const scrollbarThumb = 'var(--cl-scrollbar-thumb)'; const scrollbarDefaults = { '--cl-scrollbar-width': '8px', '--cl-scrollbar-thumb-inset': '2px', - '--cl-scrollbar-thumb': `color-mix(in oklab, ${colorVars['--cl-color-foreground-secondary']}, ${colorVars['--cl-color-background']} 55%)`, - '--cl-scrollbar-thumb-idle': `color-mix(in oklab, ${scrollbarThumb}, ${colorVars['--cl-color-background']} 45%)`, + '--cl-scrollbar-thumb': `color-mix(in oklab, ${colorVars['--cl-color-foreground-secondary']}, ${colorVars['--cl-color-surface']} 55%)`, + '--cl-scrollbar-thumb-idle': `color-mix(in oklab, ${scrollbarThumb}, ${colorVars['--cl-color-surface']} 45%)`, '--cl-scrollbar-thumb-hover': `color-mix(in oklab, ${scrollbarThumb}, ${colorVars['--cl-color-foreground']} 15%)`, '--cl-scrollbar-thumb-active': `color-mix(in oklab, ${scrollbarThumb}, ${colorVars['--cl-color-foreground']} 30%)`, } as const; diff --git a/packages/swingset/src/stories/data-list.mdx b/packages/swingset/src/stories/data-list.mdx index 74fcce41bf9..db931605b98 100644 --- a/packages/swingset/src/stories/data-list.mdx +++ b/packages/swingset/src/stories/data-list.mdx @@ -50,7 +50,7 @@ import { DataList } from '@clerk/mosaic/components/data-list'; ```css .cl-data-list { - background-color: var(--cl-color-background); + background-color: var(--cl-color-surface); } .cl-data-list[data-divided] .cl-data-list-item { diff --git a/packages/swingset/src/stories/icon-frame.mdx b/packages/swingset/src/stories/icon-frame.mdx index 3624387fe1b..63869ac256e 100644 --- a/packages/swingset/src/stories/icon-frame.mdx +++ b/packages/swingset/src/stories/icon-frame.mdx @@ -2,7 +2,7 @@ import * as IconFrameStories from './icon-frame.stories'; # IconFrame -IconFrame centers an icon or other visual in a square Mosaic surface. `bordered` and `filled` are independent treatments, while `size` controls the frame dimensions. The default is a bordered 40px frame; the filled treatment uses `--cl-color-background-subtle`. +IconFrame centers an icon or other visual in a square Mosaic surface. `bordered` and `filled` are independent treatments, while `size` controls the frame dimensions. The default is a bordered 40px frame; the filled treatment uses `--cl-color-surface-subtle`. ## Playground diff --git a/packages/swingset/src/stories/item.mdx b/packages/swingset/src/stories/item.mdx index d39759de8bf..9f216aaddfa 100644 --- a/packages/swingset/src/stories/item.mdx +++ b/packages/swingset/src/stories/item.mdx @@ -166,7 +166,7 @@ The root reflects its state as `data-*` attributes on `.cl-item`, so consumers c ```css /* Re-theme interactive rows */ .cl-item[data-interactive] { - background-color: var(--cl-color-background); + background-color: var(--cl-color-surface); } /* Widen the media column on compact rows */ diff --git a/packages/swingset/src/stories/section.stories.tsx b/packages/swingset/src/stories/section.stories.tsx index 21227014991..aae2acd0dee 100644 --- a/packages/swingset/src/stories/section.stories.tsx +++ b/packages/swingset/src/stories/section.stories.tsx @@ -440,7 +440,7 @@ export function ContainedHeader() { .cl-section-group { border: 1px solid var(--cl-color-border) !important; border-radius: var(--cl-radius-xl) !important; - background: var(--cl-color-background) !important; + background: var(--cl-color-surface) !important; } .cl-section-header { margin: 0 1rem !important; diff --git a/packages/swingset/src/stories/toast.mdx b/packages/swingset/src/stories/toast.mdx index b5579918bc5..df8cea7f49a 100644 --- a/packages/swingset/src/stories/toast.mdx +++ b/packages/swingset/src/stories/toast.mdx @@ -73,7 +73,7 @@ The viewports render these slots for each toast: | `.cl-toast-description` | — | | `.cl-toast-icon` | `data-type`: `success` \| `error` \| `loading` | -The stacked toast reads `--cl-color-background`, `--cl-color-foreground`, `--cl-shadow-lg`, and `--cl-radius-lg`; its icon is tinted from `--cl-color-positive` or `--cl-color-negative`. The anchored toast shares the tooltip surface: `--cl-color-foreground` fill with `--cl-color-background` text. +The stacked toast reads `--cl-color-surface`, `--cl-color-foreground`, `--cl-shadow-lg`, and `--cl-radius-lg`; its icon is tinted from `--cl-color-positive` or `--cl-color-negative`. The anchored toast shares the tooltip surface: `--cl-color-foreground` fill with `--cl-color-surface` text. ```css @import '@clerk/mosaic/styles.css' layer(components); diff --git a/packages/swingset/src/stories/tooltip.component.mdx b/packages/swingset/src/stories/tooltip.component.mdx index 94fd7f59699..5453fcd0075 100644 --- a/packages/swingset/src/stories/tooltip.component.mdx +++ b/packages/swingset/src/stories/tooltip.component.mdx @@ -119,7 +119,7 @@ classes — override by targeting the `.cl-*` slot from a CSS layer that wins ov } ``` -The popup fills with `--cl-color-foreground` and sets its text in `--cl-color-background`, so it +The popup fills with `--cl-color-foreground` and sets its text in `--cl-color-surface`, so it reads as an inverted surface in both color schemes. It is padded `--cl-spacing` on the block axis and one and a half times that inline, and caps its width at `20rem`. From 69d27f3dfd48b8545ec4c3f1de3d743b96283d17 Mon Sep 17 00:00:00 2001 From: Preston Booth Date: Wed, 28 Oct 2026 17:13:55 -0600 Subject: [PATCH 2/4] fix(mosaic): keep light-mode borders and raise status text contrast Light-mode borders are 7 and 4 percent washes so they render as before on a white page; dark mode keeps 12 and 8 percent. The light negative, positive and warning seeds are darker so their text passes contrast on their own -subtle fills. The input placeholder gets its own value instead of following secondary text. Co-Authored-By: Claude Sonnet 5.5 --- .changeset/mosaic-color-tokens.md | 6 ++++-- packages/mosaic/ARCHITECTURE.md | 2 +- packages/mosaic/src/tokens.stylex.ts | 14 ++++++++------ 3 files changed, 13 insertions(+), 9 deletions(-) diff --git a/.changeset/mosaic-color-tokens.md b/.changeset/mosaic-color-tokens.md index f7200e79217..a36173ea52c 100644 --- a/.changeset/mosaic-color-tokens.md +++ b/.changeset/mosaic-color-tokens.md @@ -2,6 +2,8 @@ '@clerk/mosaic': minor --- -Dark mode gives the profile its own shell color. `--cl-color-background` is replaced by `--cl-color-page`, the base layer (near-black in dark mode, white in light), which backs the profile shell. A new `--cl-color-surface` backs everything raised on it, such as cards, sections, menus, popovers and controls, and the gray ramp mixes into it. The surface is derived from the page (neutral mixed 5 percent into it in dark mode, white mixed 60 percent into it in light mode), so retinting `--cl-color-page` retints the shell, the surface and the ramp together, and light mode is unchanged with the default white page. `--cl-color-background-subtle` is renamed `--cl-color-surface-subtle`. +Dark mode gives the profile its own shell color. `--cl-color-background` is replaced by `--cl-color-page`, the base layer (near-black in dark mode, white in light), which backs the profile shell. A new `--cl-color-surface` backs everything raised on it, such as cards, sections, menus, popovers and controls, and the gray ramp mixes into it. The surface is derived from the page (neutral mixed 5 percent into it in dark mode, white mixed 60 percent into it in light mode), so retinting `--cl-color-page` retints the shell, the surface and the ramp together, and with the default white page the shell, surface and borders look the same in light mode. `--cl-color-background-subtle` is renamed `--cl-color-surface-subtle`. -Brand and the negative, positive and warning colors now share one set of derived tokens: `-foreground`, `-subtle`, `-border` and `-alpha-100|200|300`, so overriding a seed retints its companions. `-foreground` flips to dark text in dark mode where white would fail, and the dark negative is a little lighter. `--cl-color-brand-secondary` is removed. `--cl-color-neutral-subtle` is new. `--cl-color-border` and `--cl-color-border-subtle` are now neutral washes (12 and 8 percent), so they stay visible on the surface and faded fills, and `--cl-color-input-placeholder` follows `--cl-color-foreground-secondary`, which is slightly darker in light mode. +Brand and the negative, positive and warning colors now share one set of derived tokens: `-foreground`, `-subtle`, `-border` and `-alpha-100|200|300`, so overriding a seed retints its companions. `-foreground` flips to dark text in dark mode where white would fail, and the dark negative is a little lighter. `--cl-color-brand-secondary` is removed. `--cl-color-neutral-subtle` is new. `--cl-color-border` and `--cl-color-border-subtle` are now neutral washes (7 and 4 percent in light mode, 12 and 8 in dark mode), so they stay visible on the surface and faded fills. + +In light mode the negative, positive and warning colors are slightly darker so their text passes contrast on their own `-subtle` fill, `--cl-color-foreground-secondary` is a little darker, and `--cl-color-input-placeholder` has its own value that now passes contrast on the field. diff --git a/packages/mosaic/ARCHITECTURE.md b/packages/mosaic/ARCHITECTURE.md index db1b2ec1c77..cf140eb4863 100644 --- a/packages/mosaic/ARCHITECTURE.md +++ b/packages/mosaic/ARCHITECTURE.md @@ -25,7 +25,7 @@ export const colorVars = stylex.defineVars({ Each exported `*Vars` const in that file is one group (`colorVars`, `radiusVars`, …); the file is the list. `space` is also declared with `defineVars`, but its keys are not `--cl-*` names, so its steps compile to hashed custom properties that are internal, not part of the contract. -There is no gray scale. Every achromatic token (`--cl-color-foreground`, `-border`, `-surface-subtle`, …) is `--cl-color-neutral` (black on light, white on dark) mixed into `--cl-color-surface` at a fixed percentage per mode, so overriding neutral to a warm gray retints the whole ramp. The exception is the borders: `--cl-color-border` (controls) is a 12 percent neutral wash and `--cl-color-border-subtle` (containers and dividers) an 8 percent one, in both modes, so they keep the same step over the surface and the faded surface alike. The `--cl--alpha-100|200|300` tokens in `colorVars` are washes (6, 8, and 12 percent of `--cl-color-neutral`, `-negative`, `-positive`, and `-warning` over `transparent`); overriding the solid color retints its washes. +There is no gray scale. Every achromatic token (`--cl-color-foreground`, `-border`, `-surface-subtle`, …) is `--cl-color-neutral` (black on light, white on dark) mixed into `--cl-color-surface` at a fixed percentage per mode, so overriding neutral to a warm gray retints the whole ramp. The exception is the borders: `--cl-color-border` (controls) and `--cl-color-border-subtle` (containers and dividers) are neutral washes, 7 and 4 percent in light mode and 12 and 8 percent in dark mode, so they keep the same step over the surface and the faded surface alike. The `--cl--alpha-100|200|300` tokens in `colorVars` are washes (6, 8, and 12 percent of `--cl-color-neutral`, `-negative`, `-positive`, and `-warning` over `transparent`); overriding the solid color retints its washes. The seeds are `--cl-color-page`, `--cl-color-neutral`, `--cl-color-brand`, `--cl-color-negative`, `--cl-color-positive` and `--cl-color-warning`. Brand and the three statuses share one shape: the seed, `-foreground` (white in light mode, the surface color in dark mode, where the fills are light), `-subtle` (the 8 percent wash, 12 in dark mode) and `-border` (the 12 percent wash), so both work over the page, the surface or a faded fill and `-alpha-100|200|300` washes. Neutral has the same slots under its own names: `--cl-color-surface-subtle`, `--cl-color-neutral-subtle` (the same wash as the accent `-subtle`, for chips such as the neutral badge) and `--cl-color-border`. Only the seeds and the `-foreground` pairs are set by hand, so retinting a seed retints its companions. diff --git a/packages/mosaic/src/tokens.stylex.ts b/packages/mosaic/src/tokens.stylex.ts index bf3269bef99..73e2a91a233 100644 --- a/packages/mosaic/src/tokens.stylex.ts +++ b/packages/mosaic/src/tokens.stylex.ts @@ -47,8 +47,10 @@ const colorDefaults = { '--cl-color-foreground-secondary': neutralMix(55, 64.25), '--cl-color-foreground-disabled': neutralMix(28.5, 44), - '--cl-color-border': 'var(--cl-color-neutral-alpha-300)', - '--cl-color-border-subtle': 'var(--cl-color-neutral-alpha-200)', + '--cl-color-border': + 'light-dark(color-mix(in oklab, var(--cl-color-neutral) 7%, transparent), var(--cl-color-neutral-alpha-300))', + '--cl-color-border-subtle': + 'light-dark(color-mix(in oklab, var(--cl-color-neutral) 4%, transparent), var(--cl-color-neutral-alpha-200))', '--cl-color-brand': 'light-dark(oklch(0.2046 0 0), oklch(0.9851 0 0))', '--cl-color-brand-foreground': 'light-dark(oklch(1 0 0), var(--cl-color-surface))', @@ -58,7 +60,7 @@ const colorDefaults = { '--cl-color-brand-alpha-200': 'color-mix(in oklab, var(--cl-color-brand) 8%, transparent)', '--cl-color-brand-alpha-300': 'color-mix(in oklab, var(--cl-color-brand) 12%, transparent)', - '--cl-color-negative': 'light-dark(oklch(0.577 0.2161 26.11), oklch(0.68 0.2 26.11))', + '--cl-color-negative': 'light-dark(oklch(0.56 0.2161 26.11), oklch(0.68 0.2 26.11))', '--cl-color-negative-foreground': 'light-dark(oklch(1 0 0), var(--cl-color-surface))', '--cl-color-negative-subtle': 'light-dark(var(--cl-color-negative-alpha-200), var(--cl-color-negative-alpha-300))', '--cl-color-negative-border': 'var(--cl-color-negative-alpha-300)', @@ -66,7 +68,7 @@ const colorDefaults = { '--cl-color-negative-alpha-200': 'color-mix(in oklab, var(--cl-color-negative) 8%, transparent)', '--cl-color-negative-alpha-300': 'color-mix(in oklab, var(--cl-color-negative) 12%, transparent)', - '--cl-color-positive': 'light-dark(oklch(0.6082 0.1799 145.47), oklch(0.7227 0.192 149.58))', + '--cl-color-positive': 'light-dark(oklch(0.55 0.15 148), oklch(0.7227 0.192 149.58))', '--cl-color-positive-foreground': 'light-dark(oklch(1 0 0), var(--cl-color-surface))', '--cl-color-positive-subtle': 'light-dark(var(--cl-color-positive-alpha-200), var(--cl-color-positive-alpha-300))', '--cl-color-positive-border': 'var(--cl-color-positive-alpha-300)', @@ -74,7 +76,7 @@ const colorDefaults = { '--cl-color-positive-alpha-200': 'color-mix(in oklab, var(--cl-color-positive) 8%, transparent)', '--cl-color-positive-alpha-300': 'color-mix(in oklab, var(--cl-color-positive) 12%, transparent)', - '--cl-color-warning': 'light-dark(oklch(0.7099 0.1888 44.94), oklch(0.7576 0.159 55.93))', + '--cl-color-warning': 'light-dark(oklch(0.6 0.16 48), oklch(0.7576 0.159 55.93))', '--cl-color-warning-foreground': 'light-dark(oklch(1 0 0), var(--cl-color-surface))', '--cl-color-warning-subtle': 'light-dark(var(--cl-color-warning-alpha-200), var(--cl-color-warning-alpha-300))', '--cl-color-warning-border': 'var(--cl-color-warning-alpha-300)', @@ -83,7 +85,7 @@ const colorDefaults = { '--cl-color-warning-alpha-300': 'color-mix(in oklab, var(--cl-color-warning) 12%, transparent)', '--cl-color-input': 'var(--cl-color-surface)', - '--cl-color-input-placeholder': 'var(--cl-color-foreground-secondary)', + '--cl-color-input-placeholder': neutralMix(45, 64.25), '--cl-color-ring': neutralMix(80, 90), } as const; From c77310e8602856d220997eb46fca99f57a43be0f Mon Sep 17 00:00:00 2001 From: Preston Booth Date: Thu, 29 Oct 2026 09:21:52 -0600 Subject: [PATCH 3/4] fix(mosaic): keep negative foreground white in dark mode The dark-surface foreground read wrong on the lighter dark negative fill, so negative keeps white text in both modes. Brand, positive and warning still flip to the surface color in dark mode. Co-Authored-By: Claude Sonnet 5.5 --- .changeset/mosaic-color-tokens.md | 2 +- packages/mosaic/ARCHITECTURE.md | 2 +- packages/mosaic/src/tokens.stylex.ts | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/.changeset/mosaic-color-tokens.md b/.changeset/mosaic-color-tokens.md index a36173ea52c..2b0b237c494 100644 --- a/.changeset/mosaic-color-tokens.md +++ b/.changeset/mosaic-color-tokens.md @@ -4,6 +4,6 @@ Dark mode gives the profile its own shell color. `--cl-color-background` is replaced by `--cl-color-page`, the base layer (near-black in dark mode, white in light), which backs the profile shell. A new `--cl-color-surface` backs everything raised on it, such as cards, sections, menus, popovers and controls, and the gray ramp mixes into it. The surface is derived from the page (neutral mixed 5 percent into it in dark mode, white mixed 60 percent into it in light mode), so retinting `--cl-color-page` retints the shell, the surface and the ramp together, and with the default white page the shell, surface and borders look the same in light mode. `--cl-color-background-subtle` is renamed `--cl-color-surface-subtle`. -Brand and the negative, positive and warning colors now share one set of derived tokens: `-foreground`, `-subtle`, `-border` and `-alpha-100|200|300`, so overriding a seed retints its companions. `-foreground` flips to dark text in dark mode where white would fail, and the dark negative is a little lighter. `--cl-color-brand-secondary` is removed. `--cl-color-neutral-subtle` is new. `--cl-color-border` and `--cl-color-border-subtle` are now neutral washes (7 and 4 percent in light mode, 12 and 8 in dark mode), so they stay visible on the surface and faded fills. +Brand and the negative, positive and warning colors now share one set of derived tokens: `-foreground`, `-subtle`, `-border` and `-alpha-100|200|300`, so overriding a seed retints its companions. `-foreground` flips to dark text in dark mode for brand, positive and warning, where white would fail, while negative keeps white text, and the dark negative is a little lighter. `--cl-color-brand-secondary` is removed. `--cl-color-neutral-subtle` is new. `--cl-color-border` and `--cl-color-border-subtle` are now neutral washes (7 and 4 percent in light mode, 12 and 8 in dark mode), so they stay visible on the surface and faded fills. In light mode the negative, positive and warning colors are slightly darker so their text passes contrast on their own `-subtle` fill, `--cl-color-foreground-secondary` is a little darker, and `--cl-color-input-placeholder` has its own value that now passes contrast on the field. diff --git a/packages/mosaic/ARCHITECTURE.md b/packages/mosaic/ARCHITECTURE.md index cf140eb4863..685fc00117f 100644 --- a/packages/mosaic/ARCHITECTURE.md +++ b/packages/mosaic/ARCHITECTURE.md @@ -27,7 +27,7 @@ Each exported `*Vars` const in that file is one group (`colorVars`, `radiusVars` There is no gray scale. Every achromatic token (`--cl-color-foreground`, `-border`, `-surface-subtle`, …) is `--cl-color-neutral` (black on light, white on dark) mixed into `--cl-color-surface` at a fixed percentage per mode, so overriding neutral to a warm gray retints the whole ramp. The exception is the borders: `--cl-color-border` (controls) and `--cl-color-border-subtle` (containers and dividers) are neutral washes, 7 and 4 percent in light mode and 12 and 8 percent in dark mode, so they keep the same step over the surface and the faded surface alike. The `--cl--alpha-100|200|300` tokens in `colorVars` are washes (6, 8, and 12 percent of `--cl-color-neutral`, `-negative`, `-positive`, and `-warning` over `transparent`); overriding the solid color retints its washes. -The seeds are `--cl-color-page`, `--cl-color-neutral`, `--cl-color-brand`, `--cl-color-negative`, `--cl-color-positive` and `--cl-color-warning`. Brand and the three statuses share one shape: the seed, `-foreground` (white in light mode, the surface color in dark mode, where the fills are light), `-subtle` (the 8 percent wash, 12 in dark mode) and `-border` (the 12 percent wash), so both work over the page, the surface or a faded fill and `-alpha-100|200|300` washes. Neutral has the same slots under its own names: `--cl-color-surface-subtle`, `--cl-color-neutral-subtle` (the same wash as the accent `-subtle`, for chips such as the neutral badge) and `--cl-color-border`. Only the seeds and the `-foreground` pairs are set by hand, so retinting a seed retints its companions. +The seeds are `--cl-color-page`, `--cl-color-neutral`, `--cl-color-brand`, `--cl-color-negative`, `--cl-color-positive` and `--cl-color-warning`. Brand and the three statuses share one shape: the seed, `-foreground` (white in light mode; in dark mode the surface color for brand, positive and warning, where the fills are light, and white for negative), `-subtle` (the 8 percent wash, 12 in dark mode) and `-border` (the 12 percent wash), so both work over the page, the surface or a faded fill and `-alpha-100|200|300` washes. Neutral has the same slots under its own names: `--cl-color-surface-subtle`, `--cl-color-neutral-subtle` (the same wash as the accent `-subtle`, for chips such as the neutral badge) and `--cl-color-border`. Only the seeds and the `-foreground` pairs are set by hand, so retinting a seed retints its companions. Three tiers carry the layering. `--cl-color-page` is the base: the shell of a settings surface such as the profile, and the darkest layer (`oklch(0.16 0 0)` in dark mode, white in light mode). `--cl-color-surface` is everything raised on it (cards, sections, menus, popovers, dialogs, controls) and the base the gray ramp mixes into. `--cl-color-surface-subtle` is the faded step on a surface, for footers, tiles and striped rows. The surface is derived, not set: in dark mode it is `--cl-color-neutral` mixed 5 percent into `--cl-color-page`, and in light mode it is white mixed 60 percent into `--cl-color-page`, so it is exactly white over a white page and still reads as near white over a gray one. So retinting `--cl-color-page` retints the shell, the surface and the whole ramp together; override `--cl-color-surface` directly only to change how far cards sit above the shell. All three are solid, never alpha, so they look the same over any backdrop. diff --git a/packages/mosaic/src/tokens.stylex.ts b/packages/mosaic/src/tokens.stylex.ts index 73e2a91a233..c590c14ecae 100644 --- a/packages/mosaic/src/tokens.stylex.ts +++ b/packages/mosaic/src/tokens.stylex.ts @@ -61,7 +61,7 @@ const colorDefaults = { '--cl-color-brand-alpha-300': 'color-mix(in oklab, var(--cl-color-brand) 12%, transparent)', '--cl-color-negative': 'light-dark(oklch(0.56 0.2161 26.11), oklch(0.68 0.2 26.11))', - '--cl-color-negative-foreground': 'light-dark(oklch(1 0 0), var(--cl-color-surface))', + '--cl-color-negative-foreground': 'oklch(1 0 0)', '--cl-color-negative-subtle': 'light-dark(var(--cl-color-negative-alpha-200), var(--cl-color-negative-alpha-300))', '--cl-color-negative-border': 'var(--cl-color-negative-alpha-300)', '--cl-color-negative-alpha-100': 'color-mix(in oklab, var(--cl-color-negative) 6%, transparent)', From 5a0acea0a2046da3bed689a725298b370cebf694 Mon Sep 17 00:00:00 2001 From: Preston Booth Date: Thu, 29 Oct 2026 15:41:39 -0600 Subject: [PATCH 4/4] fix(mosaic): keep the dark negative color as it was Dark mode keeps the original negative red instead of the lighter one, which also gives the white text on it better contrast. Co-Authored-By: Claude Sonnet 5.5 --- .changeset/mosaic-color-tokens.md | 2 +- packages/mosaic/src/tokens.stylex.ts | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/.changeset/mosaic-color-tokens.md b/.changeset/mosaic-color-tokens.md index 2b0b237c494..791d95329cd 100644 --- a/.changeset/mosaic-color-tokens.md +++ b/.changeset/mosaic-color-tokens.md @@ -4,6 +4,6 @@ Dark mode gives the profile its own shell color. `--cl-color-background` is replaced by `--cl-color-page`, the base layer (near-black in dark mode, white in light), which backs the profile shell. A new `--cl-color-surface` backs everything raised on it, such as cards, sections, menus, popovers and controls, and the gray ramp mixes into it. The surface is derived from the page (neutral mixed 5 percent into it in dark mode, white mixed 60 percent into it in light mode), so retinting `--cl-color-page` retints the shell, the surface and the ramp together, and with the default white page the shell, surface and borders look the same in light mode. `--cl-color-background-subtle` is renamed `--cl-color-surface-subtle`. -Brand and the negative, positive and warning colors now share one set of derived tokens: `-foreground`, `-subtle`, `-border` and `-alpha-100|200|300`, so overriding a seed retints its companions. `-foreground` flips to dark text in dark mode for brand, positive and warning, where white would fail, while negative keeps white text, and the dark negative is a little lighter. `--cl-color-brand-secondary` is removed. `--cl-color-neutral-subtle` is new. `--cl-color-border` and `--cl-color-border-subtle` are now neutral washes (7 and 4 percent in light mode, 12 and 8 in dark mode), so they stay visible on the surface and faded fills. +Brand and the negative, positive and warning colors now share one set of derived tokens: `-foreground`, `-subtle`, `-border` and `-alpha-100|200|300`, so overriding a seed retints its companions. `-foreground` flips to dark text in dark mode for brand, positive and warning, where white would fail, while negative keeps white text. `--cl-color-brand-secondary` is removed. `--cl-color-neutral-subtle` is new. `--cl-color-border` and `--cl-color-border-subtle` are now neutral washes (7 and 4 percent in light mode, 12 and 8 in dark mode), so they stay visible on the surface and faded fills. In light mode the negative, positive and warning colors are slightly darker so their text passes contrast on their own `-subtle` fill, `--cl-color-foreground-secondary` is a little darker, and `--cl-color-input-placeholder` has its own value that now passes contrast on the field. diff --git a/packages/mosaic/src/tokens.stylex.ts b/packages/mosaic/src/tokens.stylex.ts index c590c14ecae..330ea2788c9 100644 --- a/packages/mosaic/src/tokens.stylex.ts +++ b/packages/mosaic/src/tokens.stylex.ts @@ -60,7 +60,7 @@ const colorDefaults = { '--cl-color-brand-alpha-200': 'color-mix(in oklab, var(--cl-color-brand) 8%, transparent)', '--cl-color-brand-alpha-300': 'color-mix(in oklab, var(--cl-color-brand) 12%, transparent)', - '--cl-color-negative': 'light-dark(oklch(0.56 0.2161 26.11), oklch(0.68 0.2 26.11))', + '--cl-color-negative': 'light-dark(oklch(0.56 0.2161 26.11), oklch(0.6252 0.2161 26.11))', '--cl-color-negative-foreground': 'oklch(1 0 0)', '--cl-color-negative-subtle': 'light-dark(var(--cl-color-negative-alpha-200), var(--cl-color-negative-alpha-300))', '--cl-color-negative-border': 'var(--cl-color-negative-alpha-300)',