Agents (llms.txt)

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