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
2 changes: 2 additions & 0 deletions .changeset/swingset-color-tokens-page.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,2 @@
---
---
2 changes: 2 additions & 0 deletions packages/swingset/src/components/ClientRoot.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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() {
Expand Down Expand Up @@ -89,6 +90,7 @@ export function ClientRoot({ children }: { children: React.ReactNode }) {
</Breadcrumb>
)}
<div className='ml-auto flex items-center gap-4'>
<PaletteToggle />
<ChaosToggle />
<DirectionToggle />
<ThemeToggle />
Expand Down
1 change: 1 addition & 0 deletions packages/swingset/src/components/DocsViewer.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -128,6 +128,7 @@ const docModules: Record<string, Record<string, React.ComponentType>> = {
},
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: {
Expand Down
53 changes: 53 additions & 0 deletions packages/swingset/src/components/PaletteToggle.tsx
Original file line number Diff line number Diff line change
@@ -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 (
<div className='flex items-center gap-2'>
<Label
htmlFor='palette-toggle'
className='text-muted-foreground text-xs'
>
Palette
</Label>
<Select
value={active}
onValueChange={value => value && value !== 'custom' && applyPreset(value)}
>
<SelectTrigger
id='palette-toggle'
size='sm'
>
<SelectValue>{label}</SelectValue>
</SelectTrigger>
<SelectContent>
{colorPresets.map(preset => (
<SelectItem
key={preset.id}
value={preset.id}
>
{preset.label}
</SelectItem>
))}
{active === 'custom' ? (
<SelectItem
value='custom'
disabled
>
Custom
</SelectItem>
) : null}
</SelectContent>
</Select>
</div>
);
}
153 changes: 153 additions & 0 deletions packages/swingset/src/lib/color-overrides.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,153 @@
export type ColorMode = 'light' | 'dark';

export type ColorSides = Partial<Record<ColorMode, string>>;

export interface ColorPreset {
id: string;
label: string;
vars: Record<string, ColorSides>;
}

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<string, ColorSides>();
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();
}
4 changes: 4 additions & 0 deletions packages/swingset/src/lib/registry.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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,
Expand Down Expand Up @@ -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,
Expand Down
14 changes: 14 additions & 0 deletions packages/swingset/src/stories/color-tokens.mdx
Original file line number Diff line number Diff line change
@@ -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.

<Story
name='Default'
storyModule={ColorTokenStories}
/>
Loading
Loading