diff --git a/Documentation/TODO.md b/Documentation/TODO.md index 4ef2f51..ea4546a 100644 --- a/Documentation/TODO.md +++ b/Documentation/TODO.md @@ -66,6 +66,14 @@ - [ ] Table - [ ] CVA - [ ] Storyboard +- [ ] Theme-System + - [ ] Theme-Registry + - [ ] Built-In-Theme-Presets + - [ ] Active-Theme-Persistence + - [ ] Theme-Switcher + - [ ] Theme-JSON-Upload + - [ ] Theme-JSON-Import-Validation + - [ ] Community-Theme-Readiness ### Version 0.3.0 diff --git a/Frontend/public/themes/moku-default.json b/Frontend/public/themes/moku-default.json new file mode 100644 index 0000000..3a21476 --- /dev/null +++ b/Frontend/public/themes/moku-default.json @@ -0,0 +1,170 @@ +{ + "schemaVersion": "1.0.0", + "id": "moku-default", + "name": "Moku Default", + "description": "Baseline Moku shell tokens for built-in light and dark modes.", + "tokens": { + "shared": { + "palette": { + "gray": { + "0": "hsl(210 20% 99%)", + "50": "hsl(220 20% 97%)", + "100": "hsl(220 16% 93%)", + "200": "hsl(220 13% 87%)", + "300": "hsl(220 11% 75%)", + "400": "hsl(220 9% 58%)", + "500": "hsl(220 10% 45%)", + "600": "hsl(220 14% 34%)", + "700": "hsl(220 18% 24%)", + "800": "hsl(220 22% 16%)", + "900": "hsl(220 28% 10%)" + }, + "blue": { + "400": "hsl(218 88% 61%)", + "500": "hsl(221 83% 53%)", + "600": "hsl(224 76% 48%)" + }, + "green": { + "500": "hsl(154 60% 40%)" + }, + "red": { + "500": "hsl(0 72% 54%)" + }, + "amber": { + "500": "hsl(36 100% 50%)" + } + }, + "space": { + "1": "0.25rem", + "2": "0.5rem", + "3": "0.75rem", + "4": "1rem", + "5": "1.25rem", + "6": "1.5rem", + "8": "2rem", + "10": "2.5rem", + "12": "3rem" + }, + "radius": { + "sm": "0.375rem", + "md": "0.625rem", + "lg": "0.875rem", + "xl": "1.25rem", + "pill": "999px" + }, + "size": { + "controlMd": "2.25rem", + "controlLg": "2.5rem", + "contentWidthWide": "72rem", + "blurOverlay": "18px" + }, + "shadow": { + "soft": "0 12px 32px hsl(220 30% 10% / 0.08)", + "strong": "0 20px 48px hsl(220 30% 10% / 0.16)" + }, + "zIndex": { + "base": "1", + "dropdown": "100", + "sticky": "200", + "overlay": "400", + "modal": "500", + "toast": "600" + }, + "motion": { + "durationFast": "140ms", + "durationBase": "220ms", + "durationSlow": "320ms", + "easeStandard": "cubic-bezier(0.2, 0.8, 0.2, 1)" + }, + "typography": { + "fontFamily": { + "sans": "ui-sans-serif, system-ui, -apple-system, BlinkMacSystemFont, \"Segoe UI\", \"Helvetica Neue\", Arial, sans-serif", + "heading": "\"Avenir Next\", \"Segoe UI\", \"Helvetica Neue\", Arial, sans-serif", + "display": "\"Avenir Next\", \"Segoe UI\", \"Helvetica Neue\", Arial, sans-serif", + "serif": "ui-serif, Georgia, Cambria, \"Times New Roman\", Times, serif", + "mono": "ui-monospace, \"SF Mono\", \"SFMono-Regular\", Menlo, Monaco, Consolas, \"Liberation Mono\", \"Courier New\", monospace" + }, + "fontSize": { + "caption": "0.75rem", + "label": "0.875rem", + "body": "1rem", + "title": "clamp(1.125rem, 1.05rem + 0.3vw, 1.25rem)", + "heading": "clamp(1.5rem, 1.2rem + 1vw, 2.125rem)", + "display": "clamp(2.25rem, 1.7rem + 2.2vw, 3.75rem)" + }, + "lineHeight": { + "caption": "1.4", + "label": "1.35", + "body": "1.55", + "title": "1.3", + "heading": "1.15", + "display": "1.05" + }, + "fontWeight": { + "caption": "500", + "label": "600", + "body": "400", + "title": "600", + "heading": "600", + "display": "700" + }, + "letterSpacing": { + "caption": "0.01em", + "label": "0.005em", + "body": "0", + "title": "-0.01em", + "heading": "-0.02em", + "display": "-0.03em" + } + } + }, + "modes": { + "light": { + "colorScheme": "light", + "colors": { + "canvas": "var(--gray-50)", + "surface": "hsl(0 0% 100% / 0.9)", + "surfaceMuted": "var(--gray-0)", + "surfaceHover": "var(--gray-100)", + "border": "hsl(220 15% 85% / 0.9)", + "borderStrong": "hsl(220 12% 70% / 0.9)", + "text": "var(--gray-800)", + "textMuted": "var(--gray-500)", + "accent": "var(--blue-500)", + "accentStrong": "var(--blue-600)", + "accentSoft": "hsl(218 88% 61% / 0.12)", + "accentContrast": "hsl(0 0% 100%)", + "success": "var(--green-500)", + "danger": "var(--red-500)", + "warning": "var(--amber-500)", + "focusRing": "hsl(221 83% 53% / 0.55)" + } + }, + "dark": { + "colorScheme": "dark", + "colors": { + "canvas": "var(--gray-900)", + "surface": "hsl(220 23% 14% / 0.92)", + "surfaceMuted": "hsl(220 22% 12% / 0.96)", + "surfaceHover": "hsl(220 18% 20% / 0.96)", + "border": "hsl(220 12% 26% / 0.9)", + "borderStrong": "hsl(220 12% 38% / 0.9)", + "text": "hsl(210 20% 96%)", + "textMuted": "hsl(220 12% 70%)", + "accent": "hsl(217 91% 67%)", + "accentStrong": "hsl(218 88% 61%)", + "accentSoft": "hsl(217 91% 67% / 0.18)", + "accentContrast": "hsl(220 28% 10%)", + "success": "hsl(154 55% 48%)", + "danger": "hsl(0 72% 62%)", + "warning": "hsl(36 100% 60%)", + "focusRing": "hsl(217 91% 67% / 0.65)" + }, + "shadow": { + "soft": "0 16px 40px hsl(220 40% 3% / 0.45)", + "strong": "0 24px 60px hsl(220 40% 3% / 0.55)" + } + } + } + } +} diff --git a/Frontend/src/components/shell/AppShell/AppShell.tsx b/Frontend/src/components/shell/AppShell/AppShell.tsx index 9d0b58f..cca68af 100644 --- a/Frontend/src/components/shell/AppShell/AppShell.tsx +++ b/Frontend/src/components/shell/AppShell/AppShell.tsx @@ -1,7 +1,7 @@ // Path: Frontend/src/components/shell/AppShell/AppShell.tsx import { createSignal, onMount, type JSX } from "solid-js"; -import { getDocumentTheme, setTheme, type Theme } from "../../../helpers/theme"; +import { getDocumentTheme, setTheme, type Theme } from "../../../theme/runtime"; import { WorkspaceHome } from "../../workspace-home/WorkspaceHome/WorkspaceHome"; import { LeftRail } from "../LeftRail/LeftRail"; import { ProfileDock } from "../ProfileDock/ProfileDock"; diff --git a/Frontend/src/components/shell/TopBar/TopBar.tsx b/Frontend/src/components/shell/TopBar/TopBar.tsx index d143661..a94fa0c 100644 --- a/Frontend/src/components/shell/TopBar/TopBar.tsx +++ b/Frontend/src/components/shell/TopBar/TopBar.tsx @@ -1,8 +1,8 @@ // Path: Frontend/src/components/shell/TopBar/TopBar.tsx import { For, type JSX } from "solid-js"; -import type { Theme } from "../../../helpers/theme"; import { ChevronDown } from "../../../lib/icons"; +import type { Theme } from "../../../theme/runtime"; import { topBarActions } from "../data/shell.data"; import styles from "./TopBar.module.scss"; diff --git a/Frontend/src/entry-client.tsx b/Frontend/src/entry-client.tsx index cd3b040..a725265 100644 --- a/Frontend/src/entry-client.tsx +++ b/Frontend/src/entry-client.tsx @@ -1,8 +1,9 @@ // Path: Frontend/src/entry-client.tsx // @refresh reload -import type { JSX } from "solid-js"; import { mount, StartClient } from "@solidjs/start/client"; +import type { JSX } from "solid-js"; +import { initializeThemeRuntime } from "./theme/runtime"; const getAppRoot = (): HTMLElement => { const appRoot = document.getElementById("app"); @@ -18,4 +19,6 @@ const mountApp = (): void => { mount((): JSX.Element => , getAppRoot()); }; +void initializeThemeRuntime(); + mountApp(); diff --git a/Frontend/src/entry-server.tsx b/Frontend/src/entry-server.tsx index dfd91a3..37888e3 100644 --- a/Frontend/src/entry-server.tsx +++ b/Frontend/src/entry-server.tsx @@ -3,19 +3,21 @@ // @refresh reload import type { JSX } from "solid-js"; import { createHandler, StartServer } from "@solidjs/start/server"; +import { DEFAULT_THEME, THEME_STORAGE_KEY } from "./theme/runtime"; +import { THEME_MODE_NAMES } from "./theme/schema"; const themeBootstrapScript = ` (() => { try { - const storageKey = "theme"; + const storageKey = ${JSON.stringify(THEME_STORAGE_KEY)}; const stored = localStorage.getItem(storageKey); - const theme = stored === "light" || stored === "dark" + const theme = stored === ${JSON.stringify(THEME_MODE_NAMES[0])} || stored === ${JSON.stringify(THEME_MODE_NAMES[1])} ? stored - : (window.matchMedia("(prefers-color-scheme: dark)").matches ? "dark" : "light"); + : (window.matchMedia("(prefers-color-scheme: dark)").matches ? ${JSON.stringify(THEME_MODE_NAMES[1])} : ${JSON.stringify(DEFAULT_THEME)}); document.documentElement.setAttribute("data-theme", theme); } catch { - document.documentElement.setAttribute("data-theme", "light"); + document.documentElement.setAttribute("data-theme", ${JSON.stringify(DEFAULT_THEME)}); } })(); `; @@ -28,7 +30,7 @@ type DocumentRenderProps = { const renderDocument = ({ assets, children, scripts }: DocumentRenderProps): JSX.Element => { return ( - + diff --git a/Frontend/src/helpers/theme.ts b/Frontend/src/helpers/theme.ts deleted file mode 100644 index c229864..0000000 --- a/Frontend/src/helpers/theme.ts +++ /dev/null @@ -1,22 +0,0 @@ -// Path: Frontend/src/helpers/theme.ts - -export type Theme = "light" | "dark"; - -export const THEME_STORAGE_KEY = "theme"; - -export const resolvePreferredTheme = (): Theme => { - const stored = localStorage.getItem(THEME_STORAGE_KEY); - - if (stored === "light" || stored === "dark") { - return stored; - } - - return window.matchMedia("(prefers-color-scheme: dark)").matches ? "dark" : "light"; -}; - -export const getDocumentTheme = (): Theme => (document.documentElement.getAttribute("data-theme") === "dark" ? "dark" : "light"); - -export const setTheme = (theme: Theme): void => { - document.documentElement.setAttribute("data-theme", theme); - localStorage.setItem(THEME_STORAGE_KEY, theme); -}; diff --git a/Frontend/src/theme/presets.ts b/Frontend/src/theme/presets.ts new file mode 100644 index 0000000..332cbfc --- /dev/null +++ b/Frontend/src/theme/presets.ts @@ -0,0 +1,11 @@ +// Path: Frontend/src/theme/presets.ts + +import type { ThemeDefinition } from "./schema"; + +export const defaultThemePresetPath = "/themes/moku-default.json"; + +export const defaultThemePresetMeta = { + id: "moku-default", + name: "Moku Default", + description: "The baseline Moku theme preset, matching the current shell styling tokens.", +} satisfies Pick; diff --git a/Frontend/src/theme/runtime.ts b/Frontend/src/theme/runtime.ts new file mode 100644 index 0000000..f79d6af --- /dev/null +++ b/Frontend/src/theme/runtime.ts @@ -0,0 +1,129 @@ +// Path: Frontend/src/theme/runtime.ts + +import { defaultThemePresetPath } from "./presets"; +import { createCssVariableMap, isThemeModeName, validateThemeDefinition, type ThemeDefinition, type ThemeModeName } from "./schema"; + +export type Theme = ThemeModeName; + +export const THEME_STORAGE_KEY = "theme"; +export const DEFAULT_THEME: Theme = "light"; + +let activeThemeDefinition: ThemeDefinition | null = null; +let themeInitializationPromise: Promise | null = null; + +const canUseDom = (): boolean => typeof document !== "undefined"; + +const canUseStorage = (): boolean => typeof localStorage !== "undefined"; + +const canUseMatchMedia = (): boolean => typeof window !== "undefined" && typeof window.matchMedia === "function"; + +const getRootElement = (): HTMLElement | null => { + return canUseDom() ? document.documentElement : null; +}; + +const setDocumentThemeMode = (theme: Theme): void => { + const rootElement = getRootElement(); + + rootElement?.setAttribute("data-theme", theme); + + if (rootElement) { + rootElement.style.colorScheme = theme; + } + + if (canUseStorage()) { + localStorage.setItem(THEME_STORAGE_KEY, theme); + } +}; + +const applyThemeVariables = (themeDefinition: ThemeDefinition, theme: Theme): void => { + const rootElement = getRootElement(); + + if (!rootElement) { + return; + } + + const variableMap = createCssVariableMap(themeDefinition, theme); + + for (const [name, value] of Object.entries(variableMap)) { + rootElement.style.setProperty(name, value); + } +}; + +export const resolvePreferredTheme = (): Theme => { + const stored = canUseStorage() ? localStorage.getItem(THEME_STORAGE_KEY) : null; + + if (isThemeModeName(stored)) { + return stored; + } + + if (canUseMatchMedia()) { + return window.matchMedia("(prefers-color-scheme: dark)").matches ? "dark" : DEFAULT_THEME; + } + + return DEFAULT_THEME; +}; + +export const getDocumentTheme = (): Theme => { + const theme = getRootElement()?.getAttribute("data-theme"); + + return isThemeModeName(theme) ? theme : resolvePreferredTheme(); +}; + +export const applyThemeDefinition = (themeDefinition: ThemeDefinition, theme: Theme): void => { + activeThemeDefinition = themeDefinition; + setDocumentThemeMode(theme); + applyThemeVariables(themeDefinition, theme); +}; + +export const initializeThemeRuntime = async (): Promise => { + if (typeof window === "undefined") { + return null; + } + + if (activeThemeDefinition) { + applyThemeDefinition(activeThemeDefinition, getDocumentTheme()); + return activeThemeDefinition; + } + + if (!themeInitializationPromise) { + themeInitializationPromise = (async (): Promise => { + try { + const response = await fetch(defaultThemePresetPath, { + headers: { + Accept: "application/json", + }, + }); + + if (!response.ok) { + throw new Error(`Theme preset request failed with status ${response.status}.`); + } + + const candidate = (await response.json()) as unknown; + const result = validateThemeDefinition(candidate); + + if (!result.success) { + throw new Error(result.errors.join(" ")); + } + + applyThemeDefinition(result.data, getDocumentTheme()); + + return result.data; + } catch (error) { + console.error("Failed to initialize theme runtime.", error); + return null; + } finally { + themeInitializationPromise = null; + } + })(); + } + + return themeInitializationPromise; +}; + +export const setTheme = (theme: Theme): void => { + setDocumentThemeMode(theme); + + if (activeThemeDefinition) { + applyThemeVariables(activeThemeDefinition, theme); + } +}; diff --git a/Frontend/src/theme/schema.ts b/Frontend/src/theme/schema.ts new file mode 100644 index 0000000..27e44d3 --- /dev/null +++ b/Frontend/src/theme/schema.ts @@ -0,0 +1,342 @@ +// Path: Frontend/src/theme/schema.ts + +export const THEME_SCHEMA_VERSION = "1.0.0"; +export const THEME_MODE_NAMES = ["light", "dark"] as const; +export const THEME_PALETTE_KEYS = { + gray: ["0", "50", "100", "200", "300", "400", "500", "600", "700", "800", "900"], + blue: ["400", "500", "600"], + green: ["500"], + red: ["500"], + amber: ["500"], +} as const; +export const THEME_SPACE_KEYS = ["1", "2", "3", "4", "5", "6", "8", "10", "12"] as const; +export const THEME_RADIUS_KEYS = ["sm", "md", "lg", "xl", "pill"] as const; +export const THEME_SIZE_KEYS = ["controlMd", "controlLg", "contentWidthWide", "blurOverlay"] as const; +export const THEME_SHADOW_KEYS = ["soft", "strong"] as const; +export const THEME_Z_INDEX_KEYS = ["base", "dropdown", "sticky", "overlay", "modal", "toast"] as const; +export const THEME_MOTION_KEYS = ["durationFast", "durationBase", "durationSlow", "easeStandard"] as const; +export const THEME_TYPE_SCALE_KEYS = ["caption", "label", "body", "title", "heading", "display"] as const; +export const THEME_FONT_FAMILY_KEYS = ["sans", "heading", "display", "serif", "mono"] as const; +export const THEME_MODE_COLOR_KEYS = ["canvas", "surface", "surfaceMuted", "surfaceHover", "border", "borderStrong", "text", "textMuted", "accent", "accentStrong", "accentSoft", "accentContrast", "success", "danger", "warning", "focusRing"] as const; + +export type ThemeModeName = (typeof THEME_MODE_NAMES)[number]; + +type ThemeTokenMap = Record; + +export type ThemeSharedTokens = { + palette: { + gray: ThemeTokenMap<(typeof THEME_PALETTE_KEYS.gray)[number]>; + blue: ThemeTokenMap<(typeof THEME_PALETTE_KEYS.blue)[number]>; + green: ThemeTokenMap<(typeof THEME_PALETTE_KEYS.green)[number]>; + red: ThemeTokenMap<(typeof THEME_PALETTE_KEYS.red)[number]>; + amber: ThemeTokenMap<(typeof THEME_PALETTE_KEYS.amber)[number]>; + }; + space: ThemeTokenMap<(typeof THEME_SPACE_KEYS)[number]>; + radius: ThemeTokenMap<(typeof THEME_RADIUS_KEYS)[number]>; + size: ThemeTokenMap<(typeof THEME_SIZE_KEYS)[number]>; + shadow: ThemeTokenMap<(typeof THEME_SHADOW_KEYS)[number]>; + zIndex: ThemeTokenMap<(typeof THEME_Z_INDEX_KEYS)[number]>; + motion: ThemeTokenMap<(typeof THEME_MOTION_KEYS)[number]>; + typography: { + fontFamily: ThemeTokenMap<(typeof THEME_FONT_FAMILY_KEYS)[number]>; + fontSize: ThemeTokenMap<(typeof THEME_TYPE_SCALE_KEYS)[number]>; + lineHeight: ThemeTokenMap<(typeof THEME_TYPE_SCALE_KEYS)[number]>; + fontWeight: ThemeTokenMap<(typeof THEME_TYPE_SCALE_KEYS)[number]>; + letterSpacing: ThemeTokenMap<(typeof THEME_TYPE_SCALE_KEYS)[number]>; + }; +}; + +export type ThemeModeTokens = { + colorScheme: ThemeModeName; + colors: ThemeTokenMap<(typeof THEME_MODE_COLOR_KEYS)[number]>; + shadow?: Partial>; +}; + +export type ThemeDefinition = { + schemaVersion: typeof THEME_SCHEMA_VERSION; + id: string; + name: string; + description?: string; + author?: string; + tokens: { + shared: ThemeSharedTokens; + modes: Record; + }; +}; + +export type ThemeValidationResult = + | { + success: true; + data: ThemeDefinition; + } + | { + success: false; + errors: string[]; + }; + +const isRecord = (value: unknown): value is Record => typeof value === "object" && value !== null && !Array.isArray(value); + +const isNonEmptyString = (value: unknown): value is string => typeof value === "string" && value.trim().length > 0; + +const getNestedRecord = (value: unknown, path: string, errors: string[]): Record | null => { + if (!isRecord(value)) { + errors.push(`${path} must be an object.`); + return null; + } + + return value; +}; + +const validateRequiredStringMap = (record: Record, path: string, requiredKeys: readonly string[], errors: string[]): void => { + for (const key of requiredKeys) { + if (!isNonEmptyString(record[key])) { + errors.push(`${path}.${key} must be a non-empty string token value.`); + } + } +}; + +export const isThemeModeName = (value: unknown): value is ThemeModeName => typeof value === "string" && THEME_MODE_NAMES.includes(value as ThemeModeName); + +export const validateThemeDefinition = (candidate: unknown): ThemeValidationResult => { + const errors: string[] = []; + const root = getNestedRecord(candidate, "theme", errors); + + if (!root) { + return { success: false, errors }; + } + + if (root.schemaVersion !== THEME_SCHEMA_VERSION) { + errors.push(`theme.schemaVersion must be ${THEME_SCHEMA_VERSION}.`); + } + + if (!isNonEmptyString(root.id)) { + errors.push("theme.id must be a non-empty string."); + } + + if (!isNonEmptyString(root.name)) { + errors.push("theme.name must be a non-empty string."); + } + + if (root.description !== undefined && !isNonEmptyString(root.description)) { + errors.push("theme.description must be a non-empty string when provided."); + } + + if (root.author !== undefined && !isNonEmptyString(root.author)) { + errors.push("theme.author must be a non-empty string when provided."); + } + + const tokens = getNestedRecord(root.tokens, "theme.tokens", errors); + const shared = tokens ? getNestedRecord(tokens.shared, "theme.tokens.shared", errors) : null; + const modes = tokens ? getNestedRecord(tokens.modes, "theme.tokens.modes", errors) : null; + + if (shared) { + const palette = getNestedRecord(shared.palette, "theme.tokens.shared.palette", errors); + const space = getNestedRecord(shared.space, "theme.tokens.shared.space", errors); + const radius = getNestedRecord(shared.radius, "theme.tokens.shared.radius", errors); + const size = getNestedRecord(shared.size, "theme.tokens.shared.size", errors); + const shadow = getNestedRecord(shared.shadow, "theme.tokens.shared.shadow", errors); + const zIndex = getNestedRecord(shared.zIndex, "theme.tokens.shared.zIndex", errors); + const motion = getNestedRecord(shared.motion, "theme.tokens.shared.motion", errors); + const typography = getNestedRecord(shared.typography, "theme.tokens.shared.typography", errors); + + if (palette) { + for (const [paletteName, keys] of Object.entries(THEME_PALETTE_KEYS)) { + const paletteScale = getNestedRecord(palette[paletteName], `theme.tokens.shared.palette.${paletteName}`, errors); + + if (paletteScale) { + validateRequiredStringMap(paletteScale, `theme.tokens.shared.palette.${paletteName}`, keys, errors); + } + } + } + + if (space) { + validateRequiredStringMap(space, "theme.tokens.shared.space", THEME_SPACE_KEYS, errors); + } + + if (radius) { + validateRequiredStringMap(radius, "theme.tokens.shared.radius", THEME_RADIUS_KEYS, errors); + } + + if (size) { + validateRequiredStringMap(size, "theme.tokens.shared.size", THEME_SIZE_KEYS, errors); + } + + if (shadow) { + validateRequiredStringMap(shadow, "theme.tokens.shared.shadow", THEME_SHADOW_KEYS, errors); + } + + if (zIndex) { + validateRequiredStringMap(zIndex, "theme.tokens.shared.zIndex", THEME_Z_INDEX_KEYS, errors); + } + + if (motion) { + validateRequiredStringMap(motion, "theme.tokens.shared.motion", THEME_MOTION_KEYS, errors); + } + + if (typography) { + const fontFamily = getNestedRecord(typography.fontFamily, "theme.tokens.shared.typography.fontFamily", errors); + const fontSize = getNestedRecord(typography.fontSize, "theme.tokens.shared.typography.fontSize", errors); + const lineHeight = getNestedRecord(typography.lineHeight, "theme.tokens.shared.typography.lineHeight", errors); + const fontWeight = getNestedRecord(typography.fontWeight, "theme.tokens.shared.typography.fontWeight", errors); + const letterSpacing = getNestedRecord(typography.letterSpacing, "theme.tokens.shared.typography.letterSpacing", errors); + + if (fontFamily) { + validateRequiredStringMap(fontFamily, "theme.tokens.shared.typography.fontFamily", THEME_FONT_FAMILY_KEYS, errors); + } + + if (fontSize) { + validateRequiredStringMap(fontSize, "theme.tokens.shared.typography.fontSize", THEME_TYPE_SCALE_KEYS, errors); + } + + if (lineHeight) { + validateRequiredStringMap(lineHeight, "theme.tokens.shared.typography.lineHeight", THEME_TYPE_SCALE_KEYS, errors); + } + + if (fontWeight) { + validateRequiredStringMap(fontWeight, "theme.tokens.shared.typography.fontWeight", THEME_TYPE_SCALE_KEYS, errors); + } + + if (letterSpacing) { + validateRequiredStringMap(letterSpacing, "theme.tokens.shared.typography.letterSpacing", THEME_TYPE_SCALE_KEYS, errors); + } + } + } + + if (modes) { + for (const modeName of THEME_MODE_NAMES) { + const mode = getNestedRecord(modes[modeName], `theme.tokens.modes.${modeName}`, errors); + + if (!mode) { + continue; + } + + if (mode.colorScheme !== modeName) { + errors.push(`theme.tokens.modes.${modeName}.colorScheme must be ${modeName}.`); + } + + const colors = getNestedRecord(mode.colors, `theme.tokens.modes.${modeName}.colors`, errors); + + if (colors) { + validateRequiredStringMap(colors, `theme.tokens.modes.${modeName}.colors`, THEME_MODE_COLOR_KEYS, errors); + } + + if (mode.shadow !== undefined) { + const modeShadow = getNestedRecord(mode.shadow, `theme.tokens.modes.${modeName}.shadow`, errors); + + if (modeShadow) { + for (const key of THEME_SHADOW_KEYS) { + if (modeShadow[key] !== undefined && !isNonEmptyString(modeShadow[key])) { + errors.push(`theme.tokens.modes.${modeName}.shadow.${key} must be a non-empty string token value when provided.`); + } + } + } + } + } + } + + if (errors.length > 0) { + return { success: false, errors }; + } + + return { success: true, data: candidate as ThemeDefinition }; +}; + +export const createCssVariableMap = (theme: ThemeDefinition, mode: ThemeModeName): Record => { + const shared = theme.tokens.shared; + const modeTokens = theme.tokens.modes[mode]; + + return { + "--gray-0": shared.palette.gray["0"], + "--gray-50": shared.palette.gray["50"], + "--gray-100": shared.palette.gray["100"], + "--gray-200": shared.palette.gray["200"], + "--gray-300": shared.palette.gray["300"], + "--gray-400": shared.palette.gray["400"], + "--gray-500": shared.palette.gray["500"], + "--gray-600": shared.palette.gray["600"], + "--gray-700": shared.palette.gray["700"], + "--gray-800": shared.palette.gray["800"], + "--gray-900": shared.palette.gray["900"], + "--blue-400": shared.palette.blue["400"], + "--blue-500": shared.palette.blue["500"], + "--blue-600": shared.palette.blue["600"], + "--green-500": shared.palette.green["500"], + "--red-500": shared.palette.red["500"], + "--amber-500": shared.palette.amber["500"], + "--space-1": shared.space["1"], + "--space-2": shared.space["2"], + "--space-3": shared.space["3"], + "--space-4": shared.space["4"], + "--space-5": shared.space["5"], + "--space-6": shared.space["6"], + "--space-8": shared.space["8"], + "--space-10": shared.space["10"], + "--space-12": shared.space["12"], + "--radius-sm": shared.radius.sm, + "--radius-md": shared.radius.md, + "--radius-lg": shared.radius.lg, + "--radius-xl": shared.radius.xl, + "--radius-pill": shared.radius.pill, + "--control-size-md": shared.size.controlMd, + "--control-size-lg": shared.size.controlLg, + "--content-width-wide": shared.size.contentWidthWide, + "--blur-overlay": shared.size.blurOverlay, + "--shadow-soft": modeTokens.shadow?.soft ?? shared.shadow.soft, + "--shadow-strong": modeTokens.shadow?.strong ?? shared.shadow.strong, + "--z-base": shared.zIndex.base, + "--z-dropdown": shared.zIndex.dropdown, + "--z-sticky": shared.zIndex.sticky, + "--z-overlay": shared.zIndex.overlay, + "--z-modal": shared.zIndex.modal, + "--z-toast": shared.zIndex.toast, + "--motion-duration-fast": shared.motion.durationFast, + "--motion-duration-base": shared.motion.durationBase, + "--motion-duration-slow": shared.motion.durationSlow, + "--motion-ease-standard": shared.motion.easeStandard, + "--font-family-sans": shared.typography.fontFamily.sans, + "--font-family-heading": shared.typography.fontFamily.heading, + "--font-family-display": shared.typography.fontFamily.display, + "--font-family-serif": shared.typography.fontFamily.serif, + "--font-family-mono": shared.typography.fontFamily.mono, + "--font-size-caption": shared.typography.fontSize.caption, + "--font-size-label": shared.typography.fontSize.label, + "--font-size-body": shared.typography.fontSize.body, + "--font-size-title": shared.typography.fontSize.title, + "--font-size-heading": shared.typography.fontSize.heading, + "--font-size-display": shared.typography.fontSize.display, + "--line-height-caption": shared.typography.lineHeight.caption, + "--line-height-label": shared.typography.lineHeight.label, + "--line-height-body": shared.typography.lineHeight.body, + "--line-height-title": shared.typography.lineHeight.title, + "--line-height-heading": shared.typography.lineHeight.heading, + "--line-height-display": shared.typography.lineHeight.display, + "--font-weight-caption": shared.typography.fontWeight.caption, + "--font-weight-label": shared.typography.fontWeight.label, + "--font-weight-body": shared.typography.fontWeight.body, + "--font-weight-title": shared.typography.fontWeight.title, + "--font-weight-heading": shared.typography.fontWeight.heading, + "--font-weight-display": shared.typography.fontWeight.display, + "--letter-spacing-caption": shared.typography.letterSpacing.caption, + "--letter-spacing-label": shared.typography.letterSpacing.label, + "--letter-spacing-body": shared.typography.letterSpacing.body, + "--letter-spacing-title": shared.typography.letterSpacing.title, + "--letter-spacing-heading": shared.typography.letterSpacing.heading, + "--letter-spacing-display": shared.typography.letterSpacing.display, + "--color-canvas": modeTokens.colors.canvas, + "--color-surface": modeTokens.colors.surface, + "--color-surface-muted": modeTokens.colors.surfaceMuted, + "--color-surface-hover": modeTokens.colors.surfaceHover, + "--color-border": modeTokens.colors.border, + "--color-border-strong": modeTokens.colors.borderStrong, + "--color-text": modeTokens.colors.text, + "--color-text-muted": modeTokens.colors.textMuted, + "--color-accent": modeTokens.colors.accent, + "--color-accent-strong": modeTokens.colors.accentStrong, + "--color-accent-soft": modeTokens.colors.accentSoft, + "--color-accent-contrast": modeTokens.colors.accentContrast, + "--color-success": modeTokens.colors.success, + "--color-danger": modeTokens.colors.danger, + "--color-warning": modeTokens.colors.warning, + "--color-focus-ring": modeTokens.colors.focusRing, + }; +};