From 6c6a790edd7df3be7dc185b4417f79d581e7d522 Mon Sep 17 00:00:00 2001 From: Alex Carpenter Date: Thu, 10 Sep 2026 15:15:08 -0400 Subject: [PATCH 1/2] feat(ui): derive gray tokens from neutral and background --- .claude/skills/mosaic/references/stylex.md | 6 +- packages/swingset/src/app/globals.css | 6 ++ .../swingset/src/components/ClientRoot.tsx | 4 +- .../swingset/src/components/NeutralToggle.tsx | 32 ++++++++++ packages/ui/src/mosaic/tokens.stylex.ts | 62 ++++++++----------- references/mosaic-architecture.md | 2 +- 6 files changed, 70 insertions(+), 42 deletions(-) create mode 100644 packages/swingset/src/components/NeutralToggle.tsx diff --git a/.claude/skills/mosaic/references/stylex.md b/.claude/skills/mosaic/references/stylex.md index 908f4fe0ebf..e0a5d5013fa 100644 --- a/.claude/skills/mosaic/references/stylex.md +++ b/.claude/skills/mosaic/references/stylex.md @@ -78,9 +78,9 @@ export const colorVars = stylex.defineVars(colorDefaults); - **DO** put light + dark in one token value via `light-dark()`. Never ship a second `@media (prefers-color-scheme: dark)` copy of a color. - **DO** reserve `--cl-*`-prefixed keys for the public, overridable contract. -- **DO** point a gray-backed colour token at the internal `gray` scale - (`light-dark(${gray['900']}, ${gray['50']})`) rather than restating the oklch value. - Its keys are unprefixed on purpose, so the names hash and stay un-overridable. +- **DO** derive a gray-backed colour token from `--cl-color-neutral` mixed into + `--cl-color-background` (`neutralMix(80, 98)` in `tokens.stylex.ts`) rather than + restating an oklch value. There is no gray scale: retinting neutral retints the ramp. - **DO** name internal, non-contract vars with a `--_cl-*` prefix (e.g. a value a parent writes for a child to read). They still emit verbatim but the `_` marks them "not a contract, don't override." diff --git a/packages/swingset/src/app/globals.css b/packages/swingset/src/app/globals.css index 46f118a65a2..e94abd5feae 100644 --- a/packages/swingset/src/app/globals.css +++ b/packages/swingset/src/app/globals.css @@ -9,6 +9,12 @@ (token :root defaults + atoms). See postcss.config.mjs. */ @stylex; +/* "Warm neutral" header toggle: overrides Mosaic's neutral root so the derived gray ramp + (foreground, border, subtle surfaces, alpha washes, shadow) can be previewed warm. */ +html.warm-neutral { + --cl-color-neutral: light-dark(oklch(0.2 0.06 50), oklch(0.95 0.06 85)); +} + body { font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif; } diff --git a/packages/swingset/src/components/ClientRoot.tsx b/packages/swingset/src/components/ClientRoot.tsx index 08a85129e6b..38dc799599b 100644 --- a/packages/swingset/src/components/ClientRoot.tsx +++ b/packages/swingset/src/components/ClientRoot.tsx @@ -16,6 +16,7 @@ import { SidebarInset, SidebarProvider, SidebarTrigger } from '@/components/ui/s import { getModule } from '@/lib/registry'; import { AppSidebar } from './app-sidebar'; +import { NeutralToggle } from './NeutralToggle'; import { ThemeToggle } from './ThemeToggle'; function useBreadcrumb() { @@ -65,7 +66,8 @@ export function ClientRoot({ children }: { children: React.ReactNode }) { )} -
+
+
diff --git a/packages/swingset/src/components/NeutralToggle.tsx b/packages/swingset/src/components/NeutralToggle.tsx new file mode 100644 index 00000000000..c34e6c35260 --- /dev/null +++ b/packages/swingset/src/components/NeutralToggle.tsx @@ -0,0 +1,32 @@ +'use client'; + +import * as React from 'react'; + +import { Label } from '@/components/ui/label'; +import { Switch } from '@/components/ui/switch'; + +// Retints Mosaic's `--cl-color-neutral` / `--cl-color-background` roots (see `globals.css`) +// to preview how the derived gray ramp follows a warm neutral. +export function NeutralToggle() { + const [warm, setWarm] = React.useState(false); + + React.useEffect(() => { + document.documentElement.classList.toggle('warm-neutral', warm); + }, [warm]); + + return ( +
+ + +
+ ); +} diff --git a/packages/ui/src/mosaic/tokens.stylex.ts b/packages/ui/src/mosaic/tokens.stylex.ts index 46a20a73d6b..6ede13c1d30 100644 --- a/packages/ui/src/mosaic/tokens.stylex.ts +++ b/packages/ui/src/mosaic/tokens.stylex.ts @@ -19,45 +19,32 @@ import * as stylex from '@stylexjs/stylex'; // `@media (prefers-color-scheme)` duplication. The `--cl-` prefix namespaces // the vars so they never collide with a host app's own custom properties. -// ============================================================================= -// Gray Scale -// ============================================================================= - -export const gray = stylex.defineVars({ - '0': 'oklch(1 0 0)', - '50': 'oklch(0.9851 0 0)', - '100': 'oklch(0.9702 0 0)', - '200': 'oklch(0.9461 0 0)', - '300': 'oklch(0.8699 0 0)', - '400': 'oklch(0.7155 0 0)', - '500': 'oklch(0.5555 0 0)', - '600': 'oklch(0.4386 0 0)', - '700': 'oklch(0.3715 0 0)', - '800': 'oklch(0.2686 0 0)', - '850': 'oklch(0.2393 0 0)', - '900': 'oklch(0.2046 0 0)', - '1000': 'oklch(0 0 0)', -}); - // ============================================================================= // 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. +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)))`; + const colorDefaults = { - '--cl-color-neutral': `light-dark(${gray['1000']}, ${gray['0']})`, + '--cl-color-neutral': 'light-dark(oklch(0 0 0), oklch(1 0 0))', '--cl-color-neutral-alpha-100': 'color-mix(in oklab, var(--cl-color-neutral) 6%, transparent)', '--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-foreground': `light-dark(${gray['900']}, ${gray['50']})`, - '--cl-color-foreground-secondary': `light-dark(${gray['600']}, ${gray['400']})`, - '--cl-color-foreground-disabled': `light-dark(${gray['400']}, ${gray['500']})`, + '--cl-color-background': 'light-dark(oklch(1 0 0), oklch(0.2046 0 0))', + '--cl-color-background-subtle': neutralMix(3, 4), - '--cl-color-background': `light-dark(${gray['0']}, ${gray['900']})`, - '--cl-color-background-subtle': `light-dark(${gray['100']}, ${gray['850']})`, + '--cl-color-foreground': neutralMix(80, 98), + '--cl-color-foreground-secondary': neutralMix(56, 64), + '--cl-color-foreground-disabled': neutralMix(28, 44), - '--cl-color-border': `light-dark(${gray['200']}, ${gray['800']})`, - '--cl-color-border-subtle': `light-dark(${gray['100']}, ${gray['850']})`, + '--cl-color-border': neutralMix(5, 8), + '--cl-color-border-subtle': neutralMix(3, 4), '--cl-color-brand': 'light-dark(oklch(0.2046 0 0), oklch(0.9851 0 0))', '--cl-color-brand-secondary': 'light-dark(oklch(0.5555 0 0), oklch(0.7155 0 0))', @@ -65,7 +52,7 @@ const colorDefaults = { '--cl-color-brand-foreground': 'light-dark(oklch(1 0 0), oklch(0.2046 0 0))', '--cl-color-negative': 'light-dark(oklch(0.5903 0.213 26.78), oklch(0.7106 0.1661 22.22))', - '--cl-color-negative-foreground': gray['0'], + '--cl-color-negative-foreground': 'oklch(1 0 0)', '--cl-color-negative-subtle': 'light-dark(oklch(0.9757 0.0118 17.36), oklch(0.255 0.0604 22.31))', '--cl-color-negative-border': 'light-dark(oklch(0.8155 0.0983 19.41), oklch(0.3958 0.1331 25.72))', '--cl-color-negative-alpha-100': 'color-mix(in oklab, var(--cl-color-negative) 6%, transparent)', @@ -73,7 +60,7 @@ const colorDefaults = { '--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': gray['0'], + '--cl-color-positive-foreground': 'oklch(1 0 0)', '--cl-color-positive-subtle': 'light-dark(oklch(0.9859 0.0164 156.92), oklch(0.3297 0.052 152.31))', '--cl-color-positive-border': 'light-dark(oklch(0.7922 0.1959 148.18), oklch(0.4479 0.1083 151.33))', '--cl-color-positive-alpha-100': 'color-mix(in oklab, var(--cl-color-positive) 6%, transparent)', @@ -81,17 +68,18 @@ const colorDefaults = { '--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': gray['0'], + '--cl-color-warning-foreground': 'oklch(1 0 0)', '--cl-color-warning-subtle': 'light-dark(oklch(0.9799 0.0147 70.89), oklch(0.2725 0.0547 55.7))', '--cl-color-warning-border': 'light-dark(oklch(0.8672 0.0902 63.47), oklch(0.4084 0.1165 38.17))', '--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': `light-dark(${gray['0']}, ${gray['850']})`, - '--cl-color-input-placeholder': gray['400'], + '--cl-color-input': + 'light-dark(var(--cl-color-background), color-mix(in oklab, var(--cl-color-neutral) 4%, var(--cl-color-background)))', + '--cl-color-input-placeholder': neutralMix(28, 64), - '--cl-color-ring': 'light-dark(oklch(0.205 0 0), oklch(0.922 0 0))', + '--cl-color-ring': neutralMix(80, 90), } as const; export const colorVars = stylex.defineVars(colorDefaults); @@ -435,9 +423,9 @@ export const focusVars = stylex.defineVars(focusDefaults); // dark. Branched per colour via `light-dark()` since a shadow's geometry cannot branch — see the // note on `Dialog`'s popup for why `@media (prefers-color-scheme)` is not the escape hatch. const shadowDefaults = { - '--cl-shadow-card': `0 12px 12px -7px light-dark(color-mix(in oklab, ${gray['900']} 12%, transparent), transparent), - 0 24px 24px -10px light-dark(color-mix(in oklab, ${gray['900']} 4%, transparent), transparent), - 0 0 0 1px light-dark(color-mix(in oklab, ${gray['900']} 4%, transparent), color-mix(in oklab, ${gray['0']} 10%, transparent))`, + '--cl-shadow-card': `0 12px 12px -7px light-dark(color-mix(in oklab, ${colorVars['--cl-color-foreground']} 12%, transparent), transparent), + 0 24px 24px -10px light-dark(color-mix(in oklab, ${colorVars['--cl-color-foreground']} 4%, transparent), transparent), + 0 0 0 1px light-dark(color-mix(in oklab, ${colorVars['--cl-color-foreground']} 4%, transparent), color-mix(in oklab, ${colorVars['--cl-color-neutral']} 10%, transparent))`, }; export const shadowVars = stylex.defineVars(shadowDefaults); diff --git a/references/mosaic-architecture.md b/references/mosaic-architecture.md index 604c9f12eca..21b655e74d1 100644 --- a/references/mosaic-architecture.md +++ b/references/mosaic-architecture.md @@ -25,7 +25,7 @@ export const colorVars = stylex.defineVars({ Groups: `colorVars`, `radiusVars`, `targetVars`, `scrollbarVars`, `scrollFadeVars`, `spacingVars`, `space`, `typeScaleVars`, `fontFamilyVars`, `fontWeightVars`, `durationVars`, `easingVars`, `focusVars`. -`gray` is the one group that is not public: its keys carry no `--cl-` prefix, so StyleX hashes the names and there is no `--cl-gray-*` to override. The gray-backed `--cl-color-*` tokens read from it; consumers retheme those, not the scale. 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-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. 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. From 60c78e40354b152052a941fc42f286b9cd96b955 Mon Sep 17 00:00:00 2001 From: Alex Carpenter Date: Thu, 10 Sep 2026 16:36:15 -0400 Subject: [PATCH 2/2] fix foreground-secondary --- packages/ui/src/mosaic/tokens.stylex.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/ui/src/mosaic/tokens.stylex.ts b/packages/ui/src/mosaic/tokens.stylex.ts index 6ede13c1d30..698ae5949d3 100644 --- a/packages/ui/src/mosaic/tokens.stylex.ts +++ b/packages/ui/src/mosaic/tokens.stylex.ts @@ -40,7 +40,7 @@ const colorDefaults = { '--cl-color-background-subtle': neutralMix(3, 4), '--cl-color-foreground': neutralMix(80, 98), - '--cl-color-foreground-secondary': neutralMix(56, 64), + '--cl-color-foreground-secondary': neutralMix(43, 64), '--cl-color-foreground-disabled': neutralMix(28, 44), '--cl-color-border': neutralMix(5, 8),