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..698ae5949d3 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(43, 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.