Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 3 additions & 3 deletions .claude/skills/mosaic/references/stylex.md
Original file line number Diff line number Diff line change
Expand Up @@ -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."
Expand Down
6 changes: 6 additions & 0 deletions packages/swingset/src/app/globals.css
Original file line number Diff line number Diff line change
Expand Up @@ -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;
}
Expand Down
4 changes: 3 additions & 1 deletion packages/swingset/src/components/ClientRoot.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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() {
Expand Down Expand Up @@ -65,7 +66,8 @@ export function ClientRoot({ children }: { children: React.ReactNode }) {
</BreadcrumbList>
</Breadcrumb>
)}
<div className='ml-auto'>
<div className='ml-auto flex items-center gap-4'>
<NeutralToggle />
<ThemeToggle />
</div>
</header>
Expand Down
32 changes: 32 additions & 0 deletions packages/swingset/src/components/NeutralToggle.tsx
Original file line number Diff line number Diff line change
@@ -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 (
<div className='flex items-center gap-2'>
<Switch
id='neutral-toggle'
checked={warm}
onCheckedChange={setWarm}
/>
<Label
htmlFor='neutral-toggle'
className='text-muted-foreground cursor-pointer text-xs'
>
Warm neutral
</Label>
</div>
);
}
62 changes: 25 additions & 37 deletions packages/ui/src/mosaic/tokens.stylex.ts
Original file line number Diff line number Diff line change
Expand Up @@ -19,79 +19,67 @@ 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))',
'--cl-color-brand-border': 'light-dark(oklch(0.9219 0 0), oklch(0.3715 0 0))',
'--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)',
'--cl-color-negative-alpha-200': 'color-mix(in oklab, var(--cl-color-negative) 8%, transparent)',
'--cl-color-negative-alpha-300': 'color-mix(in oklab, var(--cl-color-negative) 12%, transparent)',

'--cl-color-positive': 'light-dark(oklch(0.6082 0.1799 145.47), oklch(0.7227 0.192 149.58))',
'--cl-color-positive-foreground': 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)',
'--cl-color-positive-alpha-200': 'color-mix(in oklab, var(--cl-color-positive) 8%, transparent)',
'--cl-color-positive-alpha-300': 'color-mix(in oklab, var(--cl-color-positive) 12%, transparent)',

'--cl-color-warning': 'light-dark(oklch(0.7099 0.1888 44.94), oklch(0.7576 0.159 55.93))',
'--cl-color-warning-foreground': 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);
Expand Down Expand Up @@ -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);
2 changes: 1 addition & 1 deletion references/mosaic-architecture.md
Original file line number Diff line number Diff line change
Expand Up @@ -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-<color>-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-<color>-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.

Expand Down
Loading