diff --git a/.changeset/swingset-color-tokens-page.md b/.changeset/swingset-color-tokens-page.md new file mode 100644 index 00000000000..a845151cc84 --- /dev/null +++ b/.changeset/swingset-color-tokens-page.md @@ -0,0 +1,2 @@ +--- +--- diff --git a/packages/swingset/src/components/ClientRoot.tsx b/packages/swingset/src/components/ClientRoot.tsx index 6a3ba3618a2..2d2f1a4ccf9 100644 --- a/packages/swingset/src/components/ClientRoot.tsx +++ b/packages/swingset/src/components/ClientRoot.tsx @@ -21,6 +21,7 @@ import { ChaosToggle } from './ChaosToggle'; import { CommandPalette } from './CommandPalette'; import { DirectionProvider } from './DirectionProvider'; import { DirectionToggle } from './DirectionToggle'; +import { PaletteToggle } from './PaletteToggle'; import { ThemeToggle } from './ThemeToggle'; function useBreadcrumb() { @@ -89,6 +90,7 @@ export function ClientRoot({ children }: { children: React.ReactNode }) { )}
+ diff --git a/packages/swingset/src/components/DocsViewer.tsx b/packages/swingset/src/components/DocsViewer.tsx index 66e3234b6da..212f4d4d565 100644 --- a/packages/swingset/src/components/DocsViewer.tsx +++ b/packages/swingset/src/components/DocsViewer.tsx @@ -128,6 +128,7 @@ const docModules: Record> = { }, styles: { // Atomic styles — shipped as StyleX atoms rather than components. + 'color-tokens': dynamic(() => import('../stories/color-tokens.mdx')), 'scroll-area': dynamic(() => import('../stories/scroll-area.mdx')), }, hooks: { diff --git a/packages/swingset/src/components/PaletteToggle.tsx b/packages/swingset/src/components/PaletteToggle.tsx new file mode 100644 index 00000000000..67c3d1d5a06 --- /dev/null +++ b/packages/swingset/src/components/PaletteToggle.tsx @@ -0,0 +1,53 @@ +'use client'; + +import * as React from 'react'; + +import { Label } from '@/components/ui/label'; +import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select'; +import { applyPreset, colorPresets, getActivePreset, getColorVersion, subscribeColors } from '@/lib/color-overrides'; + +export function PaletteToggle() { + React.useSyncExternalStore(subscribeColors, getColorVersion, () => 0); + const active = getActivePreset(); + const label = active === 'custom' ? 'Custom' : colorPresets.find(preset => preset.id === active)?.label; + + return ( +
+ + +
+ ); +} diff --git a/packages/swingset/src/lib/color-overrides.ts b/packages/swingset/src/lib/color-overrides.ts new file mode 100644 index 00000000000..5ded95c70f9 --- /dev/null +++ b/packages/swingset/src/lib/color-overrides.ts @@ -0,0 +1,153 @@ +export type ColorMode = 'light' | 'dark'; + +export type ColorSides = Partial>; + +export interface ColorPreset { + id: string; + label: string; + vars: Record; +} + +export const colorPresets: ColorPreset[] = [ + { id: 'default', label: 'Default', vars: {} }, + { + id: 'gray', + label: 'Gray', + vars: { + '--cl-color-page': { light: '#f2f2f3', dark: 'oklch(0.2 0 0)' }, + '--cl-color-neutral': { light: 'oklch(0.05 0.002 280)', dark: 'oklch(0.98 0.002 280)' }, + }, + }, + { + id: 'warm-gray', + label: 'Warm gray', + vars: { + '--cl-color-page': { light: 'oklch(0.96 0.004 75)', dark: 'oklch(0.19 0.005 70)' }, + '--cl-color-neutral': { light: 'oklch(0.05 0.01 75)', dark: 'oklch(0.98 0.01 80)' }, + }, + }, + { + id: 'cool-gray', + label: 'Cool gray', + vars: { + '--cl-color-page': { light: 'oklch(0.96 0.004 250)', dark: 'oklch(0.19 0.008 255)' }, + '--cl-color-neutral': { light: 'oklch(0.05 0.01 255)', dark: 'oklch(0.98 0.01 250)' }, + }, + }, +]; + +const overrides = new Map(); +const listeners = new Set<() => void>(); +let version = 0; +let activePreset = 'default'; + +function emit() { + version += 1; + listeners.forEach(listener => listener()); +} + +function probe(name: string, mode: ColorMode): string { + const element = document.createElement('span'); + element.style.cssText = `position:absolute;visibility:hidden;pointer-events:none;color-scheme:${mode};background:var(${name})`; + document.documentElement.appendChild(element); + const resolved = getComputedStyle(element).backgroundColor; + element.remove(); + return resolved; +} + +function applyOverrides() { + const style = document.documentElement.style; + overrides.forEach((_, name) => style.removeProperty(name)); + for (let pass = 0; pass < 2; pass += 1) { + overrides.forEach((sides, name) => { + if (pass === 1) { + if (sides.light !== undefined && sides.dark !== undefined) { + return; + } + style.removeProperty(name); + } + const light = sides.light ?? probe(name, 'light'); + const dark = sides.dark ?? probe(name, 'dark'); + style.setProperty(name, `light-dark(${light}, ${dark})`); + }); + } +} + +export { probe }; + +export function subscribeColors(listener: () => void) { + listeners.add(listener); + return () => { + listeners.delete(listener); + }; +} + +export function getColorVersion() { + return version; +} + +export function getOverride(name: string): ColorSides | undefined { + return overrides.get(name); +} + +export function getOverrideCount() { + return overrides.size; +} + +export function getActivePreset() { + return activePreset; +} + +function setSide(name: string, mode: ColorMode, value: string) { + const sides: ColorSides = { ...overrides.get(name) }; + if (value) { + sides[mode] = value; + } else { + delete sides[mode]; + } + if (sides.light === undefined && sides.dark === undefined) { + overrides.delete(name); + } else { + overrides.set(name, sides); + } +} + +export function setOverride(name: string, mode: ColorMode, value: string) { + setSide(name, mode, value); + activePreset = overrides.size === 0 ? 'default' : 'custom'; + applyOverrides(); + emit(); +} + +export function resetOverride(name: string, mode?: ColorMode) { + if (mode) { + setOverride(name, mode, ''); + return; + } + overrides.delete(name); + activePreset = overrides.size === 0 ? 'default' : 'custom'; + applyOverrides(); + emit(); +} + +export function applyPreset(id: string) { + const preset = colorPresets.find(entry => entry.id === id); + if (!preset) { + return; + } + const style = document.documentElement.style; + overrides.forEach((_, name) => style.removeProperty(name)); + overrides.clear(); + Object.entries(preset.vars).forEach(([name, sides]) => overrides.set(name, { ...sides })); + applyOverrides(); + activePreset = id; + emit(); +} + +export function resetAll() { + const style = document.documentElement.style; + overrides.forEach((_, name) => style.removeProperty(name)); + overrides.clear(); + activePreset = 'default'; + emit(); +} diff --git a/packages/swingset/src/lib/registry.ts b/packages/swingset/src/lib/registry.ts index 088e9994198..5c6a581c777 100644 --- a/packages/swingset/src/lib/registry.ts +++ b/packages/swingset/src/lib/registry.ts @@ -44,6 +44,7 @@ import { States as CheckboxStates, } from '../stories/checkbox.stories'; import { meta as collapsibleMeta } from '../stories/collapsible.stories'; +import { Default as ColorTokensDefault, meta as colorTokensMeta } from '../stories/color-tokens.stories'; import { meta as comboboxPrimitiveMeta } from '../stories/combobox.primitive.stories'; import { Default as ComboboxDefault, @@ -776,6 +777,8 @@ const tagInputModule: StoryModule = { meta: tagInputMeta }; const toastPrimitiveModule: StoryModule = { meta: toastPrimitiveMeta }; const tooltipModule: StoryModule = { meta: tooltipMeta }; +const colorTokensModule: StoryModule = { meta: colorTokensMeta, Default: ColorTokensDefault }; + const scrollAreaModule: StoryModule = { meta: scrollAreaMeta, Default: ScrollAreaDefault, @@ -1115,6 +1118,7 @@ export const registry: StoryModule[] = [ toastPrimitiveModule, tooltipModule, // Styles — atomic styles that ship as StyleX atoms rather than components. + colorTokensModule, scrollAreaModule, // Hooks useFormModule, diff --git a/packages/swingset/src/stories/color-tokens.mdx b/packages/swingset/src/stories/color-tokens.mdx new file mode 100644 index 00000000000..69f52329a42 --- /dev/null +++ b/packages/swingset/src/stories/color-tokens.mdx @@ -0,0 +1,14 @@ +import * as ColorTokenStories from './color-tokens.stories'; + +# Color Tokens + +**Presets** set the page and the neutral together, for both modes: Gray, Warm gray and Cool gray. They are also in the Palette menu in the header, so you can switch while you browse other pages. Editing any token afterwards switches the palette to Custom. + +The color tokens in two groups. **Set by you** are the seeds: `--cl-color-page`, `--cl-color-neutral`, `--cl-color-brand`, the three status colors and their `-foreground` pairs. Each token shows the mode selected by the dark mode toggle, and edits apply to that mode only; flip the toggle to set the other. Click a swatch to open the browser's color picker, which has a color wheel and takes hex codes, or type any CSS color (hex, `oklch()`, `rgb()`) into the cell. Either way that mode is overridden for the whole preview and the other mode keeps its default. Reset drops the current mode's override. The picker works in sRGB hex, so a color you pick is converted from `oklch` and rounded. **Generated** tokens are derived from the seeds, so they show their resolved color for the current mode but can't be edited here; change a seed and watch them follow. + +Overrides are applied to the document root, so every other page in the preview shows the change, and they survive navigation until you reload. + + diff --git a/packages/swingset/src/stories/color-tokens.stories.tsx b/packages/swingset/src/stories/color-tokens.stories.tsx new file mode 100644 index 00000000000..ecd8f15e666 --- /dev/null +++ b/packages/swingset/src/stories/color-tokens.stories.tsx @@ -0,0 +1,258 @@ +import { colorVars } from '@clerk/mosaic/tokens.stylex'; +import { useTheme } from 'next-themes'; +import * as React from 'react'; + +import { + applyPreset, + colorPresets, + getActivePreset, + getColorVersion, + getOverride, + getOverrideCount, + probe, + resetAll, + resetOverride, + setOverride, + subscribeColors, +} from '@/lib/color-overrides'; +import type { StoryMeta } from '@/lib/types'; + +export { default as __source } from './color-tokens.stories?raw'; + +export const meta: StoryMeta = { + group: 'Styles', + title: 'Color Tokens', + status: 'stable', + source: 'packages/mosaic/src/tokens.stylex.ts', +}; + +type Mode = 'light' | 'dark'; + +const seeds = [ + '--cl-color-page', + '--cl-color-neutral', + '--cl-color-brand', + '--cl-color-brand-foreground', + '--cl-color-negative', + '--cl-color-negative-foreground', + '--cl-color-positive', + '--cl-color-positive-foreground', + '--cl-color-warning', + '--cl-color-warning-foreground', +]; + +const groups: Array<{ title: string; match: (name: string) => boolean }> = [ + { + title: 'Layers and borders', + match: n => + [ + '--cl-color-surface', + '--cl-color-surface-subtle', + '--cl-color-neutral-subtle', + '--cl-color-border', + '--cl-color-border-subtle', + ].includes(n), + }, + { title: 'Text', match: n => n.includes('-foreground') }, + { title: 'Input', match: n => ['--cl-color-input', '--cl-color-input-placeholder', '--cl-color-ring'].includes(n) }, + { title: 'Brand', match: n => n.startsWith('--cl-color-brand') }, + { title: 'Negative', match: n => n.startsWith('--cl-color-negative') }, + { title: 'Positive', match: n => n.startsWith('--cl-color-positive') }, + { title: 'Warning', match: n => n.startsWith('--cl-color-warning') }, + { title: 'Neutral washes', match: n => n.startsWith('--cl-color-neutral-alpha') }, +]; + +function toHex(css: string): string { + if (typeof document === 'undefined') { + return '#000000'; + } + const canvas = document.createElement('canvas'); + canvas.width = 1; + canvas.height = 1; + const context = canvas.getContext('2d', { willReadFrequently: true }); + if (!context || !css) { + return '#000000'; + } + context.clearRect(0, 0, 1, 1); + context.fillStyle = css; + context.fillRect(0, 0, 1, 1); + const [r, g, b] = context.getImageData(0, 0, 1, 1).data; + return `#${[r, g, b].map(channel => channel.toString(16).padStart(2, '0')).join('')}`; +} + +function useResolved(name: string, token: number) { + const [resolved, setResolved] = React.useState>({ light: '', dark: '' }); + + React.useEffect(() => { + setResolved({ light: probe(name, 'light'), dark: probe(name, 'dark') }); + }, [name, token]); + + return resolved; +} + +function ModeCell({ + name, + mode, + resolved, + value, + editable, +}: { + name: string; + mode: Mode; + resolved: string; + value: string | undefined; + editable: boolean; +}) { + return ( +
+ + + {editable ? ( + setOverride(name, mode, event.target.value)} + /> + ) : null} + + {editable ? ( + <> + setOverride(name, mode, event.target.value)} + /> + + + ) : ( + {resolved} + )} +
+ ); +} + +function TokenRow({ name, token, mode, editable }: { name: string; token: number; mode: Mode; editable: boolean }) { + const resolved = useResolved(name, token); + const sides = getOverride(name); + + return ( +
+
{name}
+ +
+ ); +} + +export function Default() { + const { resolvedTheme } = useTheme(); + const mode: Mode = resolvedTheme === 'dark' ? 'dark' : 'light'; + const count = React.useSyncExternalStore(subscribeColors, getColorVersion, () => 0); + const activePreset = getActivePreset(); + const names = Object.keys(colorVars); + const placed = new Set(); + + return ( +
+
+

+ Edits apply to the mode shown by the dark mode toggle; the other mode keeps its default. Overrides apply to + the whole preview and stay while you move between pages. {getOverrideCount()} active. +

+ +
+
+

Presets

+

+ A preset sets the page and the neutral together. Editing a token afterwards switches to Custom. +

+
+ {colorPresets.map(preset => ( + + ))} + {activePreset === 'custom' ? ( + Custom + ) : null} +
+
+
+

Set by you

+

The seeds. Everything below is generated from them.

+ {seeds + .filter(name => names.includes(name)) + .map(name => ( + + ))} +
+

Generated

+

Derived from the seeds, so not editable here.

+ {groups.map(group => { + const members = names.filter(name => !seeds.includes(name) && !placed.has(name) && group.match(name)); + members.forEach(name => placed.add(name)); + if (members.length === 0) { + return null; + } + return ( +
+

{group.title}

+ {members.map(name => ( + + ))} +
+ ); + })} +
+ ); +}