// src/stories/docs/theme-editor/theme-editor.tsx import { createSignal, createEffect, onMount, onCleanup, For } from 'solid-js'; import { Button } from '../../../ui/button'; import { discoverPalettes } from '../theme-tokens'; import { buildThemeCss, type Palette } from './theme-css'; import { buildPresets, type Preset } from './presets'; import { Inspector } from './inspector'; import { Canvas, CANVAS_CLASS } from './canvas'; const STYLE_ID = 'kitn-theme-editor-overrides'; /** Full-screen live theme editor: edit light/dark tokens, preview a real chat, export CSS. */ export function ThemeEditor() { const [presets, setPresets] = createSignal([]); const [mode, setMode] = createSignal<'light' | 'dark'>('light'); const [light, setLight] = createSignal({}); const [dark, setDark] = createSignal({}); const [presetName, setPresetName] = createSignal('Default'); const [copied, setCopied] = createSignal(false); onMount(() => { const ps = buildPresets(discoverPalettes()); setPresets(ps); const def = ps.find((p) => p.name === 'Default')!; setLight({ ...def.light }); setDark({ ...def.dark }); }); // Apply the ACTIVE mode's palette directly onto the canvas wrapper. Scoping to // CANVAS_CLASS (rather than :root/.dark) means the preview reflects the editor's // mode independently of any ancestor `.dark` (e.g. Storybook's dark theme), and // only the canvas reskins — not the editor chrome. Export (Copy CSS) still emits // the full :root + .dark theme separately. let styleEl: HTMLStyleElement | undefined; createEffect(() => { if (!Object.keys(light()).length) return; // not seeded yet const colors: Palette = mode() === 'light' ? light() : dark(); const radius = light()['--radius'] ?? '0.6rem'; const colorBody = Object.keys(colors) .filter((k) => k.startsWith('--color-')) .sort() .map((k) => ` ${k}: ${colors[k]};`) .join('\n'); // Re-express the radius scale on the wrapper. The kit's --radius-sm/md/lg/xl are // calc(var(--radius) …) declared at :root, so their var(--radius) resolves once // at :root and inherits as a fixed length — overriding --radius lower in the tree // doesn't move them. Re-declaring them here (against the wrapper's --radius) does. const radiusBody = [ ` --radius: ${radius};`, ` --radius-sm: calc(var(--radius) - 4px);`, ` --radius-md: calc(var(--radius) - 2px);`, ` --radius-lg: var(--radius);`, ` --radius-xl: calc(var(--radius) + 4px);`, ].join('\n'); // Re-establish the inherited `color` and native `color-scheme` on the wrapper: // tokens alone don't fix text whose color is inherited from outside the canvas // (e.g. elements with no explicit text-color class), nor native form controls. const css = `.${CANVAS_CLASS} {\n${colorBody}\n${radiusBody}\n color: var(--color-foreground);\n color-scheme: ${mode()};\n}`; if (!styleEl) { styleEl = document.createElement('style'); styleEl.id = STYLE_ID; document.head.appendChild(styleEl); } styleEl.textContent = css; }); onCleanup(() => styleEl?.remove()); const colorTokens = () => Object.keys(light()).filter((n) => n.startsWith('--color-')).sort(); const activeValues = () => (mode() === 'light' ? light() : dark()); const setColor = (token: string, hex: string) => { if (mode() === 'light') setLight((v) => ({ ...v, [token]: hex })); else setDark((v) => ({ ...v, [token]: hex })); }; const setRadius = (rem: string) => setLight((v) => ({ ...v, '--radius': rem })); const loadPreset = (name: string) => { const p = presets().find((x) => x.name === name); if (!p) return; setLight({ ...p.light }); setDark({ ...p.dark }); setPresetName(name); }; const reset = () => loadPreset('Default'); const copyCss = async () => { try { await navigator.clipboard.writeText(buildThemeCss(light(), dark())); setCopied(true); setTimeout(() => setCopied(false), 1500); } catch { /* clipboard blocked */ } }; return (
{/* Top bar */}
Theme editor
{/* Body */}
); }