feat(studio): add offline manual theme designer
Quality / quality (ubuntu-latest) (push) Failing after 6m40s
Quality / quality (windows-latest) (push) Canceled after 0s

This commit is contained in:
Clintchiz committed 2026-10-19 20:58:12 +05:30
1 parent 5259668006
commit ae35ac6714
7 files changed
+570 -8

No files matched your search

+182
View File
@@ -0,0 +1,182 @@
import { 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',
humanist: '"Trebuchet MS", Arial, sans-serif',
serif: 'Georgia, "Times New Roman", serif',
mono: '"Courier New", monospace',
};
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<string, DesignToken>,
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<string, string[]>
)[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<string, [DesignToken["type"], string | undefined]> = {
"shape.control": ["dimension", "radius-sm"],
"shape.panel": ["dimension", "radius-md"],
"shape.dialog": ["dimension", "radius-lg"],
"type.body": ["fontFamily", "font-sans"],
"type.heading": ["fontFamily", 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" &&
(!/^\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.");
set(system.tokens, edit.name, edit.value, type, legacy);
// 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;
}