Merge branch 'Features/Frontend/Adoptive-Theme'
This commit is contained in:
@@ -66,6 +66,14 @@
|
|||||||
- [ ] Table
|
- [ ] Table
|
||||||
- [ ] CVA
|
- [ ] CVA
|
||||||
- [ ] Storyboard
|
- [ ] 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
|
### Version 0.3.0
|
||||||
|
|
||||||
|
|||||||
170
Frontend/public/themes/moku-default.json
Normal file
170
Frontend/public/themes/moku-default.json
Normal file
@@ -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)"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,7 +1,7 @@
|
|||||||
// Path: Frontend/src/components/shell/AppShell/AppShell.tsx
|
// Path: Frontend/src/components/shell/AppShell/AppShell.tsx
|
||||||
|
|
||||||
import { createSignal, onMount, type JSX } from "solid-js";
|
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 { WorkspaceHome } from "../../workspace-home/WorkspaceHome/WorkspaceHome";
|
||||||
import { LeftRail } from "../LeftRail/LeftRail";
|
import { LeftRail } from "../LeftRail/LeftRail";
|
||||||
import { ProfileDock } from "../ProfileDock/ProfileDock";
|
import { ProfileDock } from "../ProfileDock/ProfileDock";
|
||||||
|
|||||||
@@ -1,8 +1,8 @@
|
|||||||
// Path: Frontend/src/components/shell/TopBar/TopBar.tsx
|
// Path: Frontend/src/components/shell/TopBar/TopBar.tsx
|
||||||
|
|
||||||
import { For, type JSX } from "solid-js";
|
import { For, type JSX } from "solid-js";
|
||||||
import type { Theme } from "../../../helpers/theme";
|
|
||||||
import { ChevronDown } from "../../../lib/icons";
|
import { ChevronDown } from "../../../lib/icons";
|
||||||
|
import type { Theme } from "../../../theme/runtime";
|
||||||
import { topBarActions } from "../data/shell.data";
|
import { topBarActions } from "../data/shell.data";
|
||||||
import styles from "./TopBar.module.scss";
|
import styles from "./TopBar.module.scss";
|
||||||
|
|
||||||
|
|||||||
@@ -1,8 +1,9 @@
|
|||||||
// Path: Frontend/src/entry-client.tsx
|
// Path: Frontend/src/entry-client.tsx
|
||||||
|
|
||||||
// @refresh reload
|
// @refresh reload
|
||||||
import type { JSX } from "solid-js";
|
|
||||||
import { mount, StartClient } from "@solidjs/start/client";
|
import { mount, StartClient } from "@solidjs/start/client";
|
||||||
|
import type { JSX } from "solid-js";
|
||||||
|
import { initializeThemeRuntime } from "./theme/runtime";
|
||||||
|
|
||||||
const getAppRoot = (): HTMLElement => {
|
const getAppRoot = (): HTMLElement => {
|
||||||
const appRoot = document.getElementById("app");
|
const appRoot = document.getElementById("app");
|
||||||
@@ -18,4 +19,6 @@ const mountApp = (): void => {
|
|||||||
mount((): JSX.Element => <StartClient />, getAppRoot());
|
mount((): JSX.Element => <StartClient />, getAppRoot());
|
||||||
};
|
};
|
||||||
|
|
||||||
|
void initializeThemeRuntime();
|
||||||
|
|
||||||
mountApp();
|
mountApp();
|
||||||
|
|||||||
@@ -3,19 +3,21 @@
|
|||||||
// @refresh reload
|
// @refresh reload
|
||||||
import type { JSX } from "solid-js";
|
import type { JSX } from "solid-js";
|
||||||
import { createHandler, StartServer } from "@solidjs/start/server";
|
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 = `
|
const themeBootstrapScript = `
|
||||||
(() => {
|
(() => {
|
||||||
try {
|
try {
|
||||||
const storageKey = "theme";
|
const storageKey = ${JSON.stringify(THEME_STORAGE_KEY)};
|
||||||
const stored = localStorage.getItem(storageKey);
|
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
|
? 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);
|
document.documentElement.setAttribute("data-theme", theme);
|
||||||
} catch {
|
} 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 => {
|
const renderDocument = ({ assets, children, scripts }: DocumentRenderProps): JSX.Element => {
|
||||||
return (
|
return (
|
||||||
<html lang="en" data-theme="light">
|
<html lang="en" data-theme={DEFAULT_THEME}>
|
||||||
<head>
|
<head>
|
||||||
<meta charset="utf-8" />
|
<meta charset="utf-8" />
|
||||||
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
||||||
|
|||||||
@@ -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);
|
|
||||||
};
|
|
||||||
11
Frontend/src/theme/presets.ts
Normal file
11
Frontend/src/theme/presets.ts
Normal file
@@ -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<ThemeDefinition, "id" | "name" | "description">;
|
||||||
129
Frontend/src/theme/runtime.ts
Normal file
129
Frontend/src/theme/runtime.ts
Normal file
@@ -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<ThemeDefinition | null> | 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<ThemeDefinition | null> => {
|
||||||
|
if (typeof window === "undefined") {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (activeThemeDefinition) {
|
||||||
|
applyThemeDefinition(activeThemeDefinition, getDocumentTheme());
|
||||||
|
return activeThemeDefinition;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!themeInitializationPromise) {
|
||||||
|
themeInitializationPromise = (async (): Promise<ThemeDefinition | null> => {
|
||||||
|
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);
|
||||||
|
}
|
||||||
|
};
|
||||||
342
Frontend/src/theme/schema.ts
Normal file
342
Frontend/src/theme/schema.ts
Normal file
@@ -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<TKey extends string> = Record<TKey, string>;
|
||||||
|
|
||||||
|
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<ThemeTokenMap<(typeof THEME_SHADOW_KEYS)[number]>>;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type ThemeDefinition = {
|
||||||
|
schemaVersion: typeof THEME_SCHEMA_VERSION;
|
||||||
|
id: string;
|
||||||
|
name: string;
|
||||||
|
description?: string;
|
||||||
|
author?: string;
|
||||||
|
tokens: {
|
||||||
|
shared: ThemeSharedTokens;
|
||||||
|
modes: Record<ThemeModeName, ThemeModeTokens>;
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
|
export type ThemeValidationResult =
|
||||||
|
| {
|
||||||
|
success: true;
|
||||||
|
data: ThemeDefinition;
|
||||||
|
}
|
||||||
|
| {
|
||||||
|
success: false;
|
||||||
|
errors: string[];
|
||||||
|
};
|
||||||
|
|
||||||
|
const isRecord = (value: unknown): value is Record<string, unknown> => 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<string, unknown> | null => {
|
||||||
|
if (!isRecord(value)) {
|
||||||
|
errors.push(`${path} must be an object.`);
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
return value;
|
||||||
|
};
|
||||||
|
|
||||||
|
const validateRequiredStringMap = (record: Record<string, unknown>, 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<string, string> => {
|
||||||
|
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,
|
||||||
|
};
|
||||||
|
};
|
||||||
Reference in New Issue
Block a user