feat: release design system engine, studio and component contracts
This commit is contained in:
1 parent
ff1065fb27
commit
3943b946d6
693 files changed
+175546
-3894
No files matched your search
File diff suppressed because it is too large.
Load diff
@@ -0,0 +1,45 @@
|
||||
import { CATALOG_COMPONENT_CONTRACTS } from "./catalog-contracts.ts";
|
||||
import type { ComponentRecipe, RecipeStyles } from "./types.ts";
|
||||
|
||||
/** Family defaults change styling only: no display, dimensions, positioning, clipping or graph geometry. */
|
||||
export function catalogRecipes(): Record<string, ComponentRecipe> {
|
||||
const recipes: Record<string, ComponentRecipe> = {};
|
||||
const ref = (name: string) => ({ ref: name });
|
||||
for (const [name, contract] of Object.entries(CATALOG_COMPONENT_CONTRACTS)) {
|
||||
const base: Record<string, RecipeStyles> = {};
|
||||
const axes: Record<string, { default: string }> = contract.axes;
|
||||
for (const [part, descriptor] of Object.entries(contract.parts)) {
|
||||
if (descriptor.ownership !== "framework" || descriptor.profile === "managed") continue;
|
||||
const styles: RecipeStyles = {};
|
||||
if (
|
||||
descriptor.properties.includes("fontFamily") &&
|
||||
descriptor.role !== "code" &&
|
||||
name !== "Kbd"
|
||||
)
|
||||
styles.fontFamily = ref(
|
||||
/^title|heading|headline/i.test(part) ? "type.heading" : "type.body",
|
||||
);
|
||||
const roundedFalse = axes.rounded?.default === "false";
|
||||
const circle = ["circle", "round"].includes(axes.shape?.default ?? "");
|
||||
const semanticGeometry =
|
||||
/thumb|track|spinner|progress|marker|port$|status|dot|star|srOnly/i.test(part);
|
||||
if (
|
||||
descriptor.properties.includes("borderRadius") &&
|
||||
!roundedFalse &&
|
||||
!circle &&
|
||||
!semanticGeometry
|
||||
) {
|
||||
styles.borderRadius = ref(
|
||||
descriptor.profile === "control" ? "shape.control" : "shape.panel",
|
||||
);
|
||||
}
|
||||
// Only existing surfaces receive elevation. Layout wrappers and provider hosts are untouched.
|
||||
if (descriptor.profile === "surface" && descriptor.properties.includes("boxShadow"))
|
||||
styles.boxShadow = ref("elevation.panel");
|
||||
if (Object.keys(styles).length) base[part] = styles;
|
||||
}
|
||||
// Every catalog component has a deliberate recipe, even if only its text wrapper is styled.
|
||||
recipes[name] = { contractVersion: 1, base };
|
||||
}
|
||||
return recipes;
|
||||
}
|
||||
@@ -0,0 +1,92 @@
|
||||
import type { DesignSystem, LegacyTheme, ResolveOptions, ResolvedDesignSystem } from "./types.ts";
|
||||
import { resolveDesignSystem, fail } from "./resolve.ts";
|
||||
import { validName } from "./validation.ts";
|
||||
import { compileComponentRecipes } from "./recipes.ts";
|
||||
|
||||
function declarations(system: ResolvedDesignSystem, legacyOnly = false): Record<string, string> {
|
||||
const result: Record<string, string> = {};
|
||||
const owners = new Map<string, string>();
|
||||
for (const [name, token] of Object.entries(system.tokens)) {
|
||||
const keys = legacyOnly
|
||||
? token.legacy
|
||||
? [token.legacy]
|
||||
: []
|
||||
: [
|
||||
`--wrn-ds-${name.replace(/\./g, "-")}`,
|
||||
...(token.legacy
|
||||
? [token.legacy === "color-scheme" ? "color-scheme" : `--wrn-${token.legacy}`]
|
||||
: []),
|
||||
];
|
||||
for (const key of keys) {
|
||||
const previous = owners.get(key);
|
||||
if (previous && previous !== name)
|
||||
fail("WRN-DS-COLLISION", name, `Output '${key}' is already bound to '${previous}'.`);
|
||||
owners.set(key, name);
|
||||
result[key] = String(token.value);
|
||||
}
|
||||
}
|
||||
return Object.fromEntries(
|
||||
Object.entries(result).sort(([a], [b]) => (a < b ? -1 : a > b ? 1 : 0)),
|
||||
);
|
||||
}
|
||||
|
||||
/** Compile deterministic tokens and versioned core-component recipes. */
|
||||
export function compileDesignSystem(
|
||||
system: DesignSystem,
|
||||
options: ResolveOptions & { scope?: string } = {},
|
||||
): { css: string; recipeCss: string; resolved: ResolvedDesignSystem } {
|
||||
if (options.scope !== undefined && !validName(options.scope))
|
||||
fail("WRN-DS-NAME", "scope", "Invalid scope identifier.");
|
||||
const resolved = resolveDesignSystem(system, options);
|
||||
const selector = options.scope ? `[data-wrn-design="${options.scope}"]` : ":root";
|
||||
const block = (target: string, value: ResolvedDesignSystem) =>
|
||||
`${target}{${Object.entries(declarations(value))
|
||||
.map(([key, val]) => `${key}:${val};`)
|
||||
.join("")}}`;
|
||||
const blocks = [block(selector, resolved)];
|
||||
const defaultRecipes = compileComponentRecipes(resolved, selector, options.scope);
|
||||
const recipeBlocks = [defaultRecipes];
|
||||
for (const mode of resolved.modes) {
|
||||
blocks.push(
|
||||
block(`${selector}[data-theme="${mode}"]`, resolveDesignSystem(system, { ...options, mode })),
|
||||
);
|
||||
const modeResolved = resolveDesignSystem(system, { ...options, mode });
|
||||
// Legacy-bound references already follow mode variables. Avoid repeating identical rules.
|
||||
const normalized = compileComponentRecipes(modeResolved, selector, options.scope);
|
||||
if (normalized !== defaultRecipes)
|
||||
recipeBlocks.push(
|
||||
compileComponentRecipes(modeResolved, `${selector}[data-theme="${mode}"]`, options.scope),
|
||||
);
|
||||
}
|
||||
const recipeCss = recipeBlocks.filter(Boolean).join("\n");
|
||||
return {
|
||||
css: blocks.join("\n") + "\n" + (recipeCss ? recipeCss + "\n" : ""),
|
||||
recipeCss,
|
||||
resolved,
|
||||
};
|
||||
}
|
||||
|
||||
/** Return existing ThemeConfig-compatible data, retaining explicit app overrides. */
|
||||
export function adaptLegacyTheme<T extends LegacyTheme>(
|
||||
system: DesignSystem,
|
||||
legacy: T,
|
||||
options: ResolveOptions = {},
|
||||
): T & LegacyTheme {
|
||||
const resolved = resolveDesignSystem(system, options);
|
||||
const base = declarations(resolved, true);
|
||||
const names = [
|
||||
...new Set(["light", "dark", ...resolved.modes, ...Object.keys(legacy.themes ?? {})]),
|
||||
].sort();
|
||||
const themes: Record<string, Record<string, string>> = {};
|
||||
for (const name of names) {
|
||||
const mapped = resolved.modes.includes(name)
|
||||
? declarations(resolveDesignSystem(system, { ...options, mode: name }), true)
|
||||
: base;
|
||||
themes[name] = { ...mapped, ...(legacy.themes?.[name] ?? {}) };
|
||||
}
|
||||
return {
|
||||
...legacy,
|
||||
...(legacy.default ? {} : resolved.mode ? { default: resolved.mode } : {}),
|
||||
themes,
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,36 @@
|
||||
/** Shared immutable property profiles keep catalog metadata small and explicit. */
|
||||
export const CATALOG_PART_PROPERTIES = {
|
||||
text: ["color", "fontFamily", "fontSize", "fontWeight", "letterSpacing"],
|
||||
layout: ["color", "fontFamily", "gap", "paddingBlock", "paddingInline"],
|
||||
surface: [
|
||||
"backgroundColor",
|
||||
"borderColor",
|
||||
"borderRadius",
|
||||
"borderStyle",
|
||||
"borderWidth",
|
||||
"boxShadow",
|
||||
"color",
|
||||
"fontFamily",
|
||||
"outlineColor",
|
||||
],
|
||||
control: [
|
||||
"backgroundColor",
|
||||
"backgroundImage",
|
||||
"borderColor",
|
||||
"borderRadius",
|
||||
"borderStyle",
|
||||
"borderWidth",
|
||||
"boxShadow",
|
||||
"color",
|
||||
"fontFamily",
|
||||
"fontSize",
|
||||
"fontWeight",
|
||||
"letterSpacing",
|
||||
"outlineColor",
|
||||
"paddingBlock",
|
||||
"paddingInline",
|
||||
],
|
||||
decoration: ["color", "opacity"],
|
||||
media: ["borderColor", "borderRadius", "borderStyle", "borderWidth", "boxShadow"],
|
||||
managed: [],
|
||||
} as const;
|
||||
@@ -0,0 +1,9 @@
|
||||
import { CORE_COMPONENT_CONTRACTS } from "./core-contracts.ts";
|
||||
import { CATALOG_COMPONENT_CONTRACTS } from "./catalog-contracts.ts";
|
||||
|
||||
/** Entire first-party catalog. CORE_COMPONENT_CONTRACTS remains the original stable subset. */
|
||||
export const COMPONENT_CONTRACTS = {
|
||||
...CORE_COMPONENT_CONTRACTS,
|
||||
...CATALOG_COMPONENT_CONTRACTS,
|
||||
} as const;
|
||||
export { CATALOG_COMPONENT_CONTRACTS } from "./catalog-contracts.ts";
|
||||
File diff suppressed because it is too large.
Load diff
@@ -0,0 +1,41 @@
|
||||
export type {
|
||||
TokenType,
|
||||
TokenReference,
|
||||
DesignToken,
|
||||
TokenMap,
|
||||
DesignSystem,
|
||||
DesignDiagnostic,
|
||||
ResolveOptions,
|
||||
DesignSystemInput,
|
||||
ResolvedToken,
|
||||
ResolvedDesignSystem,
|
||||
LegacyTheme,
|
||||
} from "./types.ts";
|
||||
export {
|
||||
DESIGN_SYSTEM_SCHEMA_VERSION,
|
||||
TOKEN_TYPES,
|
||||
DesignSystemError,
|
||||
assertDesignSystem,
|
||||
defineDesignSystem,
|
||||
} from "./validation.ts";
|
||||
export { validateDesignSystem } from "./validate.ts";
|
||||
export { resolveDesignSystem } from "./resolve.ts";
|
||||
export { compileDesignSystem, adaptLegacyTheme } from "./compile.ts";
|
||||
export { CORE_COMPONENT_CONTRACTS } from "./core-contracts.ts";
|
||||
export { COMPONENT_CONTRACTS, CATALOG_COMPONENT_CONTRACTS } from "./contracts.ts";
|
||||
export { componentDesignContract } from "./recipes.ts";
|
||||
export type { ComponentDesignContract } from "./recipes.ts";
|
||||
export type {
|
||||
RecipeValue,
|
||||
RecipeStyles,
|
||||
RecipeParts,
|
||||
RecipeRule,
|
||||
RecipeStateRule,
|
||||
ComponentRecipe,
|
||||
} from "./types.ts";
|
||||
export {
|
||||
CORE_DESIGN_SYSTEMS,
|
||||
SQUARE_DESIGN_SYSTEM,
|
||||
SOFT_DESIGN_SYSTEM,
|
||||
ANGULAR_DESIGN_SYSTEM,
|
||||
} from "./presets.ts";
|
||||
@@ -0,0 +1,96 @@
|
||||
import { validateDesignSystem } from "./validate.ts";
|
||||
import type { DesignSystem } from "./types.ts";
|
||||
export const DESIGN_PACKAGE_VERSION = 1;
|
||||
export interface DesignPackage {
|
||||
formatVersion: 1;
|
||||
contractVersion: 1;
|
||||
system: DesignSystem;
|
||||
notes?: string;
|
||||
integrity: { algorithm: "SHA-256"; digest: string };
|
||||
}
|
||||
function stable(value: unknown): string {
|
||||
if (Array.isArray(value)) return "[" + value.map(stable).join(",") + "]";
|
||||
if (value && typeof value === "object")
|
||||
return (
|
||||
"{" +
|
||||
Object.keys(value)
|
||||
.filter((key) => (value as Record<string, unknown>)[key] !== undefined)
|
||||
.sort()
|
||||
.map((key) => JSON.stringify(key) + ":" + stable((value as Record<string, unknown>)[key]))
|
||||
.join(",") +
|
||||
"}"
|
||||
);
|
||||
return JSON.stringify(value);
|
||||
}
|
||||
async function digest(body: unknown) {
|
||||
return [
|
||||
...new Uint8Array(
|
||||
await crypto.subtle.digest("SHA-256", new TextEncoder().encode(stable(body))),
|
||||
),
|
||||
]
|
||||
.map((n) => n.toString(16).padStart(2, "0"))
|
||||
.join("");
|
||||
}
|
||||
function check(system: unknown): asserts system is DesignSystem {
|
||||
const errors = validateDesignSystem(system);
|
||||
if (errors.length)
|
||||
throw Error(
|
||||
"WRN-DESIGN-PACKAGE: " + errors.map((error) => error.path + ": " + error.message).join("; "),
|
||||
);
|
||||
if ((system as DesignSystem).extends?.length)
|
||||
throw Error(
|
||||
"WRN-DESIGN-PACKAGE: shared packages must be self-contained; flatten inheritance before packaging.",
|
||||
);
|
||||
if (!/^\d+\.\d+\.\d+(?:-[\w.-]+)?$/.test((system as DesignSystem).version))
|
||||
throw Error("WRN-DESIGN-PACKAGE: release version must be a semantic version.");
|
||||
}
|
||||
/** Portable self-contained data. The digest detects corruption; it is not a publisher signature. */
|
||||
export async function createDesignPackage(
|
||||
system: DesignSystem,
|
||||
notes?: string,
|
||||
): Promise<DesignPackage> {
|
||||
check(system);
|
||||
if (notes !== undefined && (typeof notes !== "string" || notes.length > 2000))
|
||||
throw Error("WRN-DESIGN-PACKAGE: release notes exceed 2,000 characters.");
|
||||
const body = {
|
||||
formatVersion: 1 as const,
|
||||
contractVersion: 1 as const,
|
||||
system: structuredClone(system),
|
||||
...(notes === undefined ? {} : { notes }),
|
||||
};
|
||||
if (JSON.stringify(body).length > 2_000_000)
|
||||
throw Error("WRN-DESIGN-PACKAGE: package exceeds 2 MB.");
|
||||
return { ...body, integrity: { algorithm: "SHA-256", digest: await digest(body) } };
|
||||
}
|
||||
/** Explicit migration: schema-v1 canonical JSON becomes a verified package; future formats fail closed. */
|
||||
export async function readDesignPackage(
|
||||
source: string,
|
||||
): Promise<{ package: DesignPackage; migrations: string[] }> {
|
||||
if (typeof source !== "string" || source.length > 2_000_000)
|
||||
throw Error("WRN-DESIGN-PACKAGE: source exceeds 2 MB.");
|
||||
const input = JSON.parse(source);
|
||||
if (input?.formatVersion === undefined && input?.schemaVersion === 1)
|
||||
return {
|
||||
package: await createDesignPackage(input),
|
||||
migrations: ["canonical-v1-to-package-v1"],
|
||||
};
|
||||
if (input?.formatVersion !== 1 || input?.contractVersion !== 1)
|
||||
throw Error(
|
||||
"WRN-DESIGN-PACKAGE-VERSION: unsupported format or contract version; keep the original source.",
|
||||
);
|
||||
if (
|
||||
Object.keys(input).some(
|
||||
(key) => !["formatVersion", "contractVersion", "system", "notes", "integrity"].includes(key),
|
||||
)
|
||||
)
|
||||
throw Error("WRN-DESIGN-PACKAGE: unknown envelope fields.");
|
||||
const expected = await createDesignPackage(input.system, input.notes);
|
||||
if (
|
||||
input.integrity?.algorithm !== "SHA-256" ||
|
||||
input.integrity?.digest !== expected.integrity.digest
|
||||
)
|
||||
throw Error(
|
||||
"WRN-DESIGN-PACKAGE-INTEGRITY: package content changed; obtain the reviewed original.",
|
||||
);
|
||||
return { package: expected, migrations: [] };
|
||||
}
|
||||
@@ -0,0 +1,255 @@
|
||||
import type { ComponentRecipe, DesignSystem, DesignToken, RecipeParts, TokenMap } from "./types.ts";
|
||||
import { catalogRecipes } from "./catalog-recipes.ts";
|
||||
|
||||
const ref = (name: string) => ({ ref: name });
|
||||
const token = (
|
||||
type: DesignToken["type"],
|
||||
value: DesignToken["value"],
|
||||
legacy?: string,
|
||||
): DesignToken => ({ type, value, ...(legacy ? { legacy } : {}) });
|
||||
function colors(values: Record<string, string>): TokenMap {
|
||||
const expanded = {
|
||||
...values,
|
||||
"surface-raised": values.surface!,
|
||||
"surface-muted": values["surface-2"]!,
|
||||
"surface-soft": values["surface-2"]!,
|
||||
"text-subtle": values["text-muted"]!,
|
||||
"border-strong": values.border!,
|
||||
"primary-muted": values["primary-soft"]!,
|
||||
};
|
||||
return Object.fromEntries(
|
||||
Object.entries(expanded).map(([key, value]) => [
|
||||
`color.${key}`,
|
||||
token("color", value, `color-${key}`),
|
||||
]),
|
||||
);
|
||||
}
|
||||
function recipes(angular: boolean, outlined: boolean): Record<string, ComponentRecipe> {
|
||||
const recipe = (base: RecipeParts): ComponentRecipe => ({ contractVersion: 1, base });
|
||||
const surface = {
|
||||
borderRadius: ref("shape.panel"),
|
||||
borderWidth: "1px",
|
||||
borderStyle: "solid",
|
||||
borderColor: ref("color.border"),
|
||||
boxShadow: ref("elevation.panel"),
|
||||
};
|
||||
const field = {
|
||||
borderRadius: ref("shape.control"),
|
||||
borderWidth: "1px",
|
||||
borderStyle: "solid",
|
||||
borderColor: ref("color.border"),
|
||||
backgroundColor: ref("color.surface"),
|
||||
color: ref("color.text"),
|
||||
fontFamily: ref("type.body"),
|
||||
};
|
||||
const result: Record<string, ComponentRecipe> = {
|
||||
Button: {
|
||||
contractVersion: 1,
|
||||
base: {
|
||||
control: {
|
||||
borderRadius: ref("shape.control"),
|
||||
borderWidth: "1px",
|
||||
borderStyle: "solid",
|
||||
paddingInline: ref("space.control"),
|
||||
boxShadow: ref("elevation.panel"),
|
||||
fontFamily: ref("type.body"),
|
||||
...(angular ? { rotate: "-2deg" } : {}),
|
||||
},
|
||||
},
|
||||
variants: [
|
||||
{
|
||||
when: { variant: "default" },
|
||||
parts: {
|
||||
control: {
|
||||
backgroundImage: "none",
|
||||
backgroundColor: outlined ? "transparent" : ref("color.primary"),
|
||||
color: outlined ? ref("color.primary") : ref("color.primary-contrast"),
|
||||
borderColor: ref("color.primary"),
|
||||
},
|
||||
},
|
||||
},
|
||||
],
|
||||
states: [
|
||||
{ state: "focusVisible", parts: { control: { outlineColor: ref("color.primary") } } },
|
||||
{
|
||||
state: "disabled",
|
||||
parts: { control: { opacity: 0.55, ...(angular ? { rotate: "0deg" } : {}) } },
|
||||
},
|
||||
],
|
||||
},
|
||||
Input: recipe({ control: field }),
|
||||
Textarea: recipe({ control: field }),
|
||||
Select: recipe({ control: field }),
|
||||
Checkbox: recipe({ control: { borderRadius: ref("shape.control"), borderWidth: "1px" } }),
|
||||
Radio: recipe({ control: { borderWidth: "1px" } }),
|
||||
Switch: recipe({ control: { borderRadius: ref("shape.control") } }),
|
||||
Card: recipe({
|
||||
panel: { ...surface, ...(angular ? { rotate: "1deg" } : {}) },
|
||||
body: { paddingInline: ref("space.panel"), paddingBlock: ref("space.panel") },
|
||||
heading: { fontFamily: ref("type.heading") },
|
||||
}),
|
||||
Alert: recipe({
|
||||
root: { borderRadius: ref("shape.panel"), boxShadow: ref("elevation.panel") },
|
||||
}),
|
||||
Modal: recipe({
|
||||
panel: { ...surface, borderRadius: ref("shape.dialog") },
|
||||
heading: { fontFamily: ref("type.heading") },
|
||||
}),
|
||||
ContextMenu: recipe({
|
||||
panel: { ...surface, borderRadius: ref("shape.dialog") },
|
||||
item: { borderRadius: ref("shape.control"), fontFamily: ref("type.body") },
|
||||
}),
|
||||
Nav: {
|
||||
contractVersion: 1,
|
||||
base: { link: { borderRadius: ref("shape.control"), fontFamily: ref("type.body") } },
|
||||
states: [{ state: "selected", parts: { link: { fontWeight: 700 } } }],
|
||||
},
|
||||
PageHeader: recipe({ title: { fontFamily: ref("type.heading"), letterSpacing: "-0.025em" } }),
|
||||
Container: recipe({ root: { paddingInline: ref("space.page") } }),
|
||||
};
|
||||
return { ...catalogRecipes(), ...result };
|
||||
}
|
||||
function preset(id: string, name: string, soft: boolean, angular: boolean): DesignSystem {
|
||||
const mono = '"IBM Plex Mono", "Courier New", monospace';
|
||||
const body = soft ? "Georgia, serif" : angular ? "Arial, sans-serif" : mono;
|
||||
const heading = angular ? '"Times New Roman", serif' : body;
|
||||
const light = soft
|
||||
? {
|
||||
bg: "#E8DCC7",
|
||||
background: "#E8DCC7",
|
||||
surface: "#D4B895",
|
||||
"surface-2": "#E8DCC7",
|
||||
text: "#292D24",
|
||||
foreground: "#292D24",
|
||||
"text-muted": "#4B5141",
|
||||
border: "#606C38",
|
||||
primary: "#606C38",
|
||||
"primary-hover": "#4B542C",
|
||||
"primary-active": "#3D4424",
|
||||
"primary-contrast": "#FFFFFF",
|
||||
"primary-text": "#3D4424",
|
||||
"primary-soft": "#D4B895",
|
||||
}
|
||||
: angular
|
||||
? {
|
||||
bg: "#E8E0C0",
|
||||
background: "#E8E0C0",
|
||||
surface: "#EDE4CF",
|
||||
"surface-2": "#E8E0C0",
|
||||
text: "#232320",
|
||||
foreground: "#232320",
|
||||
"text-muted": "#535146",
|
||||
border: "#535146",
|
||||
primary: "#8F2448",
|
||||
"primary-hover": "#701C39",
|
||||
"primary-active": "#59172D",
|
||||
"primary-contrast": "#FFFFFF",
|
||||
"primary-text": "#701C39",
|
||||
"primary-soft": "#E8E0C0",
|
||||
}
|
||||
: {
|
||||
bg: "#FFFFFF",
|
||||
background: "#FFFFFF",
|
||||
surface: "#F7F7F8",
|
||||
"surface-2": "#FFFFFF",
|
||||
text: "#0B0C0A",
|
||||
foreground: "#0B0C0A",
|
||||
"text-muted": "#454741",
|
||||
border: "#696C63",
|
||||
primary: "#705000",
|
||||
"primary-hover": "#594000",
|
||||
"primary-active": "#443100",
|
||||
"primary-contrast": "#FFFFFF",
|
||||
"primary-text": "#594000",
|
||||
"primary-soft": "#F7F7F8",
|
||||
};
|
||||
const dark = soft
|
||||
? {
|
||||
bg: "#292D24",
|
||||
background: "#292D24",
|
||||
surface: "#363D2F",
|
||||
"surface-2": "#414B37",
|
||||
text: "#E8DCC7",
|
||||
foreground: "#E8DCC7",
|
||||
"text-muted": "#C9C0AA",
|
||||
border: "#8B9D83",
|
||||
primary: "#D4B895",
|
||||
"primary-hover": "#E8DCC7",
|
||||
"primary-active": "#E8DCC7",
|
||||
"primary-contrast": "#292D24",
|
||||
"primary-text": "#E8DCC7",
|
||||
"primary-soft": "#414B37",
|
||||
}
|
||||
: angular
|
||||
? {
|
||||
bg: "#25221E",
|
||||
background: "#25221E",
|
||||
surface: "#353128",
|
||||
"surface-2": "#454032",
|
||||
text: "#EDE4CF",
|
||||
foreground: "#EDE4CF",
|
||||
"text-muted": "#D0C7B2",
|
||||
border: "#B0A78D",
|
||||
primary: "#FF9FC6",
|
||||
"primary-hover": "#FFC0DA",
|
||||
"primary-active": "#FFD2E5",
|
||||
"primary-contrast": "#25221E",
|
||||
"primary-text": "#FF9FC6",
|
||||
"primary-soft": "#454032",
|
||||
}
|
||||
: {
|
||||
bg: "#0B0C0A",
|
||||
background: "#0B0C0A",
|
||||
surface: "#000000",
|
||||
"surface-2": "#0B0C0A",
|
||||
text: "#F7F7F8",
|
||||
foreground: "#F7F7F8",
|
||||
"text-muted": "#BEC0B9",
|
||||
border: "#696C63",
|
||||
primary: "#FFB800",
|
||||
"primary-hover": "#FFD15C",
|
||||
"primary-active": "#FFE099",
|
||||
"primary-contrast": "#000000",
|
||||
"primary-text": "#FFB800",
|
||||
"primary-soft": "#0B0C0A",
|
||||
};
|
||||
const tokens: TokenMap = {
|
||||
"shape.control": token("dimension", soft ? "16px" : "0", "radius-sm"),
|
||||
"shape.panel": token("dimension", soft ? "24px" : "0", "radius-md"),
|
||||
"shape.dialog": token("dimension", soft ? "32px" : "0", "radius-lg"),
|
||||
"shape.base": token("dimension", soft ? "24px" : "0", "radius"),
|
||||
"space.control": token("dimension", soft ? "1.25rem" : "0.75rem"),
|
||||
"space.panel": token("dimension", soft ? "1.5rem" : "1rem", "space-panel"),
|
||||
"space.page": token("dimension", soft ? "1.5rem" : "1rem"),
|
||||
"control.height": token("dimension", soft ? "2.75rem" : "2.5rem", "control-height"),
|
||||
"type.body": token("fontFamily", body, "font-sans"),
|
||||
"type.heading": token("fontFamily", heading),
|
||||
"elevation.panel": token("shadow", angular ? "3px 3px 0 #535146" : "none", "shadow-1"),
|
||||
...colors(dark),
|
||||
};
|
||||
return {
|
||||
schemaVersion: 1,
|
||||
id,
|
||||
name,
|
||||
version: "1.0.0",
|
||||
defaultMode: soft || angular ? "light" : "dark",
|
||||
tokens,
|
||||
modes: {
|
||||
light: { ...colors(light), scheme: token("colorScheme", "light", "color-scheme") },
|
||||
dark: { ...colors(dark), scheme: token("colorScheme", "dark", "color-scheme") },
|
||||
},
|
||||
recipes: recipes(angular, !soft),
|
||||
};
|
||||
}
|
||||
|
||||
/** Industrial: black/amber, monospace, square outlined controls and flat panels. */
|
||||
export const SQUARE_DESIGN_SYSTEM = preset("square", "Square / Industrial", false, false);
|
||||
/** Organic: sand/sage surfaces, warm serif typography, generous rounded controls. */
|
||||
export const SOFT_DESIGN_SYSTEM = preset("soft", "Soft / Organic", true, false);
|
||||
/** Lo-Fi: paper-yellow, mixed system typography, rotated actions and hard shadows. */
|
||||
export const ANGULAR_DESIGN_SYSTEM = preset("angular", "Angular / Lo-Fi", false, true);
|
||||
export const CORE_DESIGN_SYSTEMS = {
|
||||
square: SQUARE_DESIGN_SYSTEM,
|
||||
soft: SOFT_DESIGN_SYSTEM,
|
||||
angular: ANGULAR_DESIGN_SYSTEM,
|
||||
} as const;
|
||||
@@ -0,0 +1,312 @@
|
||||
import { COMPONENT_CONTRACTS } from "./contracts.ts";
|
||||
import { CORE_COMPONENT_CONTRACTS } from "./core-contracts.ts";
|
||||
import { isRecord, validTokenValue } from "./validation.ts";
|
||||
import type {
|
||||
ComponentRecipe,
|
||||
DesignDiagnostic,
|
||||
RecipeParts,
|
||||
ResolvedDesignSystem,
|
||||
TokenType,
|
||||
} from "./types.ts";
|
||||
import { fail } from "./resolve.ts";
|
||||
|
||||
export interface ComponentDesignContract {
|
||||
contractVersion: number;
|
||||
component: string;
|
||||
source: string;
|
||||
family: string;
|
||||
support: string;
|
||||
axes: Record<string, { default: string; values: readonly string[] }>;
|
||||
parts: Record<
|
||||
string,
|
||||
{
|
||||
states: Record<string, string>;
|
||||
elements: readonly string[];
|
||||
properties: readonly string[];
|
||||
profile?: string;
|
||||
role?: string;
|
||||
ownership?: "framework" | "provider" | "native" | "content";
|
||||
}
|
||||
>;
|
||||
slots: readonly string[];
|
||||
tokens: readonly string[];
|
||||
constraints: readonly string[];
|
||||
fixtures: readonly string[];
|
||||
compatibility: { engine: number; componentContract: number };
|
||||
regions?: Record<string, { ownership: string; parts: readonly string[]; limitations: string }>;
|
||||
}
|
||||
export function componentDesignContract(name: string): ComponentDesignContract | undefined {
|
||||
return Object.hasOwn(COMPONENT_CONTRACTS, name)
|
||||
? (COMPONENT_CONTRACTS[name as keyof typeof COMPONENT_CONTRACTS] as ComponentDesignContract)
|
||||
: undefined;
|
||||
}
|
||||
export const RECIPE_PROPERTIES: Record<string, TokenType | "borderStyle" | "angle" | "imageReset"> =
|
||||
{
|
||||
backgroundImage: "imageReset",
|
||||
borderRadius: "dimension",
|
||||
borderWidth: "dimension",
|
||||
borderColor: "color",
|
||||
borderStyle: "borderStyle",
|
||||
boxShadow: "shadow",
|
||||
paddingInline: "dimension",
|
||||
paddingBlock: "dimension",
|
||||
gap: "dimension",
|
||||
minHeight: "dimension",
|
||||
color: "color",
|
||||
backgroundColor: "color",
|
||||
fontFamily: "fontFamily",
|
||||
fontSize: "dimension",
|
||||
fontWeight: "fontWeight",
|
||||
letterSpacing: "dimension",
|
||||
outlineColor: "color",
|
||||
opacity: "number",
|
||||
rotate: "angle",
|
||||
};
|
||||
function literal(property: string, value: unknown): boolean {
|
||||
const type = RECIPE_PROPERTIES[property];
|
||||
if (type === "imageReset") return value === "none";
|
||||
if (type === "borderStyle")
|
||||
return ["solid", "dashed", "dotted", "double", "none"].includes(value as string);
|
||||
if (type === "angle")
|
||||
return (
|
||||
typeof value === "string" &&
|
||||
/^-?\d+(?:\.\d+)?deg$/.test(value) &&
|
||||
Math.abs(parseFloat(value)) <= 8
|
||||
);
|
||||
if (!type || !validTokenValue(type, value) || isRecord(value)) return false;
|
||||
if (property === "opacity") return Number(value) >= 0 && Number(value) <= 1;
|
||||
if (
|
||||
[
|
||||
"borderRadius",
|
||||
"borderWidth",
|
||||
"fontSize",
|
||||
"minHeight",
|
||||
"paddingInline",
|
||||
"paddingBlock",
|
||||
"gap",
|
||||
].includes(property)
|
||||
)
|
||||
return parseFloat(String(value)) >= 0;
|
||||
return true;
|
||||
}
|
||||
export function validateRecipes(input: unknown): DesignDiagnostic[] {
|
||||
if (input === undefined) return [];
|
||||
const issues: DesignDiagnostic[] = [];
|
||||
const error = (path: string, message: string) =>
|
||||
issues.push({ code: "WRN-DS-RECIPE", path, message });
|
||||
if (!isRecord(input))
|
||||
return [
|
||||
{ code: "WRN-DS-RECIPE", path: "recipes", message: "Expected a component recipe map." },
|
||||
];
|
||||
for (const [name, recipe] of Object.entries(input)) {
|
||||
const path = `recipes.${name}`;
|
||||
const contract = componentDesignContract(name);
|
||||
if (!contract) {
|
||||
error(path, "Unknown component contract.");
|
||||
continue;
|
||||
}
|
||||
if (!isRecord(recipe)) {
|
||||
error(path, "Expected recipe object.");
|
||||
continue;
|
||||
}
|
||||
if (recipe.contractVersion !== 1)
|
||||
error(`${path}.contractVersion`, "Only contract version 1 is supported.");
|
||||
for (const key of Object.keys(recipe))
|
||||
if (!["contractVersion", "base", "variants", "states"].includes(key))
|
||||
error(`${path}.${key}`, "Unknown recipe field.");
|
||||
const parts = (value: unknown, at: string, state?: string) => {
|
||||
if (!isRecord(value)) {
|
||||
error(at, "Expected a part map.");
|
||||
return;
|
||||
}
|
||||
for (const [part, styles] of Object.entries(value)) {
|
||||
const descriptor = contract.parts[part];
|
||||
if (!Object.hasOwn(contract.parts, part) || !descriptor) {
|
||||
error(`${at}.${part}`, "Unknown component part.");
|
||||
continue;
|
||||
}
|
||||
if (state && !Object.hasOwn(descriptor.states, state))
|
||||
error(`${at}.${part}`, `State '${state}' is unsupported on this part.`);
|
||||
if (!isRecord(styles)) {
|
||||
error(`${at}.${part}`, "Expected style property map.");
|
||||
continue;
|
||||
}
|
||||
for (const [property, value] of Object.entries(styles)) {
|
||||
if (
|
||||
!Object.hasOwn(RECIPE_PROPERTIES, property) ||
|
||||
!descriptor.properties.includes(property)
|
||||
) {
|
||||
error(`${at}.${part}.${property}`, "Unsupported property.");
|
||||
continue;
|
||||
}
|
||||
if (
|
||||
property === "rotate" &&
|
||||
!((name === "Button" && part === "control") || (name === "Card" && part === "panel"))
|
||||
) {
|
||||
error(
|
||||
`${at}.${part}.${property}`,
|
||||
"Rotation is supported only on Button control and Card panel.",
|
||||
);
|
||||
continue;
|
||||
}
|
||||
if (isRecord(value)) {
|
||||
if (
|
||||
Object.keys(value).length !== 1 ||
|
||||
typeof value.ref !== "string" ||
|
||||
!validTokenValue("color", value)
|
||||
)
|
||||
error(`${at}.${part}.${property}`, "Expected a typed token reference.");
|
||||
} else if (!literal(property, value))
|
||||
error(`${at}.${part}.${property}`, "Invalid property value.");
|
||||
}
|
||||
}
|
||||
};
|
||||
if (recipe.base !== undefined) parts(recipe.base, `${path}.base`);
|
||||
for (const key of ["variants", "states"])
|
||||
if (recipe[key] !== undefined) {
|
||||
if (!Array.isArray(recipe[key])) {
|
||||
error(`${path}.${key}`, "Expected ordered rule array.");
|
||||
continue;
|
||||
}
|
||||
for (const [index, rule] of recipe[key].entries()) {
|
||||
const at = `${path}.${key}.${index}`;
|
||||
if (!isRecord(rule)) {
|
||||
error(at, "Expected rule object.");
|
||||
continue;
|
||||
}
|
||||
for (const field of Object.keys(rule))
|
||||
if (!["parts", key === "variants" ? "when" : "state"].includes(field))
|
||||
error(`${at}.${field}`, "Unknown rule field.");
|
||||
if (key === "variants") {
|
||||
if (!isRecord(rule.when) || !Object.keys(rule.when).length)
|
||||
error(`${at}.when`, "Expected nonempty axis conditions.");
|
||||
else
|
||||
for (const [axis, value] of Object.entries(rule.when))
|
||||
if (
|
||||
!Object.hasOwn(contract.axes, axis) ||
|
||||
!contract.axes[axis]?.values.includes(value as string)
|
||||
)
|
||||
error(`${at}.when.${axis}`, "Unknown axis or unsupported axis value.");
|
||||
} else if (
|
||||
typeof rule.state !== "string" ||
|
||||
!Object.values(contract.parts).some((part) =>
|
||||
Object.hasOwn(part.states, rule.state as string),
|
||||
)
|
||||
)
|
||||
error(`${at}.state`, "Unknown state name.");
|
||||
parts(rule.parts, `${at}.parts`, key === "states" ? String(rule.state) : undefined);
|
||||
}
|
||||
}
|
||||
}
|
||||
return issues;
|
||||
}
|
||||
|
||||
/** Direct owner+part selectors avoid ancestor variant leakage into nested components. */
|
||||
export function compileComponentRecipes(
|
||||
system: ResolvedDesignSystem,
|
||||
scope = ":root",
|
||||
scopeId?: string,
|
||||
): string {
|
||||
const blocks: string[] = [];
|
||||
for (const name of Object.keys(system.recipes).sort()) {
|
||||
const recipe = system.recipes[name]!;
|
||||
const contract = componentDesignContract(name)!;
|
||||
const emit = (parts: RecipeParts, when: Record<string, string> = {}, state?: string) => {
|
||||
for (const part of Object.keys(parts).sort())
|
||||
for (const property of Object.keys(parts[part]!).sort()) {
|
||||
let value = parts[part]![property]!;
|
||||
if (isRecord(value)) {
|
||||
const token = system.tokens[value.ref as string];
|
||||
if (!token)
|
||||
fail(
|
||||
"WRN-DS-REFERENCE-MISSING",
|
||||
`recipes.${name}.${part}.${property}`,
|
||||
`Unknown token '${value.ref}'.`,
|
||||
);
|
||||
const expected = RECIPE_PROPERTIES[property];
|
||||
if (token.type !== expected || !literal(property, token.value))
|
||||
fail(
|
||||
"WRN-DS-REFERENCE-TYPE",
|
||||
`recipes.${name}.${part}.${property}`,
|
||||
"Recipe reference type/value does not match property.",
|
||||
);
|
||||
value =
|
||||
token.legacy && token.legacy !== "color-scheme"
|
||||
? `var(--wrn-${token.legacy})`
|
||||
: token.value;
|
||||
}
|
||||
let target = `[data-wrn-owner="${name}"][data-wrn-part="${part}"][data-wrn-owner]`;
|
||||
for (const [axis, choice] of Object.entries(when).sort())
|
||||
target += `:where([data-wrn-axis-${axis}="${choice}"])`;
|
||||
// Preserve explicit geometry/size/color choices instead of erasing instance APIs.
|
||||
const guard = (axis: string) => {
|
||||
if (contract.axes[axis] && !Object.hasOwn(when, axis))
|
||||
target += `:where([data-wrn-axis-${axis}="${contract.axes[axis]!.default}"])`;
|
||||
};
|
||||
if (property === "borderRadius") {
|
||||
if (name === "Button") guard("pill");
|
||||
guard("radius");
|
||||
guard("shape");
|
||||
guard("rounded");
|
||||
guard("flush");
|
||||
}
|
||||
if (
|
||||
["Input", "Textarea", "Select"].includes(name) &&
|
||||
["borderRadius", "paddingInline", "paddingBlock", "minHeight"].includes(property)
|
||||
)
|
||||
guard("variant");
|
||||
if (
|
||||
!Object.hasOwn(CORE_COMPONENT_CONTRACTS, name) &&
|
||||
["borderRadius", "boxShadow", "paddingInline", "paddingBlock"].includes(property)
|
||||
)
|
||||
guard("variant");
|
||||
if (property === "boxShadow") guard("shadow");
|
||||
if (property === "borderWidth") {
|
||||
guard("bordered");
|
||||
guard("flush");
|
||||
}
|
||||
if (["paddingInline", "paddingBlock", "gap", "minHeight", "fontSize"].includes(property))
|
||||
guard("size");
|
||||
if (["paddingInline", "paddingBlock", "gap"].includes(property)) {
|
||||
guard("compact");
|
||||
guard("dense");
|
||||
}
|
||||
if (
|
||||
["color", "backgroundColor", "backgroundImage", "borderColor", "outlineColor"].includes(
|
||||
property,
|
||||
)
|
||||
)
|
||||
guard("color");
|
||||
if (state) target += `:where(${contract.parts[part]!.states[state]})`;
|
||||
// Scope identifiers name unique islands. Stop at a nested design owner.
|
||||
target += scopeId
|
||||
? `:not([data-wrn-design="${scopeId}"] [data-wrn-design],[data-wrn-design="${scopeId}"] [data-wrn-design] *)`
|
||||
: `:not([data-wrn-design],[data-wrn-design] *)`;
|
||||
const cssName = property.replace(/[A-Z]/g, (letter) => `-${letter.toLowerCase()}`);
|
||||
blocks.push(`${scope}${target},${scope} ${target}{${cssName}:${value};}`);
|
||||
}
|
||||
};
|
||||
if (recipe.base) emit(recipe.base);
|
||||
for (const rule of recipe.variants ?? []) emit(rule.parts, rule.when);
|
||||
for (const rule of recipe.states ?? []) emit(rule.parts, {}, rule.state);
|
||||
}
|
||||
return blocks.join("\n");
|
||||
}
|
||||
|
||||
export function mergeRecipes(
|
||||
base: Record<string, ComponentRecipe>,
|
||||
next: Record<string, ComponentRecipe> = {},
|
||||
): void {
|
||||
for (const [name, recipe] of Object.entries(next)) {
|
||||
const previous = base[name];
|
||||
const parts: RecipeParts = { ...previous?.base };
|
||||
for (const [part, styles] of Object.entries(recipe.base ?? {}))
|
||||
parts[part] = { ...parts[part], ...styles };
|
||||
base[name] = {
|
||||
contractVersion: 1,
|
||||
base: parts,
|
||||
variants: [...(previous?.variants ?? []), ...(recipe.variants ?? [])],
|
||||
states: [...(previous?.states ?? []), ...(recipe.states ?? [])],
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,117 @@
|
||||
import type {
|
||||
DesignSystem,
|
||||
ResolveOptions,
|
||||
ResolvedDesignSystem,
|
||||
ResolvedToken,
|
||||
TokenMap,
|
||||
DesignToken,
|
||||
} from "./types.ts";
|
||||
import { assertDesignSystem, DesignSystemError, isReference } from "./validation.ts";
|
||||
import { mergeRecipes } from "./recipes.ts";
|
||||
import type { ComponentRecipe } from "./types.ts";
|
||||
|
||||
export function fail(code: string, path: string, message: string): never {
|
||||
throw new DesignSystemError([{ code, path, message }]);
|
||||
}
|
||||
|
||||
export function resolveDesignSystem(
|
||||
system: DesignSystem,
|
||||
options: ResolveOptions = {},
|
||||
): ResolvedDesignSystem {
|
||||
const layers: DesignSystem[] = [];
|
||||
const visiting = new Set<string>();
|
||||
const visit = (current: DesignSystem) => {
|
||||
assertDesignSystem(current);
|
||||
if (visiting.has(current.id))
|
||||
fail("WRN-DS-EXTENDS-CYCLE", current.id, "System extension cycle.");
|
||||
if (visiting.size >= 64)
|
||||
fail("WRN-DS-LIMIT", current.id, "System inheritance exceeds 64 levels.");
|
||||
visiting.add(current.id);
|
||||
for (const id of current.extends ?? []) {
|
||||
const parent =
|
||||
options.registry && Object.hasOwn(options.registry, id) ? options.registry[id] : undefined;
|
||||
if (!parent)
|
||||
fail("WRN-DS-EXTENDS-MISSING", `${current.id}.extends`, `Unknown system '${id}'.`);
|
||||
if (parent.id !== id) fail("WRN-DS-EXTENDS-ID", id, "Registry key must match system id.");
|
||||
visit(parent);
|
||||
}
|
||||
visiting.delete(current.id);
|
||||
layers.push(current);
|
||||
if (layers.length > 256) fail("WRN-DS-LIMIT", current.id, "Too many inherited layers.");
|
||||
};
|
||||
visit(system);
|
||||
const modes = [...new Set(layers.flatMap((layer) => Object.keys(layer.modes ?? {})))].sort();
|
||||
const mode =
|
||||
options.mode ?? [...layers].reverse().find((layer) => layer.defaultMode)?.defaultMode;
|
||||
if (mode !== undefined && !modes.includes(mode))
|
||||
fail("WRN-DS-MODE", "mode", `Unknown mode '${mode}'.`);
|
||||
if (
|
||||
options.density !== undefined &&
|
||||
!layers.some((layer) => Object.hasOwn(layer.densities ?? {}, options.density!))
|
||||
)
|
||||
fail("WRN-DS-DENSITY", "density", `Unknown density '${options.density}'.`);
|
||||
const raw = new Map<string, { token: DesignToken; source: string }>();
|
||||
const merge = (tokens: TokenMap | undefined, source: string) => {
|
||||
for (const [name, token] of Object.entries(tokens ?? {})) {
|
||||
const previous = raw.get(name)?.token;
|
||||
if (previous && previous.type !== token.type)
|
||||
fail("WRN-DS-TYPE", `${source}.${name}`, "Overrides cannot change token type.");
|
||||
// An override may omit metadata; retain the inherited legacy binding.
|
||||
raw.set(name, { token: { ...previous, ...token }, source: `${source}.${name}` });
|
||||
}
|
||||
};
|
||||
for (const layer of layers) merge(layer.tokens, `${layer.id}.tokens`);
|
||||
if (mode) for (const layer of layers) merge(layer.modes?.[mode], `${layer.id}.modes.${mode}`);
|
||||
if (options.density)
|
||||
for (const layer of layers)
|
||||
merge(layer.densities?.[options.density], `${layer.id}.densities.${options.density}`);
|
||||
const resolved = new Map<string, ResolvedToken>();
|
||||
const active = new Set<string>();
|
||||
const token = (name: string): ResolvedToken => {
|
||||
const cached = resolved.get(name);
|
||||
if (cached) return cached;
|
||||
const entry = raw.get(name);
|
||||
if (!entry) fail("WRN-DS-REFERENCE-MISSING", name, "Referenced token does not exist.");
|
||||
if (active.has(name)) fail("WRN-DS-REFERENCE-CYCLE", entry.source, "Token reference cycle.");
|
||||
if (active.size >= 256) fail("WRN-DS-LIMIT", name, "Token reference depth exceeds 256.");
|
||||
active.add(name);
|
||||
const definition = entry.token;
|
||||
let value: string | number;
|
||||
let reference: string | undefined;
|
||||
if (isReference(definition.value)) {
|
||||
reference = definition.value.ref;
|
||||
const target = token(reference);
|
||||
if (target.type !== definition.type)
|
||||
fail(
|
||||
"WRN-DS-REFERENCE-TYPE",
|
||||
entry.source,
|
||||
`Reference '${reference}' has a different type.`,
|
||||
);
|
||||
value = target.value;
|
||||
} else value = definition.value;
|
||||
const result: ResolvedToken = {
|
||||
type: definition.type,
|
||||
value,
|
||||
source: entry.source,
|
||||
...(definition.legacy ? { legacy: definition.legacy } : {}),
|
||||
...(reference ? { reference } : {}),
|
||||
};
|
||||
resolved.set(name, result);
|
||||
active.delete(name);
|
||||
return result;
|
||||
};
|
||||
const tokens: Record<string, ResolvedToken> = {};
|
||||
for (const name of [...raw.keys()].sort()) tokens[name] = token(name);
|
||||
const recipes: Record<string, ComponentRecipe> = {};
|
||||
for (const layer of layers) mergeRecipes(recipes, layer.recipes);
|
||||
return {
|
||||
id: system.id,
|
||||
name: system.name,
|
||||
version: system.version,
|
||||
...(mode ? { mode } : {}),
|
||||
...(options.density ? { density: options.density } : {}),
|
||||
modes,
|
||||
tokens,
|
||||
recipes,
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,84 @@
|
||||
export type TokenType =
|
||||
| "color"
|
||||
| "dimension"
|
||||
| "number"
|
||||
| "fontFamily"
|
||||
| "duration"
|
||||
| "shadow"
|
||||
| "fontWeight"
|
||||
| "colorScheme";
|
||||
export type RecipeValue = string | number | TokenReference;
|
||||
export type RecipeStyles = Record<string, RecipeValue>;
|
||||
export type RecipeParts = Record<string, RecipeStyles>;
|
||||
export interface RecipeRule {
|
||||
when: Record<string, string>;
|
||||
parts: RecipeParts;
|
||||
}
|
||||
export interface RecipeStateRule {
|
||||
state: string;
|
||||
parts: RecipeParts;
|
||||
}
|
||||
export interface ComponentRecipe {
|
||||
contractVersion: 1;
|
||||
base?: RecipeParts;
|
||||
variants?: RecipeRule[];
|
||||
states?: RecipeStateRule[];
|
||||
}
|
||||
export interface TokenReference {
|
||||
ref: string;
|
||||
}
|
||||
export interface DesignToken {
|
||||
type: TokenType;
|
||||
value: string | number | TokenReference;
|
||||
/** Existing --wrn-* suffix, such as radius-sm. No implicit name conversion. */
|
||||
legacy?: string;
|
||||
description?: string;
|
||||
}
|
||||
export type TokenMap = Record<string, DesignToken>;
|
||||
export interface DesignSystem {
|
||||
schemaVersion: 1;
|
||||
id: string;
|
||||
name: string;
|
||||
version: string;
|
||||
description?: string;
|
||||
extends?: string[];
|
||||
tokens: TokenMap;
|
||||
modes?: Record<string, TokenMap>;
|
||||
densities?: Record<string, TokenMap>;
|
||||
defaultMode?: string;
|
||||
recipes?: Record<string, ComponentRecipe>;
|
||||
}
|
||||
export interface DesignDiagnostic {
|
||||
code: string;
|
||||
path: string;
|
||||
message: string;
|
||||
}
|
||||
export interface ResolveOptions {
|
||||
registry?: Record<string, DesignSystem>;
|
||||
mode?: string;
|
||||
density?: string;
|
||||
}
|
||||
export interface DesignSystemInput extends ResolveOptions {
|
||||
system: DesignSystem;
|
||||
}
|
||||
export interface ResolvedToken {
|
||||
type: TokenType;
|
||||
value: string | number;
|
||||
legacy?: string;
|
||||
source: string;
|
||||
reference?: string;
|
||||
}
|
||||
export interface ResolvedDesignSystem {
|
||||
id: string;
|
||||
name: string;
|
||||
version: string;
|
||||
mode?: string;
|
||||
density?: string;
|
||||
modes: string[];
|
||||
tokens: Record<string, ResolvedToken>;
|
||||
recipes: Record<string, ComponentRecipe>;
|
||||
}
|
||||
export interface LegacyTheme {
|
||||
default?: string;
|
||||
themes?: Record<string, Record<string, string>>;
|
||||
}
|
||||
@@ -0,0 +1,47 @@
|
||||
import type { DesignDiagnostic, ResolveOptions } from "./types.ts";
|
||||
import {
|
||||
validateDesignSystem as validateSchema,
|
||||
DesignSystemError,
|
||||
assertDesignSystem,
|
||||
} from "./validation.ts";
|
||||
import { compileDesignSystem } from "./compile.ts";
|
||||
|
||||
/** Validate schema and every available mode/density combination, including output bindings. */
|
||||
export function validateDesignSystem(
|
||||
input: unknown,
|
||||
options: ResolveOptions = {},
|
||||
): DesignDiagnostic[] {
|
||||
const diagnostics = validateSchema(input);
|
||||
if (diagnostics.length) return diagnostics;
|
||||
assertDesignSystem(input);
|
||||
try {
|
||||
const initial = compileDesignSystem(input, options);
|
||||
// Discover inherited density names using a bounded traversal of validated parents.
|
||||
const densities = new Set<string>();
|
||||
const seen = new Set<string>();
|
||||
const walk = (system: typeof input) => {
|
||||
if (seen.has(system.id)) return;
|
||||
seen.add(system.id);
|
||||
for (const name of Object.keys(system.densities ?? {})) densities.add(name);
|
||||
for (const id of system.extends ?? []) {
|
||||
const parent = options.registry?.[id];
|
||||
if (parent) walk(parent);
|
||||
}
|
||||
};
|
||||
walk(input);
|
||||
if (densities.size * Math.max(initial.resolved.modes.length, 1) > 1024)
|
||||
return [
|
||||
{
|
||||
code: "WRN-DS-LIMIT",
|
||||
path: "densities",
|
||||
message: "Validation exceeds 1024 mode/density combinations.",
|
||||
},
|
||||
];
|
||||
for (const density of [...densities].sort())
|
||||
compileDesignSystem(input, { ...options, density });
|
||||
return [];
|
||||
} catch (error) {
|
||||
if (error instanceof DesignSystemError) return error.diagnostics;
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,221 @@
|
||||
import type { DesignDiagnostic, DesignSystem, DesignToken, TokenType } from "./types.ts";
|
||||
import { validateRecipes } from "./recipes.ts";
|
||||
|
||||
export const DESIGN_SYSTEM_SCHEMA_VERSION = 1;
|
||||
export const TOKEN_TYPES: readonly TokenType[] = [
|
||||
"color",
|
||||
"dimension",
|
||||
"number",
|
||||
"fontFamily",
|
||||
"duration",
|
||||
"shadow",
|
||||
"fontWeight",
|
||||
"colorScheme",
|
||||
];
|
||||
const forbidden = new Set(["__proto__", "prototype", "constructor"]);
|
||||
export const validName = (value: string): boolean =>
|
||||
/^[a-zA-Z][\w.-]*$/.test(value) && !value.split(/[.-]/).some((part) => forbidden.has(part));
|
||||
export const isRecord = (v: unknown): v is Record<string, unknown> =>
|
||||
typeof v === "object" &&
|
||||
v !== null &&
|
||||
!Array.isArray(v) &&
|
||||
[Object.prototype, null].includes(Object.getPrototypeOf(v));
|
||||
const length = /^-?(?:\d*\.)?\d+(?:px|rem|em|%|vw|vh|dvh|svh|ch|ex|vmin|vmax)$/;
|
||||
|
||||
/** Deliberately bounded CSS subset. References use { ref }, never arbitrary var()/url(). */
|
||||
export function validTokenValue(type: TokenType, value: unknown): boolean {
|
||||
if (isRecord(value))
|
||||
return Object.keys(value).length === 1 && typeof value.ref === "string" && validName(value.ref);
|
||||
if (typeof value === "number")
|
||||
return (
|
||||
Number.isFinite(value) &&
|
||||
(type === "number" || (type === "fontWeight" && value >= 1 && value <= 1000))
|
||||
);
|
||||
if (
|
||||
typeof value !== "string" ||
|
||||
!value.trim() ||
|
||||
value.length > 2048 ||
|
||||
/[;{}<>\\]|\/\*|\*\//.test(value) ||
|
||||
[...value].some((character) => character.charCodeAt(0) < 32)
|
||||
)
|
||||
return false;
|
||||
if (type === "colorScheme") return value === "light" || value === "dark";
|
||||
if (type === "dimension") return value === "0" || length.test(value);
|
||||
if (type === "duration") return /^(?:\d*\.)?\d+(ms|s)$/.test(value);
|
||||
if (type === "fontWeight") return /^(normal|bold|[1-9]\d{0,2}|1000)$/.test(value);
|
||||
if (type === "number") return /^-?(?:\d*\.)?\d+$/.test(value) && Number.isFinite(Number(value));
|
||||
if (type === "fontFamily")
|
||||
return /^(?:"[\w -]+"|'[\w -]+'|[a-zA-Z][\w -]*)(?:\s*,\s*(?:"[\w -]+"|'[\w -]+'|[a-zA-Z][\w -]*))*$/.test(
|
||||
value,
|
||||
);
|
||||
if (type === "color") {
|
||||
if (
|
||||
/^(?:#[\da-f]{3}|#[\da-f]{4}|#[\da-f]{6}|#[\da-f]{8})$/i.test(value) ||
|
||||
/^(?:transparent|currentColor|black|white|red|green|blue|gray|grey|orange|purple|yellow|pink|rebeccapurple)$/i.test(
|
||||
value,
|
||||
)
|
||||
)
|
||||
return true;
|
||||
const call = /^(rgb|rgba|hsl|hsla|oklch|oklab|lab|lch)\(([^()]+)\)$/i.exec(value);
|
||||
if (!call) return false;
|
||||
const args = call[2]!.trim();
|
||||
const kind = call[1]!.toLowerCase();
|
||||
let channels: string[];
|
||||
if (args.includes(",")) {
|
||||
if (args.includes("/") || !["rgb", "rgba", "hsl", "hsla"].includes(kind)) return false;
|
||||
channels = args.split(/\s*,\s*/);
|
||||
} else {
|
||||
const sections = args.split(/\s*\/\s*/);
|
||||
if (sections.length > 2) return false;
|
||||
channels = sections[0]!.trim().split(/\s+/);
|
||||
if (channels.length !== 3) return false;
|
||||
if (sections.length === 2) channels.push(sections[1]!);
|
||||
}
|
||||
return (
|
||||
(channels.length === 3 || channels.length === 4) &&
|
||||
channels.every((channel, index) => {
|
||||
const hue =
|
||||
(["hsl", "hsla"].includes(kind) && index === 0) ||
|
||||
(["lch", "oklch"].includes(kind) && index === 2);
|
||||
const pattern = hue ? /^[+-]?(?:\d*\.)?\d+(?:deg)?$/ : /^[+-]?(?:\d*\.)?\d+%?$/;
|
||||
if (!pattern.test(channel) || !Number.isFinite(parseFloat(channel))) return false;
|
||||
return (
|
||||
!(["hsl", "hsla"].includes(kind) && (index === 1 || index === 2)) || channel.endsWith("%")
|
||||
);
|
||||
})
|
||||
);
|
||||
}
|
||||
if (type === "shadow") {
|
||||
if (value === "none") return true;
|
||||
const shadows = value.split(/,(?![^()]*\))/);
|
||||
return shadows.every((shadow) => {
|
||||
let colors = 0;
|
||||
const dimensions = shadow
|
||||
.replace(
|
||||
/(?:rgba?|hsla?|oklch|oklab|lab|lch)\([^()]*\)|#[\da-f]+|\b(?:black|white|transparent|currentColor)\b/gi,
|
||||
(color) => {
|
||||
if (!validTokenValue("color", color)) return "INVALID";
|
||||
colors++;
|
||||
return "";
|
||||
},
|
||||
)
|
||||
.trim()
|
||||
.split(/\s+/);
|
||||
const inset = dimensions.indexOf("inset");
|
||||
if (inset !== -1) dimensions.splice(inset, 1);
|
||||
return (
|
||||
colors <= 1 &&
|
||||
dimensions.length >= 2 &&
|
||||
dimensions.length <= 4 &&
|
||||
dimensions.every((part) => part === "0" || (length.test(part) && !part.endsWith("%"))) &&
|
||||
(dimensions.length < 3 || parseFloat(dimensions[2]!) >= 0)
|
||||
);
|
||||
});
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
export function validateDesignSystem(input: unknown): DesignDiagnostic[] {
|
||||
const issues: DesignDiagnostic[] = [];
|
||||
const error = (code: string, path: string, message: string) =>
|
||||
issues.push({ code, path, message });
|
||||
if (!isRecord(input))
|
||||
return [{ code: "WRN-DS-SCHEMA", path: "$", message: "Expected a data-only object." }];
|
||||
const fields = new Set([
|
||||
"schemaVersion",
|
||||
"id",
|
||||
"name",
|
||||
"version",
|
||||
"description",
|
||||
"extends",
|
||||
"tokens",
|
||||
"modes",
|
||||
"densities",
|
||||
"defaultMode",
|
||||
"recipes",
|
||||
]);
|
||||
for (const key of Object.keys(input).sort())
|
||||
if (!fields.has(key)) error("WRN-DS-FIELD", key, "Unknown or unsupported field.");
|
||||
if (input.schemaVersion !== 1)
|
||||
error("WRN-DS-VERSION", "schemaVersion", "Only schemaVersion 1 is supported.");
|
||||
for (const key of ["id", "name", "version"] as const)
|
||||
if (typeof input[key] !== "string" || !input[key].trim())
|
||||
error("WRN-DS-SCHEMA", key, "Expected a nonempty string.");
|
||||
if (typeof input.id === "string" && !validName(input.id))
|
||||
error("WRN-DS-NAME", "id", "Invalid system identifier.");
|
||||
if (input.description !== undefined && typeof input.description !== "string")
|
||||
error("WRN-DS-SCHEMA", "description", "Expected a string.");
|
||||
if (
|
||||
input.extends !== undefined &&
|
||||
(!Array.isArray(input.extends) ||
|
||||
input.extends.some((v) => typeof v !== "string" || !validName(v)))
|
||||
)
|
||||
error("WRN-DS-SCHEMA", "extends", "Expected system identifier array.");
|
||||
if (
|
||||
input.defaultMode !== undefined &&
|
||||
(typeof input.defaultMode !== "string" || !validName(input.defaultMode))
|
||||
)
|
||||
error("WRN-DS-NAME", "defaultMode", "Invalid mode name.");
|
||||
const map = (value: unknown, path: string) => {
|
||||
if (!isRecord(value)) {
|
||||
error("WRN-DS-SCHEMA", path, "Expected a token map.");
|
||||
return;
|
||||
}
|
||||
for (const [key, token] of Object.entries(value).sort()) {
|
||||
const at = `${path}.${key}`;
|
||||
if (!validName(key)) error("WRN-DS-NAME", at, "Invalid token name.");
|
||||
if (!isRecord(token)) {
|
||||
error("WRN-DS-SCHEMA", at, "Expected a token object.");
|
||||
continue;
|
||||
}
|
||||
for (const field of Object.keys(token))
|
||||
if (!["type", "value", "legacy", "description"].includes(field))
|
||||
error("WRN-DS-FIELD", `${at}.${field}`, "Unknown token field.");
|
||||
if (!TOKEN_TYPES.includes(token.type as TokenType))
|
||||
error("WRN-DS-TYPE", `${at}.type`, "Unsupported token type.");
|
||||
else if (!validTokenValue(token.type as TokenType, token.value))
|
||||
error("WRN-DS-VALUE", `${at}.value`, "Invalid or unsupported token value.");
|
||||
if (
|
||||
token.legacy !== undefined &&
|
||||
(typeof token.legacy !== "string" ||
|
||||
!/^[a-z][a-z0-9-]*$/.test(token.legacy) ||
|
||||
!validName(token.legacy))
|
||||
)
|
||||
error("WRN-DS-NAME", `${at}.legacy`, "Invalid legacy token suffix.");
|
||||
if (token.legacy === "color-scheme" && token.type !== "colorScheme")
|
||||
error("WRN-DS-TYPE", `${at}.legacy`, "color-scheme requires colorScheme type.");
|
||||
if (token.description !== undefined && typeof token.description !== "string")
|
||||
error("WRN-DS-SCHEMA", `${at}.description`, "Expected a string.");
|
||||
}
|
||||
};
|
||||
map(input.tokens, "tokens");
|
||||
for (const axis of ["modes", "densities"])
|
||||
if (input[axis] !== undefined) {
|
||||
if (!isRecord(input[axis])) error("WRN-DS-SCHEMA", axis, "Expected a map.");
|
||||
else
|
||||
for (const [key, tokens] of Object.entries(input[axis]).sort()) {
|
||||
if (!validName(key)) error("WRN-DS-NAME", `${axis}.${key}`, "Invalid axis name.");
|
||||
map(tokens, `${axis}.${key}`);
|
||||
}
|
||||
}
|
||||
issues.push(...validateRecipes(input.recipes));
|
||||
return issues;
|
||||
}
|
||||
|
||||
export class DesignSystemError extends Error {
|
||||
constructor(public readonly diagnostics: DesignDiagnostic[]) {
|
||||
super(diagnostics.map((d) => `${d.code} ${d.path}: ${d.message}`).join("\n"));
|
||||
this.name = "DesignSystemError";
|
||||
}
|
||||
}
|
||||
export function assertDesignSystem(input: unknown): asserts input is DesignSystem {
|
||||
const issues = validateDesignSystem(input);
|
||||
if (issues.length) throw new DesignSystemError(issues);
|
||||
}
|
||||
export function defineDesignSystem<T extends DesignSystem>(input: T): T {
|
||||
assertDesignSystem(input);
|
||||
return input;
|
||||
}
|
||||
export function isReference(value: DesignToken["value"]): value is { ref: string } {
|
||||
return isRecord(value);
|
||||
}
|
||||
Reference in new issue
Block a user