Theme
Unified theme provider and panel for Playstack. Two orthogonal attributes on <html>: data-theme="<scheme-id>" + data-mode="light" | "dark".
Dependencies
Source Code
'use client';
import { useCallback, useEffect, useState } from 'react';
import { createContext } from '@/lib/create-context';
import { THEME_STORAGE_KEY } from '@/lib/theme';
import { SCHEMES, type Scheme } from './schemes';
import { DEFAULT_CONFIG_SCHEME_ID, readStored, writeStored } from './storage';
type ThemeMode = 'light' | 'dark';
interface ThemeContextValue {
scheme: string;
mode: ThemeMode;
setScheme: (id: string) => void;
setMode: (mode: ThemeMode) => void;
schemes: readonly Scheme[];
}
const [ThemeContext, useTheme] = createContext<ThemeContextValue>('Theme');
interface ThemeProps {
scheme?: string;
mode?: ThemeMode;
children: React.ReactNode;
}
// SSR fallback uses DEFAULT_CONFIG_SCHEME_ID (= 'obsidian'), NOT 'slate'.
// The inline script in shell.astro writes data-theme="obsidian" for
// first-time visitors pre-paint, so initial-client and server-render
// states must agree to avoid hydration mismatches.
const readSchemeFromDom = (fallback: string): string => {
if (typeof document === 'undefined') return fallback;
return document.documentElement.getAttribute('data-theme') ?? fallback;
};
const readModeFromDom = (fallback: ThemeMode): ThemeMode => {
if (typeof document === 'undefined') return fallback;
const attr = document.documentElement.getAttribute('data-mode');
return attr === 'dark' ? 'dark' : 'light';
};
const Theme = ({
scheme: schemeProp,
mode: modeProp,
children,
}: ThemeProps) => {
const [schemeState, setSchemeState] = useState<string>(
() => schemeProp ?? readSchemeFromDom(DEFAULT_CONFIG_SCHEME_ID)
);
const [modeState, setModeState] = useState<ThemeMode>(
() => modeProp ?? readModeFromDom('light')
);
const scheme = schemeProp ?? schemeState;
const mode = modeProp ?? modeState;
const setScheme = useCallback(
(id: string) => {
// When prop-controlled, the parent owns the scheme value. Writing to the
// DOM or localStorage here would desync: the visible attribute would
// flip, but `useTheme().scheme` (resolved from `schemeProp`) would not.
// Warn and no-op — consumers should lift state up or drop the prop.
if (schemeProp !== undefined) {
console.warn(
'[<Theme>] setScheme called while scheme prop is controlled; ' +
'lift state up or remove the prop to let the user change schemes.'
);
return;
}
document.documentElement.setAttribute('data-theme', id);
const stored = readStored();
writeStored({ ...stored, schemeId: id });
setSchemeState(id);
},
[schemeProp]
);
const setMode = useCallback(
(next: ThemeMode) => {
// Same guard as setScheme — prop-controlled mode is owned by the parent.
if (modeProp !== undefined) {
console.warn(
'[<Theme>] setMode called while mode prop is controlled; ' +
'lift state up or remove the prop to let the user change mode.'
);
return;
}
document.documentElement.setAttribute('data-mode', next);
// Persist to THEME_STORAGE_KEY so the no-flicker inline script in
// shell.astro can restore the user's choice on reload.
if (typeof window !== 'undefined') {
window.localStorage.setItem(THEME_STORAGE_KEY, next);
}
setModeState(next);
},
[modeProp]
);
// Sync state on mount if attrs were set after initial render
// biome-ignore lint/correctness/useExhaustiveDependencies: intentional one-time sync
useEffect(() => {
if (!schemeProp) {
const fromDom = readSchemeFromDom(schemeState);
if (fromDom !== schemeState) setSchemeState(fromDom);
}
if (!modeProp) {
const fromDom = readModeFromDom(modeState);
if (fromDom !== modeState) setModeState(fromDom);
}
}, []);
// Sync the DOM attribute when a prop-controlled `scheme` changes so the
// visible CSS tokens always match the resolved context value.
useEffect(() => {
if (schemeProp !== undefined) {
document.documentElement.setAttribute('data-theme', schemeProp);
}
}, [schemeProp]);
// Same for `mode`. Light mode is encoded as the absence of the attr,
// matching the pattern in shell.astro's `applyMode`.
useEffect(() => {
if (modeProp !== undefined) {
if (modeProp === 'dark') {
document.documentElement.setAttribute('data-mode', 'dark');
} else {
document.documentElement.removeAttribute('data-mode');
}
}
}, [modeProp]);
const value: ThemeContextValue = {
scheme,
mode,
setScheme,
setMode,
schemes: SCHEMES,
};
return <ThemeContext value={value}>{children}</ThemeContext>;
};
export type { ThemeContextValue, ThemeMode };
export { Theme, useTheme }; import {
ArrowLeft,
Pencil01,
RefreshCcw01,
Sliders01,
} from '@untitledui-pro/icons/solid';
import { MotionConfig } from 'motion/react';
import * as motion from 'motion/react-client';
import { useCallback, useEffect, useMemo, useState } from 'react';
import { useTailwindBreakpoint } from '@/foundations/hooks/use-tailwind-breakpoint';
import { CopyButton } from '@/foundations/icons/copy-button';
import { Button, IconButton } from '@/components/button';
import { Drawer } from '@/components/drawer';
import { Popover } from '@/components/popover';
import { Scrubber } from '@/components/scrubber';
import { Tooltip } from '@/components/tooltip';
import { cn } from '@/lib/utils/classnames';
import {
DEFAULT_PAIRING,
findPairing,
PAIRINGS,
type Pairing,
} from './pairings';
import { SchemeEditor } from './scheme-editor';
import {
COLOR_TOKENS,
type ColorToken,
DEFAULT_SCHEME,
DEFAULT_SCHEME_ID,
SCHEMES,
type Scheme,
type TokenValues,
} from './schemes';
import {
EMPTY_FONTS,
FONT_SLOTS,
FONT_VARS,
type FontSlot,
RADIUS_DEFAULT,
RADIUS_STEP_REM,
RING_DEFAULT,
readStored,
type StoredFont,
type StoredFonts,
SURFACE_STYLE_DEFAULT,
type SurfaceStyle,
writeStored,
} from './storage';
import { TypographyEditor } from './typography-editor';
import { fallbackFor, loadGoogleFont } from './use-fonts-catalog';
const RADIUS_MAX = 4;
const RING_MIN = 2;
const RING_MAX = 6;
type View = 'presets' | 'edit-colors' | 'edit-fonts';
const setRoot = (prop: string, value: string) =>
document.documentElement.style.setProperty(prop, value);
const removeRoot = (prop: string) =>
document.documentElement.style.removeProperty(prop);
const tokenVar = (token: ColorToken) => `--color-${token}`;
const lightDark = ({ light, dark }: TokenValues) =>
`light-dark(${light}, ${dark})`;
const valuesEqual = (a: TokenValues, b: TokenValues) =>
a.light === b.light && a.dark === b.dark;
const resolveToken = (
scheme: Scheme,
overrides: Partial<Record<ColorToken, TokenValues>>,
token: ColorToken
): TokenValues => overrides[token] ?? scheme.colors[token];
const fontFamilyValue = (font: StoredFont) =>
`'${font.family}', ${fallbackFor(font.category)}`;
// Wrap a state change in the View Transitions API when available so the
// browser cross-fades the old → new pixels. Lets us animate non-animatable
// CSS properties like `font-family` and bulk token swaps (scheme changes)
// without snapping. Falls back to a plain call on unsupported browsers.
const startViewTransition = (fn: () => void) => {
if (typeof document === 'undefined') {
fn();
return;
}
// biome-ignore lint/suspicious/noExplicitAny: View Transitions API not yet in lib.dom
const vt = (document as any).startViewTransition;
if (typeof vt === 'function') {
vt.call(document, fn);
} else {
fn();
}
};
// Trigger Google Font loading and wait for the browser to register the
// face. Without this the view-transition snapshot can capture a fallback
// font and the cross-fade looks janky.
const ensureFontsLoaded = async (
fonts: Array<StoredFont | null | undefined>
) => {
const promises: Promise<unknown>[] = [];
for (const font of fonts) {
if (!font) continue;
loadGoogleFont(font.family);
if (typeof document !== 'undefined' && document.fonts) {
promises.push(document.fonts.load(`1em "${font.family}"`));
}
}
if (promises.length > 0) await Promise.all(promises);
};
const ThemePanel = () => {
const stored = readStored();
const initialScheme =
SCHEMES.find((s) => s.id === stored.schemeId) ?? DEFAULT_SCHEME;
const [schemeId, setSchemeId] = useState(initialScheme.id);
const [overrides, setOverrides] = useState<
Partial<Record<ColorToken, TokenValues>>
>(stored.overrides);
const [radiusStep, setRadiusStep] = useState(stored.radiusStep);
const [ringWidth, setRingWidth] = useState(stored.ringWidth);
const [fonts, setFonts] = useState<StoredFonts>(stored.fonts);
const [surfaceStyle, setSurfaceStyle] = useState<SurfaceStyle>(
stored.surfaceStyle
);
const [view, setView] = useState<View>('presets');
const scheme = useMemo(
() => SCHEMES.find((s) => s.id === schemeId) ?? DEFAULT_SCHEME,
[schemeId]
);
const activePairing = useMemo(() => findPairing(fonts), [fonts]);
const apply = useCallback(() => {
for (const token of COLOR_TOKENS) {
const onDefaultScheme = scheme.id === DEFAULT_SCHEME_ID;
const overridden = overrides[token];
if (onDefaultScheme && !overridden) {
removeRoot(tokenVar(token));
} else {
const values = resolveToken(scheme, overrides, token);
setRoot(tokenVar(token), lightDark(values));
}
}
if (radiusStep !== RADIUS_DEFAULT) {
setRoot('--radius', `${radiusStep * RADIUS_STEP_REM}rem`);
} else {
removeRoot('--radius');
}
if (ringWidth !== RING_DEFAULT) {
setRoot('--ring-width', `${ringWidth}px`);
} else {
removeRoot('--ring-width');
}
for (const slot of FONT_SLOTS) {
const font = fonts[slot];
if (font) {
setRoot(FONT_VARS[slot], fontFamilyValue(font));
} else {
removeRoot(FONT_VARS[slot]);
}
}
if (surfaceStyle === SURFACE_STYLE_DEFAULT) {
delete document.documentElement.dataset.surfaceStyle;
} else {
document.documentElement.dataset.surfaceStyle = surfaceStyle;
}
if (scheme.darkFirst) {
document.documentElement.style.setProperty('color-scheme', 'dark');
} else {
document.documentElement.style.removeProperty('color-scheme');
}
}, [scheme, overrides, radiusStep, ringWidth, fonts, surfaceStyle]);
useEffect(() => {
const ensureFontsLoaded = () => {
for (const slot of FONT_SLOTS) {
const font = fonts[slot];
if (font) loadGoogleFont(font.family);
}
};
ensureFontsLoaded();
document.addEventListener('astro:after-swap', ensureFontsLoaded);
return () =>
document.removeEventListener('astro:after-swap', ensureFontsLoaded);
}, [fonts]);
useEffect(() => {
apply();
document.addEventListener('astro:after-swap', apply);
return () => document.removeEventListener('astro:after-swap', apply);
}, [apply]);
useEffect(() => {
writeStored({
schemeId,
overrides,
radiusStep,
ringWidth,
fonts,
surfaceStyle,
});
}, [schemeId, overrides, radiusStep, ringWidth, fonts, surfaceStyle]);
const handleTokenChange = (
token: ColorToken,
side: 'light' | 'dark',
value: string
) => {
setOverrides((prev) => {
const current = prev[token] ?? scheme.colors[token];
const updated: TokenValues = { ...current, [side]: value };
// If the override now matches the scheme's value for this token, drop it.
if (valuesEqual(updated, scheme.colors[token])) {
const { [token]: _, ...rest } = prev;
return rest;
}
return { ...prev, [token]: updated };
});
};
const handleFontChange = async (slot: FontSlot, next: StoredFont | null) => {
await ensureFontsLoaded([next]);
startViewTransition(() => setFonts((prev) => ({ ...prev, [slot]: next })));
};
const handlePairingSelect = async (pairing: Pairing) => {
await ensureFontsLoaded(FONT_SLOTS.map((slot) => pairing.fonts[slot]));
startViewTransition(() => setFonts(pairing.fonts));
};
const handleResetOverrides = () => setOverrides({});
const handleResetFonts = () => setFonts(EMPTY_FONTS);
const handleReset = () => {
setSchemeId(DEFAULT_SCHEME_ID);
setOverrides({});
setRadiusStep(RADIUS_DEFAULT);
setRingWidth(RING_DEFAULT);
setFonts(EMPTY_FONTS);
setSurfaceStyle(SURFACE_STYLE_DEFAULT);
};
const generateCSSOverrides = () => {
const tokenLines: string[] = [];
for (const token of COLOR_TOKENS) {
const current = resolveToken(scheme, overrides, token);
if (valuesEqual(current, DEFAULT_SCHEME.colors[token])) continue;
tokenLines.push(` ${tokenVar(token)}: ${lightDark(current)};`);
}
if (radiusStep !== RADIUS_DEFAULT) {
tokenLines.push(` --radius: ${radiusStep * RADIUS_STEP_REM}rem;`);
}
if (ringWidth !== RING_DEFAULT) {
tokenLines.push(` --ring-width: ${ringWidth}px;`);
}
for (const slot of FONT_SLOTS) {
const font = fonts[slot];
if (!font) continue;
tokenLines.push(` ${FONT_VARS[slot]}: ${fontFamilyValue(font)};`);
}
if (tokenLines.length === 0) return '/* No customizations */';
return `:root {\n${tokenLines.join('\n')}\n}`;
};
const headerLabel: Record<View, string> = {
presets: 'Design system',
'edit-colors': 'Edit colors',
'edit-fonts': 'Edit fonts',
};
const headerReset: Record<View, () => void> = {
presets: handleReset,
'edit-colors': handleResetOverrides,
'edit-fonts': handleResetFonts,
};
const headerResetLabel: Record<View, string> = {
presets: 'Reset to defaults',
'edit-colors': 'Reset overrides',
'edit-fonts': 'Reset fonts',
};
const isEditing = view !== 'presets';
const isDesktop = useTailwindBreakpoint('md');
// The horizontal-scrolling utility used on mobile for the Scheme + Typography
// rows. Hides the scrollbar so the row reads as a swiper.
const scrollRowClass =
'flex gap-1.5 overflow-x-auto [scrollbar-width:none] [&::-webkit-scrollbar]:hidden snap-x';
const trigger = (
<IconButton
variant="ghost"
size="sm"
aria-label="Design system configuration"
>
<Sliders01 />
</IconButton>
);
const headerBar = (
<div className="grid grid-cols-[0fr_auto_1fr] items-center gap-2 border-border border-b px-4 py-3">
<div className="flex justify-start">
{isEditing && (
<IconButton
variant="ghost"
size="xs"
aria-label="Back to presets"
onClick={() => setView('presets')}
>
<ArrowLeft />
</IconButton>
)}
</div>
<span className="text-start font-medium">{headerLabel[view]}</span>
<div className="flex justify-end gap-1">
<IconButton
variant="ghost"
size="xs"
aria-label={headerResetLabel[view]}
onClick={headerReset[view]}
>
<RefreshCcw01 />
</IconButton>
<CopyButton size="xs" value={generateCSSOverrides()} />
</div>
</div>
);
const renderBody = (compact: boolean) => (
<>
{view === 'edit-colors' && (
<SchemeEditor
scheme={scheme}
overrides={overrides}
onTokenChange={handleTokenChange}
/>
)}
{view === 'edit-fonts' && (
<TypographyEditor fonts={fonts} onFontChange={handleFontChange} />
)}
{view === 'presets' && (
<div className="flex flex-col divide-y divide-border">
<div className="flex flex-col gap-3 p-4">
<div className="flex items-center justify-between">
<span className="font-medium text-sm">Scheme</span>
<IconButton
variant="ghost"
size="xs"
aria-label="Edit colors"
onClick={() => setView('edit-colors')}
>
<Pencil01 />
</IconButton>
</div>
<Tooltip.Group>
<MotionConfig
transition={{ type: 'spring', bounce: 0, duration: 0.35 }}
>
<div
className={
compact
? scrollRowClass
: 'flex flex-wrap gap-2 my-0 px-1 py-3'
}
>
{SCHEMES.map((s) => {
const isActive = scheme.id === s.id;
return (
<Tooltip key={s.id} delayIn={400}>
<Tooltip.Trigger asChild>
<motion.button
type="button"
aria-label={s.label}
aria-pressed={isActive}
onClick={() =>
startViewTransition(() => setSchemeId(s.id))
}
className="focus-visible:ring-(length:--ring-width) relative size-8 shrink-0 cursor-pointer rounded-xl outline-none ring-ring ml-1"
style={{
backgroundColor: lightDark(s.colors.accent),
}}
animate={{ opacity: isActive ? 1 : 0.5 }}
whileHover={{ opacity: 0.8 }}
whileTap={{ scale: 0.88 }}
>
{isActive && (
<motion.span
layoutId="scheme-ring"
className="pointer-events-none absolute inset-0 rounded-xl ring-1 ring-accent ring-offset-2 ring-offset-background"
/>
)}
</motion.button>
</Tooltip.Trigger>
<Tooltip.Content>{s.label}</Tooltip.Content>
</Tooltip>
);
})}
</div>
</MotionConfig>
</Tooltip.Group>
</div>
<div className="flex flex-col gap-3 p-4">
<div className="flex items-center justify-between">
<span className="font-medium text-sm">Typography</span>
<IconButton
variant="ghost"
size="xs"
aria-label="Edit fonts"
onClick={() => setView('edit-fonts')}
>
<Pencil01 />
</IconButton>
</div>
<Tooltip.Group>
<div className="grid grid-cols-3 gap-2 py-1">
{PAIRINGS.map((p) => {
const isActive = activePairing?.id === p.id;
return (
<PairingTile
key={p.id}
pairing={p}
isActive={isActive}
onSelect={handlePairingSelect}
/>
);
})}
</div>
</Tooltip.Group>
</div>
<div className="flex flex-col gap-3 p-4">
<Scrubber
label="Radius"
size="sm"
min={0}
max={RADIUS_MAX}
step={1}
decimals={0}
value={radiusStep}
onValueChange={setRadiusStep}
/>
</div>
<div className="flex flex-col gap-3 p-4">
<Scrubber
label="Focus ring"
size="sm"
min={RING_MIN}
max={RING_MAX}
step={1}
decimals={0}
value={ringWidth}
onValueChange={setRingWidth}
/>
</div>
</div>
)}
</>
);
if (isDesktop) {
return (
<Popover placement="bottom-end">
<Popover.Trigger asChild>{trigger}</Popover.Trigger>
<Popover.Content className="w-90 p-0 pb-1">
{headerBar}
{renderBody(false)}
</Popover.Content>
</Popover>
);
}
return (
<Drawer>
<Drawer.Trigger asChild>{trigger}</Drawer.Trigger>
<Drawer.Content side="bottom" detached className="flex flex-col px-0">
{headerBar}
<div className="min-h-96 flex-1 overflow-y-auto">
{renderBody(true)}
</div>
<Drawer.Actions className="bg-background-secondary px-8">
<Button
variant="primary"
className="grow"
onClick={() => {
navigator.clipboard.writeText(generateCSSOverrides());
}}
>
Save CSS
</Button>
<Drawer.Close asChild>
<Button variant="outline" className="grow">
Close
</Button>
</Drawer.Close>
</Drawer.Actions>
</Drawer.Content>
</Drawer>
);
};
type PairingTileProps = {
pairing: Pairing;
isActive: boolean;
onSelect: (pairing: Pairing) => void;
};
const SLOT_LABELS: Record<FontSlot, string> = {
heading: 'Heading',
body: 'Body',
ui: 'UI',
mono: 'Mono',
};
const PairingTile = ({ pairing, isActive, onSelect }: PairingTileProps) => {
const headingFont = pairing.fonts.heading ?? pairing.fonts.body;
const uiFont = pairing.fonts.ui ?? pairing.fonts.body;
// Lazy-load preview fonts only when the popover is open.
useEffect(() => {
if (headingFont) loadGoogleFont(headingFont.family);
if (uiFont) loadGoogleFont(uiFont.family);
}, [headingFont, uiFont]);
const headingFamily = headingFont
? fontFamilyValue(headingFont)
: 'var(--font-heading)';
const uiFamily = uiFont ? fontFamilyValue(uiFont) : 'var(--font-ui)';
const setSlots = FONT_SLOTS.filter((slot) => pairing.fonts[slot] !== null);
const isSystem = pairing.id === DEFAULT_PAIRING.id;
return (
<Tooltip delayIn={1000}>
<Tooltip.Trigger asChild>
<button
type="button"
aria-label={pairing.label}
aria-pressed={isActive}
onClick={() => onSelect(pairing)}
data-active={isActive || undefined}
className={cn(
'focus-visible:ring-(length:--ring-width) flex h-20 cursor-pointer flex-col items-center justify-center gap-1.5 rounded-md border border-border bg-background px-10 outline-none ring-ring transition-colors duration-(--duration-hover) ease-out hover:bg-surface-overlay data-active:border-accent data-active:bg-surface-overlay data-active:ring-2 data-active:ring-accent'
)}
>
<span
aria-hidden="true"
className="font-semibold text-2xl leading-none"
style={{ fontFamily: headingFamily }}
>
Aa
</span>
<span
className="text-2xs text-foreground-secondary leading-tight"
style={{ fontFamily: uiFamily }}
>
{pairing.label}
</span>
</button>
</Tooltip.Trigger>
<Tooltip.Content>
{isSystem ? (
<span>Inherits your defaults</span>
) : (
<div>
{setSlots.map((slot) => {
const f = pairing.fonts[slot];
if (!f) return null;
return (
<div
key={slot}
className="flex items-center justify-between gap-4"
>
<span className="text-background">{f.family}</span>
<span className="text-right text-background/50">
{SLOT_LABELS[slot]}{' '}
</span>
</div>
);
})}
</div>
)}
</Tooltip.Content>
</Tooltip>
);
};
export { ThemePanel }; export const SCHEME_TOKENS = [
'background',
'background-secondary',
'foreground',
'foreground-secondary',
'accent',
'accent-foreground',
'border',
] as const;
export const SEMANTIC_TOKENS = ['error', 'warning', 'success', 'info'] as const;
export const COLOR_TOKENS = [...SCHEME_TOKENS, ...SEMANTIC_TOKENS] as const;
export type ColorToken = (typeof COLOR_TOKENS)[number];
export type TokenValues = { light: string; dark: string };
export type Scheme = {
id: string;
label: string;
colors: Record<ColorToken, TokenValues>;
/** Forces `color-scheme: dark` when active, regardless of the user's
* dark/light toggle. For schemes whose colors mirror dark on both sides
* of `light-dark()` (Family Midnight, obsidian Pro). */
darkFirst?: boolean;
};
export const TOKEN_LABELS: Record<ColorToken, string> = {
background: 'Background',
'background-secondary': 'Background secondary',
foreground: 'Foreground',
'foreground-secondary': 'Foreground secondary',
accent: 'Accent',
'accent-foreground': 'Accent foreground',
border: 'Border',
error: 'Error',
warning: 'Warning',
success: 'Success',
info: 'Info',
};
// Calibrated semantic set — one coherent quartet shared across schemes.
// Chroma pulled ~10-15% below the old raw values so status colors read
// confident but not garish, matching the restrained reference aesthetic
// (Linear/Apple). Single value, so light = dark.
const SEMANTIC_DEFAULTS: Record<(typeof SEMANTIC_TOKENS)[number], TokenValues> =
{
error: {
light: 'oklch(63% 0.20 25)',
dark: 'oklch(63% 0.20 25)',
},
warning: {
light: 'oklch(80% 0.155 85)',
dark: 'oklch(80% 0.155 85)',
},
success: {
light: 'oklch(70% 0.145 160)',
dark: 'oklch(70% 0.145 160)',
},
info: {
light: 'oklch(63% 0.165 255)',
dark: 'oklch(63% 0.165 255)',
},
};
// Mirrors the token values declared in src/foundations/setup/tokens.css
// (the single source of truth). Keep in sync with that file.
const DEFAULT_COLORS: Record<ColorToken, TokenValues> = {
background: { light: 'oklch(100% 0 0)', dark: 'oklch(12% 0 0)' },
'background-secondary': { light: 'oklch(96% 0 0)', dark: 'oklch(22% 0 0)' },
foreground: { light: 'oklch(0% 0 0)', dark: 'oklch(95% 0 0)' },
'foreground-secondary': { light: 'oklch(65% 0 0)', dark: 'oklch(53% 0 0)' },
// ConnectKit blue — mirrors tokens.css --color-accent.
accent: { light: 'oklch(64% 0.15 248)', dark: 'oklch(66% 0.15 248)' },
'accent-foreground': { light: 'oklch(100% 0 0)', dark: 'oklch(100% 0 0)' },
border: { light: 'oklch(94% 0 0)', dark: 'oklch(26% 0 0)' },
...SEMANTIC_DEFAULTS,
};
const buildScheme = (
id: string,
label: string,
overrides: Partial<Record<ColorToken, TokenValues>>,
options?: { darkFirst?: boolean }
): Scheme => ({
id,
label,
colors: { ...DEFAULT_COLORS, ...overrides },
...(options?.darkFirst && { darkFirst: true }),
});
export const SCHEMES: Scheme[] = [
buildScheme('default', 'Default', {}),
// ConnectKit (family.co) — soft white modal with #1A88F8 blue accent.
// Dark values mirror theme.css overrides (production rendering preserved).
buildScheme('slate', 'Slate', {
// #FFFFFF light / #1F2023 dark — theme.css's --color-background
background: { light: 'oklch(100% 0 0)', dark: 'oklch(20.5% 0.005 264)' },
// #F6F7F9 light / #313235 dark — theme.css's --color-background-secondary
'background-secondary': {
light: 'oklch(96.7% 0.005 264)',
dark: 'oklch(28% 0.005 264)',
},
// #373737 light / #FFFFFF dark — theme.css's --color-foreground
foreground: { light: 'oklch(31.5% 0 0)', dark: 'oklch(100% 0 0)' },
// #999999 light / #8B8F97 dark — theme.css's --color-foreground-secondary
'foreground-secondary': {
light: 'oklch(67% 0 0)',
dark: 'oklch(63% 0.012 264)',
},
// accent unchanged — theme.css doesn't override --color-accent
accent: { light: 'oklch(64% 0.15 248)', dark: 'oklch(66% 0.15 248)' },
'accent-foreground': {
light: 'oklch(100% 0 0)',
dark: 'oklch(100% 0 0)',
},
// #F0F0F0 light / rgba(255,255,255,0.1) dark — theme.css's --color-border
border: { light: 'oklch(95.5% 0 0)', dark: 'oklch(100% 0 0 / 0.1)' },
}),
// Warm cream + brown with burnt orange accent. Background chroma roughly
// halved so the page reads warm, not "behind orange glass"; secondary text
// neutralized; dark bg dropped for elevation headroom.
buildScheme('gruvbox', 'Gruvbox', {
background: { light: 'oklch(97% 0.012 85)', dark: 'oklch(22% 0.008 70)' },
'background-secondary': {
light: 'oklch(94% 0.016 85)',
dark: 'oklch(27% 0.011 70)',
},
foreground: { light: 'oklch(30% 0.03 60)', dark: 'oklch(92% 0.018 85)' },
'foreground-secondary': {
light: 'oklch(56% 0.018 60)',
dark: 'oklch(65% 0.015 85)',
},
accent: { light: 'oklch(57% 0.13 50)', dark: 'oklch(74% 0.12 60)' },
'accent-foreground': {
light: 'oklch(97% 0.012 85)',
dark: 'oklch(22% 0.008 70)',
},
border: { light: 'oklch(90% 0.012 85)', dark: 'oklch(30% 0.01 70)' },
}),
// Deep forest green palette. Background chroma halved, secondary text
// neutralized, accent eased back so green reads calm rather than acid.
buildScheme('forest', 'Forest', {
background: { light: 'oklch(97% 0.008 145)', dark: 'oklch(19% 0.01 150)' },
'background-secondary': {
light: 'oklch(94% 0.011 145)',
dark: 'oklch(24% 0.013 150)',
},
foreground: { light: 'oklch(28% 0.02 145)', dark: 'oklch(91% 0.012 130)' },
'foreground-secondary': {
light: 'oklch(52% 0.016 145)',
dark: 'oklch(64% 0.014 130)',
},
accent: { light: 'oklch(50% 0.115 150)', dark: 'oklch(72% 0.115 150)' },
'accent-foreground': {
light: 'oklch(97% 0.008 145)',
dark: 'oklch(19% 0.01 150)',
},
border: { light: 'oklch(91% 0.01 145)', dark: 'oklch(27% 0.012 150)' },
}),
// Frosty cool blue/gray (Nord-inspired). Already restrained; dark bg
// dropped for contrast headroom, secondary text neutralized.
buildScheme('nord', 'Nord', {
background: { light: 'oklch(98% 0.004 240)', dark: 'oklch(23% 0.01 245)' },
'background-secondary': {
light: 'oklch(95% 0.007 240)',
dark: 'oklch(28% 0.013 245)',
},
foreground: { light: 'oklch(30% 0.018 245)', dark: 'oklch(91% 0.01 240)' },
'foreground-secondary': {
light: 'oklch(53% 0.015 245)',
dark: 'oklch(66% 0.013 240)',
},
accent: { light: 'oklch(56% 0.105 230)', dark: 'oklch(76% 0.09 225)' },
'accent-foreground': {
light: 'oklch(98% 0.004 240)',
dark: 'oklch(23% 0.01 245)',
},
border: { light: 'oklch(91% 0.008 240)', dark: 'oklch(31% 0.013 245)' },
}),
// Cream/teal (Solarized-inspired). The accent hue flip (blue→yellow) is
// gone — one consistent cyan-blue both sides so the theme reads as a
// single coherent system. Background chroma roughly halved.
buildScheme('solarized', 'Solarized', {
background: { light: 'oklch(97.5% 0.01 90)', dark: 'oklch(21% 0.014 230)' },
'background-secondary': {
light: 'oklch(95% 0.013 90)',
dark: 'oklch(26% 0.016 230)',
},
foreground: { light: 'oklch(32% 0.02 220)', dark: 'oklch(90% 0.012 90)' },
'foreground-secondary': {
light: 'oklch(56% 0.015 220)',
dark: 'oklch(64% 0.012 90)',
},
accent: { light: 'oklch(58% 0.11 225)', dark: 'oklch(72% 0.11 225)' },
'accent-foreground': {
light: 'oklch(97.5% 0.01 90)',
dark: 'oklch(21% 0.014 230)',
},
border: { light: 'oklch(91% 0.012 90)', dark: 'oklch(29% 0.014 230)' },
}),
// Soft rose / dusk purple (Rose Pine-inspired). Background chroma halved;
// secondary text neutralized; accent eased back to a quiet dusty rose.
buildScheme('rose', 'Rose', {
background: { light: 'oklch(97% 0.008 30)', dark: 'oklch(22% 0.012 290)' },
'background-secondary': {
light: 'oklch(94% 0.011 30)',
dark: 'oklch(27% 0.015 290)',
},
foreground: { light: 'oklch(33% 0.02 280)', dark: 'oklch(91% 0.012 30)' },
'foreground-secondary': {
light: 'oklch(55% 0.016 280)',
dark: 'oklch(65% 0.015 30)',
},
accent: { light: 'oklch(60% 0.115 25)', dark: 'oklch(76% 0.105 30)' },
'accent-foreground': {
light: 'oklch(97% 0.008 30)',
dark: 'oklch(22% 0.012 290)',
},
border: { light: 'oklch(91% 0.011 30)', dark: 'oklch(31% 0.015 290)' },
}),
// ConnectKit "Midnight" theme — deep slate with white text, #1A88F8 accent.
// Reference: family/connectkit themes/midnight.ts
buildScheme(
'family-midnight',
'Family — Midnight',
{
// #1F2023 — deep slate (light value mirrors so the picker preview reads dark even in light mode)
background: {
light: 'oklch(20.5% 0.005 264)',
dark: 'oklch(20.5% 0.005 264)',
},
// #313235 — lifted slate
'background-secondary': {
light: 'oklch(28% 0.005 264)',
dark: 'oklch(28% 0.005 264)',
},
// #FFFFFF
foreground: { light: 'oklch(100% 0 0)', dark: 'oklch(100% 0 0)' },
// #8B8F97 — muted slate-gray
'foreground-secondary': {
light: 'oklch(63% 0.012 264)',
dark: 'oklch(63% 0.012 264)',
},
// #1A88F8 — ConnectKit blue accent, chroma eased a touch
accent: { light: 'oklch(63% 0.15 252)', dark: 'oklch(63% 0.15 252)' },
'accent-foreground': {
light: 'oklch(100% 0 0)',
dark: 'oklch(100% 0 0)',
},
// rgba(255,255,255,0.1) — translucent ring
border: { light: 'oklch(100% 0 0 / 0.1)', dark: 'oklch(100% 0 0 / 0.1)' },
},
{ darkFirst: true }
),
buildScheme(
'obsidian',
'obsidian Pro',
{
background: {
light: 'oklch(14.3% 0.002 16)',
dark: 'oklch(14.3% 0.002 16)',
},
'background-secondary': {
light: 'oklch(21.5% 0.002 15)',
dark: 'oklch(21.5% 0.002 15)',
},
// obsidian Pro --fg-1, primary text — pure white.
foreground: {
light: 'oklch(100% 0 0)',
dark: 'oklch(100% 0 0)',
},
// obsidian Pro --fg-2, muted secondary text — display-p3(.737 .733 .733).
'foreground-secondary': {
light: 'oklch(79.3% 0.001 12)',
dark: 'oklch(79.3% 0.001 12)',
},
// #9391F7 — periwinkle accent, used sparingly in obsidian's UI
accent: {
light: 'oklch(68% 0.115 285)',
dark: 'oklch(68% 0.115 285)',
},
// Sits on the periwinkle accent — mirrors the page background.
'accent-foreground': {
light: 'oklch(14.3% 0.002 16)',
dark: 'oklch(14.3% 0.002 16)',
},
// Quiet hairline border — obsidian Pro's --bg-4 step rather than the heavier
// --bg-6, so it reads as a soft division, not a dominant outline.
// Goes transparent in the "layered" surface style.
border: {
light: 'oklch(27.9% 0.003 16)',
dark: 'oklch(27.9% 0.003 16)',
},
// obsidian's iconic danger orange — kept saturated, it's a brand signal
error: { light: 'oklch(60% 0.22 32)', dark: 'oklch(60% 0.22 32)' },
},
{ darkFirst: true }
),
buildScheme('dracula', 'Dracula', {
background: { light: 'oklch(97% 0.006 290)', dark: 'oklch(22% 0.015 285)' },
'background-secondary': {
light: 'oklch(94% 0.009 290)',
dark: 'oklch(27% 0.018 285)',
},
foreground: { light: 'oklch(28% 0.025 290)', dark: 'oklch(93% 0.01 290)' },
'foreground-secondary': {
light: 'oklch(52% 0.018 290)',
dark: 'oklch(68% 0.016 290)',
},
accent: { light: 'oklch(56% 0.20 295)', dark: 'oklch(74% 0.165 295)' },
'accent-foreground': {
light: 'oklch(97% 0.006 290)',
dark: 'oklch(22% 0.015 285)',
},
border: { light: 'oklch(91% 0.01 290)', dark: 'oklch(31% 0.018 285)' },
}),
];
export const DEFAULT_SCHEME_ID = 'slate';
export const DEFAULT_SCHEME = SCHEMES[0]; import {
type ColorToken,
DEFAULT_SCHEME_ID,
type TokenValues,
} from './schemes';
export const STORAGE_KEY = 'foundations-ds-config';
// Scheme applied to a first-time visitor (no stored config yet).
// Not the same as DEFAULT_SCHEME_ID (now 'slate') — DEFAULT_SCHEME_ID is the
// labelled-default scheme the picker resets to; DEFAULT_CONFIG_SCHEME_ID is
// the production landing scheme. They're independent.
export const DEFAULT_CONFIG_SCHEME_ID = 'obsidian';
export const RADIUS_DEFAULT = 2;
export const RADIUS_STEP_REM = 0.0625;
export const RING_DEFAULT = 4;
export const SURFACE_STYLES = ['bordered', 'layered'] as const;
export type SurfaceStyle = (typeof SURFACE_STYLES)[number];
export const SURFACE_STYLE_DEFAULT: SurfaceStyle = 'bordered';
export type FontCategory =
| 'sans-serif'
| 'serif'
| 'monospace'
| 'display'
| 'handwriting';
export type StoredFont = { family: string; category: FontCategory };
export type FontSlot = 'heading' | 'body' | 'ui' | 'mono';
export const FONT_SLOTS: FontSlot[] = ['heading', 'body', 'ui', 'mono'];
export const FONT_VARS: Record<FontSlot, string> = {
heading: '--font-heading',
body: '--font-body',
ui: '--font-ui',
mono: '--font-mono',
};
export type StoredFonts = Record<FontSlot, StoredFont | null>;
export const EMPTY_FONTS: StoredFonts = {
heading: null,
body: null,
ui: null,
mono: null,
};
// Default fonts for a first-time visitor: the Geist pairing — paired sans +
// mono designed as a family. Mirrored in shell.astro's inline script.
export const DEFAULT_FONTS: StoredFonts = {
heading: null,
body: { family: 'Geist', category: 'sans-serif' },
ui: null,
mono: { family: 'Geist Mono', category: 'monospace' },
};
export type StoredConfig = {
schemeId: string;
overrides: Partial<Record<ColorToken, TokenValues>>;
radiusStep: number;
ringWidth: number;
fonts: StoredFonts;
surfaceStyle: SurfaceStyle;
};
export const DEFAULT_STORED_CONFIG: StoredConfig = {
schemeId: DEFAULT_CONFIG_SCHEME_ID,
overrides: {},
radiusStep: RADIUS_DEFAULT,
ringWidth: RING_DEFAULT,
fonts: DEFAULT_FONTS,
// obsidian Pro pairs naturally with the layered surface style.
surfaceStyle: 'layered',
};
// Pre-schemes shape: { accent: 'Blue', radiusStep, ringWidth }.
type LegacyConfig = {
accent: string;
radiusStep?: number;
ringWidth?: number;
};
// Single-font shape that preceded the heading/body/mono triple.
type SingleFontConfig = {
schemeId: string;
overrides: Partial<Record<ColorToken, TokenValues>>;
radiusStep: number;
ringWidth: number;
font: StoredFont | null;
};
const LEGACY_LABEL_TO_SCHEME_ID: Record<string, string> = {
Contrast: 'default',
Red: 'gruvbox',
Orange: 'gruvbox',
Yellow: 'solarized',
Green: 'forest',
Blue: 'nord',
Purple: 'dracula',
Pink: 'rose',
};
const isLegacy = (raw: unknown): raw is LegacyConfig =>
typeof raw === 'object' &&
raw !== null &&
'accent' in raw &&
!('schemeId' in raw);
const isSingleFont = (raw: unknown): raw is SingleFontConfig =>
typeof raw === 'object' &&
raw !== null &&
'schemeId' in raw &&
'font' in raw &&
!('fonts' in raw);
export const readStored = (): StoredConfig => {
if (typeof window === 'undefined') return DEFAULT_STORED_CONFIG;
try {
const raw = window.localStorage.getItem(STORAGE_KEY);
if (!raw) return DEFAULT_STORED_CONFIG;
const parsed: unknown = JSON.parse(raw);
if (isLegacy(parsed)) {
return {
schemeId: LEGACY_LABEL_TO_SCHEME_ID[parsed.accent] ?? DEFAULT_SCHEME_ID,
overrides: {},
radiusStep: parsed.radiusStep ?? RADIUS_DEFAULT,
ringWidth: parsed.ringWidth ?? RING_DEFAULT,
fonts: EMPTY_FONTS,
surfaceStyle: SURFACE_STYLE_DEFAULT,
};
}
if (isSingleFont(parsed)) {
return {
schemeId: parsed.schemeId,
overrides: parsed.overrides,
radiusStep: parsed.radiusStep,
ringWidth: parsed.ringWidth,
fonts: { ...EMPTY_FONTS, body: parsed.font },
surfaceStyle: SURFACE_STYLE_DEFAULT,
};
}
const next = parsed as Partial<StoredConfig>;
return {
...DEFAULT_STORED_CONFIG,
...next,
fonts: { ...EMPTY_FONTS, ...(next.fonts ?? {}) },
surfaceStyle: SURFACE_STYLES.includes(next.surfaceStyle as SurfaceStyle)
? (next.surfaceStyle as SurfaceStyle)
: SURFACE_STYLE_DEFAULT,
};
} catch {
return DEFAULT_STORED_CONFIG;
}
};
export const writeStored = (config: StoredConfig) => {
if (typeof window === 'undefined') return;
try {
window.localStorage.setItem(STORAGE_KEY, JSON.stringify(config));
} catch {
// ignore quota / private-mode failures
}
}; Mechanism
Two orthogonal attributes on <html>:
data-theme="<scheme-id>"— which named scheme (slate, obsidian, gruvbox, etc.)data-mode="light" | "dark"— color mode
All schemes are data in schemes.ts. The <Theme> provider reads from these attributes (which are set before paint by an inline script in src/layouts/shell.astro) and exposes useTheme() for consumers.
API
| Prop | Default | Type | Description |
|---|---|---|---|
scheme | - | string | Force a specific scheme id. Overrides any stored or default value. |
mode | - | "light" | "dark" | Force a specific mode. Overrides any stored or system preference. |
children | - | React.ReactNode | Subtree that consumes useTheme(). |
useTheme() returns { scheme, mode, setScheme, setMode, schemes }.
Previous
Stack
Next
Component patterns