From ea4a369ffd3eb84fe007551e6263dca11cbdb471 Mon Sep 17 00:00:00 2001 From: Preston Booth Date: Fri, 23 Oct 2026 16:19:58 -0600 Subject: [PATCH 1/4] feat(mosaic): darker dark-mode shell with a derived surface color Co-Authored-By: Claude Sonnet 5.5 --- .changeset/mosaic-dark-surfaces.md | 5 ++++ packages/mosaic/ARCHITECTURE.md | 8 ++++--- .../action-bar/action-bar.styles.ts | 2 +- .../src/components/avatar/avatar.styles.ts | 2 +- .../src/components/button/button.styles.ts | 11 +++++---- .../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 +- .../src/components/dialog/dialog.styles.ts | 4 ++-- .../src/components/drawer/drawer.styles.ts | 2 +- .../mosaic/src/components/menu/menu.styles.ts | 8 ++++--- .../phone-input/phone-input.styles.ts | 2 +- .../src/components/profile/profile.styles.ts | 6 ++++- .../src/components/radio/radio.styles.ts | 2 +- .../src/components/section/section.styles.ts | 2 +- .../segmented-control.styles.ts | 2 +- .../src/components/select/select.styles.ts | 2 +- .../src/components/switch/switch.styles.ts | 2 +- .../src/components/table/table.styles.ts | 4 ++-- .../src/components/toast/toast.styles.ts | 2 +- ...-profile-billing-history-section.styles.ts | 2 +- packages/mosaic/src/styles/skeleton.styles.ts | 2 +- .../src/styles/tooltip-surface.styles.ts | 2 +- packages/mosaic/src/tokens.stylex.ts | 23 +++++++++++-------- packages/swingset/src/stories/button.mdx | 2 +- packages/swingset/src/stories/data-list.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 +- 31 files changed, 67 insertions(+), 50 deletions(-) create mode 100644 .changeset/mosaic-dark-surfaces.md diff --git a/.changeset/mosaic-dark-surfaces.md b/.changeset/mosaic-dark-surfaces.md new file mode 100644 index 00000000000..b4a259dacc2 --- /dev/null +++ b/.changeset/mosaic-dark-surfaces.md @@ -0,0 +1,5 @@ +--- +'@clerk/mosaic': minor +--- + +Dark mode gives the profile its own shell color. `--cl-color-background` is now the base layer, near-black in dark mode, and 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. It is derived from `--cl-color-background` (a 6.5 percent mix of `--cl-color-neutral` in dark mode, equal to it in light mode), so retinting `--cl-color-background` retints the shell, the surface and the ramp together, and light mode is unchanged. Dark-mode borders are slightly stronger, a 10.5 percent mix of `--cl-color-neutral` into `--cl-color-surface` instead of 8 percent. A profile opened in a dialog now draws a border. Destructive buttons and menu items tint red on hover. diff --git a/packages/mosaic/ARCHITECTURE.md b/packages/mosaic/ARCHITECTURE.md index c070b80702b..8117e750111 100644 --- a/packages/mosaic/ARCHITECTURE.md +++ b/packages/mosaic/ARCHITECTURE.md @@ -18,14 +18,16 @@ 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`, `-background-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 `--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. + +Two tiers carry the dark look. `--cl-color-background` is the base: the shell of a settings surface such as the profile, and the darkest layer (`oklch(0.133 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. It is derived, not set: in dark mode it is `--cl-color-neutral` mixed 6.5 percent into `--cl-color-background`, and in light mode it equals `--cl-color-background`. So retinting `--cl-color-background` 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. Both are solid, never alpha, so they look the same over any backdrop. Dark borders are the same kind of mix (`--cl-color-neutral` 10.5 percent into `--cl-color-surface`), not a wash, so they stay solid on every tier. 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 +53,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/button/button.styles.ts b/packages/mosaic/src/components/button/button.styles.ts index 33d2bbd03db..e57e179bcb8 100644 --- a/packages/mosaic/src/components/button/button.styles.ts +++ b/packages/mosaic/src/components/button/button.styles.ts @@ -22,6 +22,7 @@ import { iconScope } from '../icon/icon.markers.stylex'; const primaryHover = `color-mix(in oklab, ${colorVars['--cl-color-brand']}, ${colorVars['--cl-color-brand-foreground']} 12%)`; const primaryActive = `color-mix(in oklab, ${colorVars['--cl-color-brand']}, ${colorVars['--cl-color-brand-foreground']} 18%)`; const negativeHover = `color-mix(in oklab, ${colorVars['--cl-color-negative']}, ${colorVars['--cl-color-negative-foreground']} 12%)`; +const negativePressed = `color-mix(in oklab, ${colorVars['--cl-color-negative-subtle']}, ${colorVars['--cl-color-negative']} 8%)`; const negativeActive = `color-mix(in oklab, ${colorVars['--cl-color-negative']}, ${colorVars['--cl-color-negative-foreground']} 18%)`; // Interactive states are gated on `:not([data-disabled])`: the button stays hit-testable while @@ -209,11 +210,11 @@ export const variants = stylex.create({ borderColor: colorVars['--cl-color-border'], backgroundColor: { default: 'transparent', - ':not([data-disabled]):active': colorVars['--cl-color-neutral-alpha-200'], - ':not([data-disabled])[data-open]': colorVars['--cl-color-neutral-alpha-200'], + ':not([data-disabled]):active': negativePressed, + ':not([data-disabled])[data-open]': negativePressed, '@media (hover: hover)': { default: null, - ':not([data-disabled]):hover:not(:active):not([data-open])': colorVars['--cl-color-neutral-alpha-100'], + ':not([data-disabled]):hover:not(:active):not([data-open])': colorVars['--cl-color-negative-subtle'], }, }, color: colorVars['--cl-color-negative'], @@ -248,8 +249,8 @@ export const variants = stylex.create({ 'ghost-negative': { backgroundColor: { default: 'transparent', - ':not([data-disabled]):active': `color-mix(in oklab, ${colorVars['--cl-color-negative-subtle']}, ${colorVars['--cl-color-negative']} 8%)`, - ':not([data-disabled])[data-open]': `color-mix(in oklab, ${colorVars['--cl-color-negative-subtle']}, ${colorVars['--cl-color-negative']} 8%)`, + ':not([data-disabled]):active': negativePressed, + ':not([data-disabled])[data-open]': negativePressed, '@media (hover: hover)': { default: null, ':not([data-disabled]):hover:not(:active):not([data-open])': colorVars['--cl-color-negative-subtle'], 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/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 11ec39b5c9a..ee80db82abe 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: { @@ -80,6 +80,8 @@ export const viewport = stylex.create({ }, }); +const negativeActive = `light-dark(${colorVars['--cl-color-negative-alpha-200']}, ${colorVars['--cl-color-negative-subtle']})`; + export const item = stylex.create({ base: { borderRadius: '0.375rem', @@ -128,9 +130,9 @@ export const item = stylex.create({ negative: { backgroundColor: { default: 'transparent', - ':is([data-active])': colorVars['--cl-color-negative-alpha-200'], + ':is([data-active])': negativeActive, '@media (hover: hover)': { - ':hover': colorVars['--cl-color-negative-alpha-200'], + ':hover': negativeActive, }, }, color: colorVars['--cl-color-negative'], 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 23a6f199762..faf0ed7fb91 100644 --- a/packages/mosaic/src/components/phone-input/phone-input.styles.ts +++ b/packages/mosaic/src/components/phone-input/phone-input.styles.ts @@ -41,7 +41,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..c6ccf226a3b 100644 --- a/packages/mosaic/src/components/profile/profile.styles.ts +++ b/packages/mosaic/src/components/profile/profile.styles.ts @@ -104,7 +104,10 @@ export const styles = stylex.create({ }, layoutInDialog: { - borderWidth: '0px', + borderWidth: { + [compact]: '0px', + default: '1px', + }, blockSize: 'auto', boxShadow: { [compact]: 'none', @@ -120,6 +123,7 @@ export const styles = stylex.create({ layoutFullscreen: { borderRadius: 0, + borderWidth: '0px', boxShadow: 'none', }, 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..36a3c5b213b 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; 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 2aa2c763c54..2696c57ea7d 100644 --- a/packages/mosaic/src/components/table/table.styles.ts +++ b/packages/mosaic/src/components/table/table.styles.ts @@ -24,7 +24,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: { @@ -34,7 +34,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', 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/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 f8f9229d79c..9d8c82a65ba 100644 --- a/packages/mosaic/src/tokens.stylex.ts +++ b/packages/mosaic/src/tokens.stylex.ts @@ -23,12 +23,12 @@ import * as stylex from '@stylexjs/stylex'; // Color Tokens // ============================================================================= -// Every achromatic token is `--cl-color-neutral` mixed into `--cl-color-background`, so +// Every achromatic token is `--cl-color-neutral` mixed into `--cl-color-surface`, 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. +// Dark mode needs its own percentage because its surface is a 900, not black. 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,20 +36,23 @@ 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': 'light-dark(oklch(1 0 0), oklch(0.133 0 0))', '--cl-color-background-subtle': neutralMix(3, 4.25), + '--cl-color-surface': + 'light-dark(var(--cl-color-background), color-mix(in oklab, var(--cl-color-neutral) 6.5%, var(--cl-color-background)))', '--cl-color-foreground': neutralMix(79.5, 98), '--cl-color-foreground-secondary': neutralMix(44.45, 64.25), '--cl-color-foreground-disabled': neutralMix(28.5, 44), - '--cl-color-border': neutralMix(5.5, 8), + '--cl-color-border': + 'light-dark(color-mix(in oklab, var(--cl-color-neutral) 5.5%, var(--cl-color-surface)), color-mix(in oklab, var(--cl-color-neutral) 10.5%, var(--cl-color-surface)))', '--cl-color-border-subtle': neutralMix(3, 4.25), '--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-brand-foreground': 'light-dark(oklch(1 0 0), var(--cl-color-surface))', '--cl-color-negative': 'oklch(0.6252 0.2161 26.11)', '--cl-color-negative-foreground': 'oklch(1 0 0)', @@ -75,7 +78,7 @@ const colorDefaults = { '--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': 'var(--cl-color-surface)', '--cl-color-input-placeholder': neutralMix(28, 64), '--cl-color-ring': neutralMix(80, 90), @@ -146,7 +149,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 +172,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/button.mdx b/packages/swingset/src/stories/button.mdx index 294b0718f3d..5e6987da1cd 100644 --- a/packages/swingset/src/stories/button.mdx +++ b/packages/swingset/src/stories/button.mdx @@ -60,7 +60,7 @@ it to show. Every combination below is the same two props. Hover and focus are part of the matrix — hover a cell, or tab through it, to see them. `filled` shifts its fill and `link` underlines. `outline` and `ghost` share the same neutral hover fill, -except `ghost` under `negative`, which is the one cell that tints by color. The focus ring is +except under `negative`, where both tint by color. The focus ring is the same for every cell. Date: Fri, 23 Oct 2026 16:28:12 -0600 Subject: [PATCH 2/4] fix(mosaic): keep the dark border at the existing 8 percent mix Co-Authored-By: Claude Sonnet 5.5 --- .changeset/mosaic-dark-surfaces.md | 2 +- packages/mosaic/ARCHITECTURE.md | 2 +- packages/mosaic/src/tokens.stylex.ts | 3 +-- 3 files changed, 3 insertions(+), 4 deletions(-) diff --git a/.changeset/mosaic-dark-surfaces.md b/.changeset/mosaic-dark-surfaces.md index b4a259dacc2..527ee9e69e9 100644 --- a/.changeset/mosaic-dark-surfaces.md +++ b/.changeset/mosaic-dark-surfaces.md @@ -2,4 +2,4 @@ '@clerk/mosaic': minor --- -Dark mode gives the profile its own shell color. `--cl-color-background` is now the base layer, near-black in dark mode, and 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. It is derived from `--cl-color-background` (a 6.5 percent mix of `--cl-color-neutral` in dark mode, equal to it in light mode), so retinting `--cl-color-background` retints the shell, the surface and the ramp together, and light mode is unchanged. Dark-mode borders are slightly stronger, a 10.5 percent mix of `--cl-color-neutral` into `--cl-color-surface` instead of 8 percent. A profile opened in a dialog now draws a border. Destructive buttons and menu items tint red on hover. +Dark mode gives the profile its own shell color. `--cl-color-background` is now the base layer, near-black in dark mode, and 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. It is derived from `--cl-color-background` (a 6.5 percent mix of `--cl-color-neutral` in dark mode, equal to it in light mode), so retinting `--cl-color-background` retints the shell, the surface and the ramp together, and light mode is unchanged. A profile opened in a dialog now draws a border. Destructive buttons and menu items tint red on hover. diff --git a/packages/mosaic/ARCHITECTURE.md b/packages/mosaic/ARCHITECTURE.md index 8117e750111..4faed268681 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`, `-background-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 `--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. -Two tiers carry the dark look. `--cl-color-background` is the base: the shell of a settings surface such as the profile, and the darkest layer (`oklch(0.133 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. It is derived, not set: in dark mode it is `--cl-color-neutral` mixed 6.5 percent into `--cl-color-background`, and in light mode it equals `--cl-color-background`. So retinting `--cl-color-background` 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. Both are solid, never alpha, so they look the same over any backdrop. Dark borders are the same kind of mix (`--cl-color-neutral` 10.5 percent into `--cl-color-surface`), not a wash, so they stay solid on every tier. +Two tiers carry the dark look. `--cl-color-background` is the base: the shell of a settings surface such as the profile, and the darkest layer (`oklch(0.133 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. It is derived, not set: in dark mode it is `--cl-color-neutral` mixed 6.5 percent into `--cl-color-background`, and in light mode it equals `--cl-color-background`. So retinting `--cl-color-background` 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. Both 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. diff --git a/packages/mosaic/src/tokens.stylex.ts b/packages/mosaic/src/tokens.stylex.ts index 9d8c82a65ba..4b55c74860e 100644 --- a/packages/mosaic/src/tokens.stylex.ts +++ b/packages/mosaic/src/tokens.stylex.ts @@ -45,8 +45,7 @@ const colorDefaults = { '--cl-color-foreground-secondary': neutralMix(44.45, 64.25), '--cl-color-foreground-disabled': neutralMix(28.5, 44), - '--cl-color-border': - 'light-dark(color-mix(in oklab, var(--cl-color-neutral) 5.5%, var(--cl-color-surface)), color-mix(in oklab, var(--cl-color-neutral) 10.5%, var(--cl-color-surface)))', + '--cl-color-border': neutralMix(5.5, 8), '--cl-color-border-subtle': neutralMix(3, 4.25), '--cl-color-brand': 'light-dark(oklch(0.2046 0 0), oklch(0.9851 0 0))', From aa3cdae8ccdd1d175c3a4d8a7c0cfecf64752f60 Mon Sep 17 00:00:00 2001 From: Preston Booth Date: Tue, 27 Oct 2026 14:22:59 -0600 Subject: [PATCH 3/4] feat(mosaic): page, surface and surface-subtle color tiers Co-Authored-By: Claude Sonnet 5.5 --- .changeset/mosaic-dark-surfaces.md | 2 +- packages/mosaic/ARCHITECTURE.md | 4 +- .../src/components/button/button.styles.ts | 24 ++++---- .../components/data-list/data-list.styles.ts | 2 +- .../dialog/__tests__/dialog.test.tsx | 2 +- .../src/components/profile/profile.styles.ts | 10 +--- .../src/components/section/section.styles.ts | 20 +++++++ .../src/components/section/section.test.tsx | 7 +++ .../mosaic/src/components/section/section.tsx | 18 ++++++ .../segmented-control.styles.ts | 2 +- .../src/components/table/table.styles.ts | 8 +-- .../user-profile-backup-codes.styles.ts | 2 +- packages/mosaic/src/tokens.stylex.ts | 18 +++--- packages/swingset/src/stories/icon-frame.mdx | 2 +- packages/swingset/src/stories/section.mdx | 10 ++++ .../swingset/src/stories/section.stories.tsx | 58 +++++++++++++++++++ packages/swingset/src/stories/toast.mdx | 2 +- 17 files changed, 152 insertions(+), 39 deletions(-) diff --git a/.changeset/mosaic-dark-surfaces.md b/.changeset/mosaic-dark-surfaces.md index 527ee9e69e9..5cfcc93ea7f 100644 --- a/.changeset/mosaic-dark-surfaces.md +++ b/.changeset/mosaic-dark-surfaces.md @@ -2,4 +2,4 @@ '@clerk/mosaic': minor --- -Dark mode gives the profile its own shell color. `--cl-color-background` is now the base layer, near-black in dark mode, and 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. It is derived from `--cl-color-background` (a 6.5 percent mix of `--cl-color-neutral` in dark mode, equal to it in light mode), so retinting `--cl-color-background` retints the shell, the surface and the ramp together, and light mode is unchanged. A profile opened in a dialog now draws a border. Destructive buttons and menu items tint red on hover. +Dark mode gives the profile its own shell color. A new `--cl-color-page` is the base layer (near-black in dark mode, white in light) and backs the profile shell, replacing `--cl-color-background`. 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. It is derived from `--cl-color-page` (a 6 percent mix of `--cl-color-neutral` in dark mode, white mixed 60 percent into the page in light mode), so retinting `--cl-color-page` retints the shell, the surface and the ramp together. With the default white page, light mode is unchanged, and over a gray page the surface stays near white. `--cl-color-background-subtle` is renamed `--cl-color-surface-subtle`. Borders mix into `--cl-color-surface-subtle`, so they stay visible on a darker faded surface. Destructive buttons and menu items tint red on hover. Outline buttons now fill with the surface color instead of staying transparent. A new `Section.Footer` sits on the faded surface under a section card. diff --git a/packages/mosaic/ARCHITECTURE.md b/packages/mosaic/ARCHITECTURE.md index 1215fef286e..41690e5aa17 100644 --- a/packages/mosaic/ARCHITECTURE.md +++ b/packages/mosaic/ARCHITECTURE.md @@ -25,9 +25,9 @@ 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`, `-background-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 `--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 `--cl-color-border`, which mixes into `--cl-color-surface-subtle` instead, so a darker faded surface never swallows the border drawn on 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. -Two tiers carry the dark look. `--cl-color-background` is the base: the shell of a settings surface such as the profile, and the darkest layer (`oklch(0.133 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. It is derived, not set: in dark mode it is `--cl-color-neutral` mixed 6.5 percent into `--cl-color-background`, and in light mode it equals `--cl-color-background`. So retinting `--cl-color-background` 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. Both are solid, never alpha, so they look the same over any backdrop. +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 6 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. diff --git a/packages/mosaic/src/components/button/button.styles.ts b/packages/mosaic/src/components/button/button.styles.ts index e57e179bcb8..7e8b022ad03 100644 --- a/packages/mosaic/src/components/button/button.styles.ts +++ b/packages/mosaic/src/components/button/button.styles.ts @@ -13,14 +13,16 @@ import { import { iconScope } from '../icon/icon.markers.stylex'; // Neutral fills are the three neutral alpha steps, shared across variants: -// 100 — `filled-neutral` rest, `outline-*`/`ghost-*` hover -// 200 — `filled-neutral` hover, `outline-*`/`ghost-*` pressed +// 100 — `filled-neutral` rest, `ghost-*` hover +// 200 — `filled-neutral` hover, `ghost-*` pressed // 300 — `filled-neutral` pressed // Opaque fills blend toward their own on-fill at the same 12%/18%. Neutral has no fill // to blend from, so it rides the opacity ramp above. const primaryHover = `color-mix(in oklab, ${colorVars['--cl-color-brand']}, ${colorVars['--cl-color-brand-foreground']} 12%)`; const primaryActive = `color-mix(in oklab, ${colorVars['--cl-color-brand']}, ${colorVars['--cl-color-brand-foreground']} 18%)`; +const outlineHover = `color-mix(in oklab, ${colorVars['--cl-color-neutral']} 6%, ${colorVars['--cl-color-surface']})`; +const outlinePressed = `color-mix(in oklab, ${colorVars['--cl-color-neutral']} 8%, ${colorVars['--cl-color-surface']})`; const negativeHover = `color-mix(in oklab, ${colorVars['--cl-color-negative']}, ${colorVars['--cl-color-negative-foreground']} 12%)`; const negativePressed = `color-mix(in oklab, ${colorVars['--cl-color-negative-subtle']}, ${colorVars['--cl-color-negative']} 8%)`; const negativeActive = `color-mix(in oklab, ${colorVars['--cl-color-negative']}, ${colorVars['--cl-color-negative-foreground']} 18%)`; @@ -183,12 +185,12 @@ export const variants = stylex.create({ 'outline-primary': { borderColor: colorVars['--cl-color-border'], backgroundColor: { - default: 'transparent', - ':not([data-disabled]):active': colorVars['--cl-color-neutral-alpha-200'], - ':not([data-disabled])[data-open]': colorVars['--cl-color-neutral-alpha-200'], + default: colorVars['--cl-color-surface'], + ':not([data-disabled]):active': outlinePressed, + ':not([data-disabled])[data-open]': outlinePressed, '@media (hover: hover)': { default: null, - ':not([data-disabled]):hover:not(:active):not([data-open])': colorVars['--cl-color-neutral-alpha-100'], + ':not([data-disabled]):hover:not(:active):not([data-open])': outlineHover, }, }, color: colorVars['--cl-color-brand'], @@ -196,12 +198,12 @@ export const variants = stylex.create({ 'outline-neutral': { borderColor: colorVars['--cl-color-border'], backgroundColor: { - default: 'transparent', - ':not([data-disabled]):active': colorVars['--cl-color-neutral-alpha-200'], - ':not([data-disabled])[data-open]': colorVars['--cl-color-neutral-alpha-200'], + default: colorVars['--cl-color-surface'], + ':not([data-disabled]):active': outlinePressed, + ':not([data-disabled])[data-open]': outlinePressed, '@media (hover: hover)': { default: null, - ':not([data-disabled]):hover:not(:active):not([data-open])': colorVars['--cl-color-neutral-alpha-100'], + ':not([data-disabled]):hover:not(:active):not([data-open])': outlineHover, }, }, color: colorVars['--cl-color-foreground'], @@ -209,7 +211,7 @@ export const variants = stylex.create({ 'outline-negative': { borderColor: colorVars['--cl-color-border'], backgroundColor: { - default: 'transparent', + default: colorVars['--cl-color-surface'], ':not([data-disabled]):active': negativePressed, ':not([data-disabled])[data-open]': negativePressed, '@media (hover: hover)': { 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/profile/profile.styles.ts b/packages/mosaic/src/components/profile/profile.styles.ts index c6ccf226a3b..8fd1c3326bd 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', @@ -104,10 +104,7 @@ export const styles = stylex.create({ }, layoutInDialog: { - borderWidth: { - [compact]: '0px', - default: '1px', - }, + borderWidth: '0px', blockSize: 'auto', boxShadow: { [compact]: 'none', @@ -123,7 +120,6 @@ export const styles = stylex.create({ layoutFullscreen: { borderRadius: 0, - borderWidth: '0px', boxShadow: 'none', }, @@ -133,7 +129,7 @@ export const styles = stylex.create({ nav: { padding: space['4'], - borderInlineEndColor: colorVars['--cl-color-border'], + borderInlineEndColor: colorVars['--cl-color-border-subtle'], borderInlineEndStyle: 'solid', borderInlineEndWidth: '1px', // Hidden in CSS too, so the column never shows on a phone before measuring. diff --git a/packages/mosaic/src/components/section/section.styles.ts b/packages/mosaic/src/components/section/section.styles.ts index 0904539df9f..1c0b98eff5e 100644 --- a/packages/mosaic/src/components/section/section.styles.ts +++ b/packages/mosaic/src/components/section/section.styles.ts @@ -52,6 +52,26 @@ export const styles = stylex.create({ backgroundColor: colorVars['--cl-color-surface'], display: 'flex', flexDirection: 'column', + position: 'relative', + width: 'auto', + }, + footer: { + borderColor: colorVars['--cl-color-border'], + borderRadius: radiusVars['--cl-radius-xl'], + borderStyle: 'solid', + borderWidth: '1px', + paddingBlock: space['3'], + paddingInline: space['4'], + alignItems: 'center', + backgroundColor: colorVars['--cl-color-surface-subtle'], + color: colorVars['--cl-color-foreground-secondary'], + columnGap: space['3'], + display: 'flex', + fontSize: typeScaleVars['--cl-text-sm-size'], + justifyContent: 'space-between', + lineHeight: typeScaleVars['--cl-text-sm-leading'], + marginBlockStart: `calc(${radiusVars['--cl-radius-xl']} * -1)`, + paddingBlockStart: `calc(${radiusVars['--cl-radius-xl']} + ${space['3']})`, width: 'auto', }, row: { diff --git a/packages/mosaic/src/components/section/section.test.tsx b/packages/mosaic/src/components/section/section.test.tsx index c1c29f54768..600f771c89b 100644 --- a/packages/mosaic/src/components/section/section.test.tsx +++ b/packages/mosaic/src/components/section/section.test.tsx @@ -392,6 +392,13 @@ describe('Section', () => { expect(note.parentElement).toBe(screen.getByTestId('item')); }); + it('renders a footer with its theme class', () => { + render(Changing plans); + + expect(screen.getByTestId('footer')).toHaveClass('cl-section-footer'); + expect(screen.getByTestId('footer')).toHaveTextContent('Changing plans'); + }); + it('leaves out the glyph slot when the note carries no icon', () => { render(Managed by Acme SSO); diff --git a/packages/mosaic/src/components/section/section.tsx b/packages/mosaic/src/components/section/section.tsx index e3435143c62..895ec39254d 100644 --- a/packages/mosaic/src/components/section/section.tsx +++ b/packages/mosaic/src/components/section/section.tsx @@ -22,6 +22,7 @@ export type SectionGroupProps = MosaicComponentProps<'div'>; export type SectionHeaderProps = MosaicComponentProps<'div'>; export type SectionTitleProps = Omit; export type SectionBodyProps = MosaicComponentProps<'div'>; +export type SectionFooterProps = MosaicComponentProps<'div'>; export type SectionRowProps = MosaicComponentProps<'div'>; export type SectionItemsProps = MosaicComponentProps<'ul'>; export type SectionItemProps = MosaicComponentProps<'div'> & { wrap?: boolean }; @@ -129,6 +130,22 @@ const Body = React.forwardRef(function Section }); }); +/** + * A strip on the faded surface tucked under `Section.Body`, for a summary or a link that belongs to + * the card above it. Place it directly after `Section.Body` in the group. + */ +const Footer = React.forwardRef(function SectionFooter( + { render, xstyle, ...rest }, + ref, +) { + return useRender({ + defaultTagName: 'div', + render, + ref, + props: mergeStyleProps(themeProps('section-footer'), stylex.props(reset.base, styles.footer, xstyle), rest), + }); +}); + const Row = React.forwardRef(function SectionRow({ render, xstyle, ...rest }, ref) { return useRender({ defaultTagName: 'div', @@ -358,6 +375,7 @@ export const Section = { Header, Title, Body, + Footer, Row, Items, Item, 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 36a3c5b213b..7e384c2263e 100644 --- a/packages/mosaic/src/components/segmented-control/segmented-control.styles.ts +++ b/packages/mosaic/src/components/segmented-control/segmented-control.styles.ts @@ -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/table/table.styles.ts b/packages/mosaic/src/components/table/table.styles.ts index 5ff1ea75c51..6d2036d8d67 100644 --- a/packages/mosaic/src/components/table/table.styles.ts +++ b/packages/mosaic/src/components/table/table.styles.ts @@ -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/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/tokens.stylex.ts b/packages/mosaic/src/tokens.stylex.ts index b452a6cdba4..8283ca03e5e 100644 --- a/packages/mosaic/src/tokens.stylex.ts +++ b/packages/mosaic/src/tokens.stylex.ts @@ -24,9 +24,10 @@ import * as stylex from '@stylexjs/stylex'; // ============================================================================= // Every achromatic token is `--cl-color-neutral` mixed into `--cl-color-surface`, 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 surface is a 900, not black. +// 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 6, subtle 4.5, border 2.5). const neutralMix = (light: number, dark: number) => `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)))`; @@ -36,17 +37,18 @@ 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.133 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(var(--cl-color-background), color-mix(in oklab, var(--cl-color-neutral) 6.5%, var(--cl-color-background)))', + 'light-dark(color-mix(in oklab, oklch(1 0 0) 60%, var(--cl-color-page)), color-mix(in oklab, var(--cl-color-neutral) 6%, var(--cl-color-page)))', + '--cl-color-surface-subtle': neutralMix(3, 4.5), '--cl-color-foreground': neutralMix(79.5, 98), '--cl-color-foreground-secondary': neutralMix(50, 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': + 'light-dark(color-mix(in oklab, var(--cl-color-neutral) 2.5%, var(--cl-color-surface-subtle)), color-mix(in oklab, var(--cl-color-neutral) 2.5%, var(--cl-color-surface-subtle)))', + '--cl-color-border-subtle': neutralMix(3, 4.5), '--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))', diff --git a/packages/swingset/src/stories/icon-frame.mdx b/packages/swingset/src/stories/icon-frame.mdx index 3624387fe1b..2ca1da09e7e 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-neutral-alpha-100`. ## Playground diff --git a/packages/swingset/src/stories/section.mdx b/packages/swingset/src/stories/section.mdx index 46fe68be47a..a294c38c212 100644 --- a/packages/swingset/src/stories/section.mdx +++ b/packages/swingset/src/stories/section.mdx @@ -71,6 +71,7 @@ title. See [Panel](/components/panel) and [Heading](/components/heading). | `Section.Header` | `
` | `.cl-section-header` | Content and optional actions. | | `Section.Title` | heading | `.cl-section-title` | The group's heading. | | `Section.Body` | `
` | `.cl-section-body` | The card under the header. | +| `Section.Footer` | `
` | `.cl-section-footer` | A strip on `--cl-color-surface-subtle` tucked under the body. | | `Section.Row` | `
` | `.cl-section-row` | One setting. | | `Section.Items` | `
    ` | `.cl-section-items` | A list of values. | | `Section.Item` | `
    ` | `.cl-section-item` | Media, content, and actions on one line. `
  • ` inside `Items`. | @@ -102,6 +103,15 @@ inside `Section.Items` reflect `data-nested`. storyModule={SectionStories} /> +### Footer + +`Section.Footer` follows `Section.Body` in the group. It sits on `--cl-color-surface-subtle` and tucks under the card, so the page, `--cl-color-surface` and the faded surface stack in that order. + + + ### Header description + + + + Subscription + + + + + + + + Basic Ends Oct 22 + + $12 / month + + + + + + + + + + Next invoice + Due October 22, 2026 + + $199.00 + + + + + + + Changing to the Basic plan on October 22, 2026 + + + + + + ); +} + export function ContainedHeader() { return ( <> diff --git a/packages/swingset/src/stories/toast.mdx b/packages/swingset/src/stories/toast.mdx index 049a11d630f..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-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-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); From 320efad8882b4bd0d3d02081f63fc1db29607e71 Mon Sep 17 00:00:00 2001 From: Preston Booth Date: Tue, 27 Oct 2026 14:49:24 -0600 Subject: [PATCH 4/4] feat(mosaic): wash borders and tune surface tiers Co-Authored-By: Claude Sonnet 5.5 --- .changeset/mosaic-dark-surfaces.md | 2 +- packages/mosaic/ARCHITECTURE.md | 4 +- .../src/components/icon/icon-frame.styles.ts | 2 +- packages/mosaic/src/tokens.stylex.ts | 9 +- .../swingset/src/components/ClientRoot.tsx | 89 ++++++++++--------- .../swingset/src/components/PageProvider.tsx | 57 ++++++++++++ .../swingset/src/components/PageToggle.tsx | 42 +++++++++ packages/swingset/src/stories/icon-frame.mdx | 2 +- 8 files changed, 155 insertions(+), 52 deletions(-) create mode 100644 packages/swingset/src/components/PageProvider.tsx create mode 100644 packages/swingset/src/components/PageToggle.tsx diff --git a/.changeset/mosaic-dark-surfaces.md b/.changeset/mosaic-dark-surfaces.md index 5cfcc93ea7f..dc024e3f541 100644 --- a/.changeset/mosaic-dark-surfaces.md +++ b/.changeset/mosaic-dark-surfaces.md @@ -2,4 +2,4 @@ '@clerk/mosaic': minor --- -Dark mode gives the profile its own shell color. A new `--cl-color-page` is the base layer (near-black in dark mode, white in light) and backs the profile shell, replacing `--cl-color-background`. 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. It is derived from `--cl-color-page` (a 6 percent mix of `--cl-color-neutral` in dark mode, white mixed 60 percent into the page in light mode), so retinting `--cl-color-page` retints the shell, the surface and the ramp together. With the default white page, light mode is unchanged, and over a gray page the surface stays near white. `--cl-color-background-subtle` is renamed `--cl-color-surface-subtle`. Borders mix into `--cl-color-surface-subtle`, so they stay visible on a darker faded surface. Destructive buttons and menu items tint red on hover. Outline buttons now fill with the surface color instead of staying transparent. A new `Section.Footer` sits on the faded surface under a section card. +Dark mode gives the profile its own shell color. A new `--cl-color-page` is the base layer (near-black in dark mode, white in light) and backs the profile shell, replacing `--cl-color-background`. 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. It is derived from `--cl-color-page` (a 5 percent mix of `--cl-color-neutral` in dark mode, white mixed 60 percent into the page in light mode), so retinting `--cl-color-page` retints the shell, the surface and the ramp together. With the default white page, light mode is unchanged, and over a gray page the surface stays near white. `--cl-color-background-subtle` is renamed `--cl-color-surface-subtle`. Borders are now a neutral wash, so they stay visible on the faded surface as well as the surface. Destructive buttons and menu items tint red on hover. Outline buttons now fill with the surface color instead of staying transparent. A new `Section.Footer` sits on the faded surface under a section card. diff --git a/packages/mosaic/ARCHITECTURE.md b/packages/mosaic/ARCHITECTURE.md index 41690e5aa17..973d86b0dda 100644 --- a/packages/mosaic/ARCHITECTURE.md +++ b/packages/mosaic/ARCHITECTURE.md @@ -25,9 +25,9 @@ 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 `--cl-color-border`, which mixes into `--cl-color-surface-subtle` instead, so a darker faded surface never swallows the border drawn on 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 `--cl-color-border`, which is a neutral wash (8 percent in light mode, 12 in dark), so it keeps 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. -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 6 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. +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. diff --git a/packages/mosaic/src/components/icon/icon-frame.styles.ts b/packages/mosaic/src/components/icon/icon-frame.styles.ts index aba75af6bb7..1ae1ee7d009 100644 --- a/packages/mosaic/src/components/icon/icon-frame.styles.ts +++ b/packages/mosaic/src/components/icon/icon-frame.styles.ts @@ -20,7 +20,7 @@ export const styles = stylex.create({ borderColor: colorVars['--cl-color-border'], }, filled: { - backgroundColor: colorVars['--cl-color-neutral-alpha-100'], + backgroundColor: colorVars['--cl-color-surface-subtle'], }, }); diff --git a/packages/mosaic/src/tokens.stylex.ts b/packages/mosaic/src/tokens.stylex.ts index 8283ca03e5e..bdd20a7443f 100644 --- a/packages/mosaic/src/tokens.stylex.ts +++ b/packages/mosaic/src/tokens.stylex.ts @@ -27,7 +27,7 @@ import * as stylex from '@stylexjs/stylex'; // 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 6, subtle 4.5, border 2.5). +// (surface 5, subtle 4.5). The border is a wash, so it keeps 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-surface)), color-mix(in oklab, var(--cl-color-neutral) ${dark}%, var(--cl-color-surface)))`; @@ -39,15 +39,14 @@ const colorDefaults = { '--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) 6%, var(--cl-color-page)))', - '--cl-color-surface-subtle': neutralMix(3, 4.5), + '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.5, 4.5), '--cl-color-foreground': neutralMix(79.5, 98), '--cl-color-foreground-secondary': neutralMix(50, 64.25), '--cl-color-foreground-disabled': neutralMix(28.5, 44), - '--cl-color-border': - 'light-dark(color-mix(in oklab, var(--cl-color-neutral) 2.5%, var(--cl-color-surface-subtle)), color-mix(in oklab, var(--cl-color-neutral) 2.5%, var(--cl-color-surface-subtle)))', + '--cl-color-border': 'light-dark(var(--cl-color-neutral-alpha-200), var(--cl-color-neutral-alpha-300))', '--cl-color-border-subtle': neutralMix(3, 4.5), '--cl-color-brand': 'light-dark(oklch(0.2046 0 0), oklch(0.9851 0 0))', diff --git a/packages/swingset/src/components/ClientRoot.tsx b/packages/swingset/src/components/ClientRoot.tsx index 6a3ba3618a2..883e37f60f9 100644 --- a/packages/swingset/src/components/ClientRoot.tsx +++ b/packages/swingset/src/components/ClientRoot.tsx @@ -21,6 +21,8 @@ import { ChaosToggle } from './ChaosToggle'; import { CommandPalette } from './CommandPalette'; import { DirectionProvider } from './DirectionProvider'; import { DirectionToggle } from './DirectionToggle'; +import { PageProvider } from './PageProvider'; +import { PageToggle } from './PageToggle'; import { ThemeToggle } from './ThemeToggle'; function useBreadcrumb() { @@ -56,48 +58,51 @@ export function ClientRoot({ children }: { children: React.ReactNode }) { return ( - - - - setSearchOpen(true)} /> - -
    - - - {crumbs.length > 0 && ( - - - {crumbs.map((crumb, i) => ( - - {i > 0 && } - - {i < crumbs.length - 1 ? ( - {crumb} - ) : ( - {crumb} - )} - - - ))} - - - )} -
    - - - -
    -
    - {children} -
    -
    -
    + + + + + setSearchOpen(true)} /> + +
    + + + {crumbs.length > 0 && ( + + + {crumbs.map((crumb, i) => ( + + {i > 0 && } + + {i < crumbs.length - 1 ? ( + {crumb} + ) : ( + {crumb} + )} + + + ))} + + + )} +
    + + + + +
    +
    + {children} +
    +
    +
    +
    ); } diff --git a/packages/swingset/src/components/PageProvider.tsx b/packages/swingset/src/components/PageProvider.tsx new file mode 100644 index 00000000000..2576b4411b1 --- /dev/null +++ b/packages/swingset/src/components/PageProvider.tsx @@ -0,0 +1,57 @@ +'use client'; + +import * as React from 'react'; + +export interface PagePreset { + id: string; + label: string; + vars: Record; +} + +export const pagePresets: PagePreset[] = [ + { id: 'default', label: 'Default', vars: {} }, + { + id: 'gray', + label: 'Gray', + vars: { + '--cl-color-page': 'light-dark(#f2f2f3, oklch(0.2 0 0))', + }, + }, + { + id: 'warm-gray', + label: 'Warm gray', + vars: { + '--cl-color-page': 'light-dark(oklch(0.96 0.004 75), oklch(0.19 0.005 70))', + }, + }, + { + id: 'cool-gray', + label: 'Cool gray', + vars: { + '--cl-color-page': 'light-dark(oklch(0.96 0.004 250), oklch(0.19 0.008 255))', + }, + }, +]; + +interface PageContextValue { + page: string; + setPage: (page: string) => void; +} + +const PageContext = React.createContext({ page: 'default', setPage: () => {} }); + +export function PageProvider({ children }: { children: React.ReactNode }) { + const [page, setPage] = React.useState('default'); + React.useEffect(() => { + const style = document.documentElement.style; + const vars = pagePresets.find(preset => preset.id === page)?.vars ?? {}; + Object.entries(vars).forEach(([name, value]) => style.setProperty(name, value)); + return () => Object.keys(vars).forEach(name => style.removeProperty(name)); + }, [page]); + const value = React.useMemo(() => ({ page, setPage }), [page]); + return {children}; +} + +export function usePage() { + return React.useContext(PageContext); +} diff --git a/packages/swingset/src/components/PageToggle.tsx b/packages/swingset/src/components/PageToggle.tsx new file mode 100644 index 00000000000..77419707bd1 --- /dev/null +++ b/packages/swingset/src/components/PageToggle.tsx @@ -0,0 +1,42 @@ +'use client'; + +import { Label } from '@/components/ui/label'; +import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select'; + +import { pagePresets, usePage } from './PageProvider'; + +export function PageToggle() { + const { page, setPage } = usePage(); + + return ( +
    + + +
    + ); +} diff --git a/packages/swingset/src/stories/icon-frame.mdx b/packages/swingset/src/stories/icon-frame.mdx index 2ca1da09e7e..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-neutral-alpha-100`. +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