import { COMPONENT_CONTRACTS, validateDesignSystem, type DesignSystem, type DesignToken, } from "@wrnexus/design-system"; export const MANUAL_COLORS = { primary: "Brand / primary", secondary: "Secondary", bg: "Page background", surface: "Surface", "surface-2": "Subtle surface", text: "Text", "text-muted": "Muted text", border: "Borders", success: "Success", warning: "Warning", danger: "Error", info: "Information", } as const; export const MANUAL_FONTS = { sans: '"Helvetica Neue", Arial, sans-serif', system: "system-ui, sans-serif", arial: "Arial, Helvetica, sans-serif", verdana: "Verdana, Geneva, sans-serif", tahoma: "Tahoma, sans-serif", georgia: "Georgia, serif", times: '"Times New Roman", Times, serif', palatino: '"Palatino Linotype", Palatino, serif', consolas: 'Consolas, "Courier New", monospace', humanist: '"Trebuchet MS", Arial, sans-serif', serif: 'Georgia, "Times New Roman", serif', mono: '"Courier New", monospace', }; export const MANUAL_TYPOGRAPHY = { "type.body-size": { label: "Body size (px)", type: "dimension", min: 10, max: 32, initial: 16, unit: "px", }, "type.heading-size": { label: "Heading size (px)", type: "dimension", min: 16, max: 96, initial: 28, unit: "px", }, "type.code-size": { label: "Code size (px)", type: "dimension", min: 10, max: 32, initial: 14, unit: "px", }, "type.body-weight": { label: "Body weight", type: "fontWeight", min: 100, max: 900, initial: 400, unit: "", }, "type.heading-weight": { label: "Heading weight", type: "fontWeight", min: 100, max: 900, initial: 700, unit: "", }, "type.body-leading": { label: "Body line height", type: "number", min: 1, max: 2.5, initial: 1.5, unit: "", }, "type.heading-leading": { label: "Heading line height", type: "number", min: 1, max: 2, initial: 1.2, unit: "", }, "type.body-tracking": { label: "Body letter spacing (px)", type: "dimension", min: -2, max: 8, initial: 0, unit: "px", }, "type.heading-tracking": { label: "Heading letter spacing (px)", type: "dimension", min: -4, max: 12, initial: 0, unit: "px", }, } as const; function typographyRecipes(system: DesignSystem, token: string) { const suffix = token.replace(/^type\./, ""); const group = suffix.split("-")[0]!; const property = ( { size: "fontSize", weight: "fontWeight", leading: "lineHeight", tracking: "letterSpacing", } as Record )[suffix.split("-")[1]!] ?? "fontFamily"; for (const [name, contract] of Object.entries(COMPONENT_CONTRACTS)) { for (const [part, descriptor] of Object.entries(contract.parts)) { const properties: readonly string[] = descriptor.properties; if (!properties.includes(property)) continue; const code = /code|kbd/i.test(name) || /code/i.test(part); const heading = /title|heading|headline/i.test(part); if ((code ? "code" : heading ? "heading" : "body") !== group) continue; // Keep icon geometry and managed/provider content independent of text settings. if ( /icon|spinner|thumb|track|srOnly/i.test(part) || ("ownership" in descriptor && descriptor.ownership !== "framework") ) continue; const recipe = ((system.recipes ??= {})[name] ??= { contractVersion: 1 }); ((recipe.base ??= {})[part] ??= {})[property] = { ref: token }; } } } export type ManualEdit = | { kind: "color"; mode: string; name: string; value: string } | { kind: "token"; name: string; value: string } | { kind: "mode"; value: string } | { kind: "buttonStyle"; value: "solid" | "outline" } | { kind: "tilt"; value: "none" | "subtle" | "expressive" }; function ink(hex: string) { const channels = hex .slice(1) .match(/../g)! .map((s) => parseInt(s, 16) / 255) .map((c) => (c <= 0.04045 ? c / 12.92 : ((c + 0.055) / 1.055) ** 2.4)); const luminance = channels.reduce((sum, c, i) => sum + c * [0.2126, 0.7152, 0.0722][i]!, 0); return (luminance + 0.05) / 0.05 >= 1.05 / (luminance + 0.05) ? "#000000" : "#FFFFFF"; } export function manualDesign(source: DesignSystem, edit: ManualEdit): DesignSystem { const system = structuredClone(source); const set = ( tokens: Record, name: string, value: string, type: DesignToken["type"] = "color", legacy?: string, ) => { tokens[name] = { ...tokens[name], type, value, ...(legacy ? { legacy: tokens[name]?.legacy ?? legacy } : {}), }; }; if (edit.kind === "buttonStyle") { const recipe = ((system.recipes ??= {}).Button ??= { contractVersion: 1, base: {} }); const variant = { when: { variant: "default" }, parts: { control: { backgroundImage: "none", backgroundColor: edit.value === "outline" ? "transparent" : { ref: "color.primary" }, color: { ref: edit.value === "outline" ? "color.primary" : "color.primary-contrast" }, borderColor: { ref: "color.primary" }, }, }, }; recipe.variants = [ ...(recipe.variants ?? []).filter( (rule) => !(Object.keys(rule.when).length === 1 && rule.when.variant === "default"), ), variant, ]; } else if (edit.kind === "tilt") { for (const [name, part, angle] of [ [ "Button", "control", edit.value === "none" ? "0deg" : edit.value === "subtle" ? "-0.5deg" : "-2deg", ], [ "Card", "panel", edit.value === "none" ? "0deg" : edit.value === "subtle" ? "0.25deg" : "1deg", ], ]) { const recipe = ((system.recipes ??= {})[name!] ??= { contractVersion: 1, base: {} }); ((recipe.base ??= {})[part!] ??= {}).rotate = angle!; } } else if (edit.kind === "mode") { if (!Object.hasOwn(system.modes ?? {}, edit.value)) throw Error("Choose an existing light or dark mode."); system.defaultMode = edit.value; } else if (edit.kind === "color") { if ( (!Object.hasOwn(MANUAL_COLORS, edit.name) && system.tokens["color." + edit.name]?.type !== "color" && system.modes?.[edit.mode]?.["color." + edit.name]?.type !== "color") || !/^#[a-f0-9]{6}$/i.test(edit.value) ) throw Error("Choose a color or enter a six-digit hex value, such as #00677B."); const layer = edit.mode === "base" ? system.tokens : system.modes?.[edit.mode]; if (!layer) throw Error("Choose an existing light or dark mode."); const color = (name: string, value = edit.value) => set( layer, "color." + name, value, "color", layer["color." + name]?.legacy ?? system.tokens["color." + name]?.legacy ?? "color-" + name, ); color(edit.name); for (const alias of ( { bg: ["background"], surface: ["surface-raised", "input-background"], "surface-2": [ "surface-soft", "surface-muted", "primary-soft", "primary-muted", "code-background", ], text: ["foreground", "code-text"], "text-muted": ["text-subtle", "muted"], border: ["border-strong", "input-border", "code-border"], } as Record )[edit.name] ?? []) color(alias); if (["primary", "secondary", "success", "warning", "danger", "info"].includes(edit.name)) { color(edit.name + "-contrast", ink(edit.value)); color(edit.name + "-soft", edit.value + "1A"); if (edit.name === "primary") color("primary-muted", edit.value + "1A"); color(edit.name + "-text"); for (const [suffix, factor] of [ ["-hover", 0.88], ["-active", 0.75], ] as const) color( edit.name + suffix, "#" + edit.value .slice(1) .match(/../g)! .map((c) => Math.round(parseInt(c, 16) * factor) .toString(16) .padStart(2, "0"), ) .join(""), ); if (edit.name === "primary") color("focus"); } } else { const allowed: Record = { "shape.control": ["dimension", "radius-sm"], "shape.panel": ["dimension", "radius-md"], "shape.dialog": ["dimension", "radius-lg"], "type.body": ["fontFamily", "font-sans"], "type.heading": ["fontFamily", undefined], "type.code": ["fontFamily", "font-mono"], ...Object.fromEntries( Object.entries(MANUAL_TYPOGRAPHY).map(([name, setting]) => [ name, [setting.type, undefined], ]), ), "elevation.panel": ["shadow", "shadow-1"], "space.control": ["dimension", undefined], "space.panel": ["dimension", "space-panel"], }; if (!allowed[edit.name]) throw Error("Unsupported manual design setting."); const [type, legacy] = allowed[edit.name]!; if ( type === "dimension" && !Object.hasOwn(MANUAL_TYPOGRAPHY, edit.name) && (!/^\d+(?:\.\d+)?px$/.test(edit.value) || parseFloat(edit.value) > (edit.name.startsWith("shape.") ? 999 : 64)) ) throw Error("Enter corners between 0 and 999 pixels, or padding between 0 and 64 pixels."); const typography = MANUAL_TYPOGRAPHY[edit.name as keyof typeof MANUAL_TYPOGRAPHY]; if (typography) { const numeric = Number(typography.unit ? edit.value.replace(/px$/, "") : edit.value); if ( !Number.isFinite(numeric) || numeric < typography.min || numeric > typography.max || (typography.unit && !/^-?\d+(?:\.\d+)?px$/.test(edit.value)) ) throw Error(`${typography.label} must be between ${typography.min} and ${typography.max}.`); } set(system.tokens, edit.name, edit.value, type, legacy); if (edit.name.startsWith("type.")) typographyRecipes(system, edit.name); // Existing mode overrides must not silently win over a shared foundation edit. for (const layer of Object.values(system.modes ?? {})) if (layer[edit.name]) set(layer, edit.name, edit.value, type, legacy); if (edit.name === "shape.panel") set(system.tokens, "shape.base", edit.value, "dimension", "radius"); } const errors = validateDesignSystem(system); if (errors.length) throw Error(errors.map((e) => e.message).join("\n")); return system; }