320 lines
10 KiB
TypeScript
320 lines
10 KiB
TypeScript
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<string, string>
|
|
)[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<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],
|
|
"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;
|
|
}
|