feat(studio): add offline manual theme designer
This commit is contained in:
1 parent
5259668006
commit
ae35ac6714
7 files changed
+570
-8
No files matched your search
@@ -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;
|
||||
}
|
||||
Reference in new issue
Block a user