first commit
This commit is contained in:
@@ -0,0 +1,149 @@
|
||||
/**
|
||||
* Theme system — design tokens that work SSR and client-side.
|
||||
*
|
||||
* Tokens are plain CSS custom properties (`--wire-<key>`) so they cascade and
|
||||
* can be overridden by user CSS. Each theme is a flat token map; the framework
|
||||
* ships default `light`/`dark` sets and the user's config deep-merges over them.
|
||||
*
|
||||
* The server renders `<html data-theme="…">` from the `wire-theme` cookie (no
|
||||
* flash), and a tiny client runtime toggles/persists it. The reserved token key
|
||||
* `color-scheme` is emitted as the native CSS property (not a variable) so form
|
||||
* controls and scrollbars match the theme.
|
||||
*/
|
||||
|
||||
export type ThemeTokens = Record<string, string>;
|
||||
|
||||
export interface ThemeConfig {
|
||||
/** Name of the theme used when no `wire-theme` cookie is present. */
|
||||
default?: string;
|
||||
/** Named token maps. Deep-merged over the framework's built-in light/dark. */
|
||||
themes?: Record<string, ThemeTokens>;
|
||||
}
|
||||
|
||||
export interface ResolvedTheme {
|
||||
default: string;
|
||||
names: string[];
|
||||
themes: Record<string, ThemeTokens>;
|
||||
}
|
||||
|
||||
/** Cookie the resolved theme is read from / persisted to. */
|
||||
export const THEME_COOKIE = "wire-theme";
|
||||
export const THEME_CSS_HREF = "/__wrnexus/theme.css";
|
||||
export const THEME_JS_HREF = "/__wrnexus/theme.js";
|
||||
|
||||
/** Built-in themes so components have tokens out of the box. */
|
||||
export const DEFAULT_THEMES: Record<string, ThemeTokens> = {
|
||||
light: {
|
||||
"color-scheme": "light",
|
||||
"color-bg": "#ffffff",
|
||||
"color-surface": "#f6f7fb",
|
||||
"color-surface-2": "#eceff6",
|
||||
"color-text": "#0b1020",
|
||||
"color-muted": "#5a6178",
|
||||
"color-border": "#e2e6f0",
|
||||
"color-primary": "#2563eb",
|
||||
"color-primary-hover": "#1d4ed8",
|
||||
"color-primary-contrast": "#ffffff",
|
||||
"color-danger": "#dc2626",
|
||||
"color-success": "#16a34a",
|
||||
"color-warning": "#d97706",
|
||||
radius: "8px",
|
||||
"radius-sm": "5px",
|
||||
"font-sans": "system-ui, -apple-system, Segoe UI, Roboto, sans-serif",
|
||||
"shadow-1": "0 1px 2px rgba(16,24,40,0.06), 0 1px 3px rgba(16,24,40,0.1)",
|
||||
},
|
||||
dark: {
|
||||
"color-scheme": "dark",
|
||||
"color-bg": "#0b1020",
|
||||
"color-surface": "#141a30",
|
||||
"color-surface-2": "#1c243f",
|
||||
"color-text": "#e7ecff",
|
||||
"color-muted": "#9aa6d0",
|
||||
"color-border": "#ffffff1f",
|
||||
"color-primary": "#6c8cff",
|
||||
"color-primary-hover": "#8aa2ff",
|
||||
"color-primary-contrast": "#0b1020",
|
||||
"color-danger": "#f87171",
|
||||
"color-success": "#4ade80",
|
||||
"color-warning": "#fbbf24",
|
||||
radius: "10px",
|
||||
"radius-sm": "6px",
|
||||
"font-sans": "system-ui, -apple-system, Segoe UI, Roboto, sans-serif",
|
||||
"shadow-1": "0 1px 2px rgba(0,0,0,0.3), 0 4px 16px rgba(0,0,0,0.35)",
|
||||
},
|
||||
};
|
||||
|
||||
/** Merge the user's theme config over the built-in defaults. */
|
||||
export function resolveThemeConfig(config?: ThemeConfig): ResolvedTheme {
|
||||
const themes: Record<string, ThemeTokens> = {};
|
||||
const names = new Set<string>([
|
||||
...Object.keys(DEFAULT_THEMES),
|
||||
...Object.keys(config?.themes ?? {}),
|
||||
]);
|
||||
for (const name of names) {
|
||||
themes[name] = { ...(DEFAULT_THEMES[name] ?? {}), ...(config?.themes?.[name] ?? {}) };
|
||||
}
|
||||
const list = Object.keys(themes);
|
||||
const preferred = config?.default && themes[config.default] ? config.default : undefined;
|
||||
const fallback = themes.dark ? "dark" : list[0]!;
|
||||
return { default: preferred ?? fallback, names: list, themes };
|
||||
}
|
||||
|
||||
/** Pick a valid theme name from a cookie value, falling back to the default. */
|
||||
export function resolveThemeName(cookieValue: string | undefined, theme: ResolvedTheme): string {
|
||||
return cookieValue && theme.themes[cookieValue] ? cookieValue : theme.default;
|
||||
}
|
||||
|
||||
function tokensToDeclarations(tokens: ThemeTokens): string {
|
||||
return Object.entries(tokens)
|
||||
.map(([key, value]) =>
|
||||
key === "color-scheme" ? `color-scheme:${value};` : `--wire-${key}:${value};`,
|
||||
)
|
||||
.join("");
|
||||
}
|
||||
|
||||
/** Generate the theme stylesheet: a `:root` default plus one block per theme. */
|
||||
export function renderThemeCss(theme: ResolvedTheme): string {
|
||||
const blocks: string[] = [];
|
||||
const def = theme.themes[theme.default];
|
||||
if (def) blocks.push(`:root{${tokensToDeclarations(def)}}`);
|
||||
for (const name of theme.names) {
|
||||
blocks.push(`[data-theme="${name}"]{${tokensToDeclarations(theme.themes[name]!)}}`);
|
||||
}
|
||||
return blocks.join("\n") + "\n";
|
||||
}
|
||||
|
||||
/**
|
||||
* Generate the client theme runtime. It exposes `window.wireTheme` and binds
|
||||
* `[data-wire-theme-toggle]` / `[data-wire-theme-set]` elements. The configured
|
||||
* theme names are baked in so `toggle()` cycles through them in order.
|
||||
*/
|
||||
export function renderThemeRuntime(theme: ResolvedTheme): string {
|
||||
const names = JSON.stringify(theme.names);
|
||||
return `(function(){
|
||||
var COOKIE=${JSON.stringify(THEME_COOKIE)};
|
||||
var THEMES=${names};
|
||||
var el=document.documentElement;
|
||||
function get(){return el.getAttribute("data-theme")||${JSON.stringify(theme.default)};}
|
||||
function set(name){
|
||||
if(THEMES.indexOf(name)<0)return;
|
||||
el.setAttribute("data-theme",name);
|
||||
document.cookie=COOKIE+"="+encodeURIComponent(name)+";path=/;max-age=31536000;samesite=lax";
|
||||
}
|
||||
function toggle(){var i=THEMES.indexOf(get());set(THEMES[(i+1)%THEMES.length]);}
|
||||
function bind(root){
|
||||
(root||document).querySelectorAll("[data-wire-theme-toggle]").forEach(function(n){
|
||||
if(n.__wireThemeBound)return;n.__wireThemeBound=1;
|
||||
n.addEventListener("click",function(){toggle();});
|
||||
});
|
||||
(root||document).querySelectorAll("[data-wire-theme-set]").forEach(function(n){
|
||||
if(n.__wireThemeBound)return;n.__wireThemeBound=1;
|
||||
n.addEventListener("click",function(){set(n.getAttribute("data-wire-theme-set"));});
|
||||
});
|
||||
}
|
||||
window.wireTheme={get:get,set:set,toggle:toggle,bind:bind,themes:THEMES};
|
||||
if(document.readyState==="loading")document.addEventListener("DOMContentLoaded",function(){bind(document);});
|
||||
else bind(document);
|
||||
})();
|
||||
`;
|
||||
}
|
||||
Reference in New Issue
Block a user