feat: release design system engine, studio and component contracts

This commit is contained in:
Clintchiz committed 2026-10-19 13:09:03 +05:30
1 parent ff1065fb27
commit 3943b946d6
693 files changed
+175546 -3894

No files matched your search

+140
View File
@@ -0,0 +1,140 @@
# @wrnexus/design-system
Browser-safe, dependency-free tokens and versioned component recipes. The engine validates data-only documents, resolves aliases and inheritance, compiles deterministic CSS, and integrates with the existing WrNexus theme pipeline. Adoption is opt-in. Core components have additive owner/part hooks; their existing props, events, slots and default styles remain available.
## Apply to an existing application
```ts
import { defineConfig } from "@wrnexus/styles";
import { defineDesignSystem } from "@wrnexus/design-system";
const system = defineDesignSystem({
schemaVersion: 1,
id: "square",
name: "Square",
version: "1.0.0",
tokens: {
"control.radius": { type: "dimension", value: "0", legacy: "radius-sm" },
"panel.radius": { type: "dimension", value: "0", legacy: "radius-md" },
},
});
export default defineConfig({
theme: {
designSystem: { system },
// Explicit application tokens retain final precedence.
themes: { dark: { "radius-md": "4px" } },
},
});
```
The adapter writes existing `--wrn-*` variables. A normal Button already consumes `radius-sm`; an explicit `pill` prop still uses the existing pill recipe. Hard-coded geometry is customizable only where a versioned component recipe supports the property.
## Core presets and recipes
```ts
import { SOFT_DESIGN_SYSTEM } from "@wrnexus/design-system";
export default defineConfig({
theme: { designSystem: { system: SOFT_DESIGN_SYSTEM } },
});
```
Other exports: `SQUARE_DESIGN_SYSTEM`, `ANGULAR_DESIGN_SYSTEM`, and `CORE_DESIGN_SYSTEMS`. Square uses flat, compact, outlined controls; soft uses generous rounded geometry; angular uses individual CSS rotation and hard shadows. All include light/dark token sets.
The 14 version-1 contracts cover Button, Input, Textarea, Select, Checkbox, Radio, Switch, Card, Alert, Modal, Nav, ContextMenu, PageHeader and Container. Inspect `CORE_COMPONENT_CONTRACTS` or `componentDesignContract(name)` from this package or the optional `@wrnexus/ui/design-contracts` subpath. Descriptors list source, family, parts, property allowlists, axes/defaults/accepted values, state selectors, slots, tokens, constraints and compatibility. `COMPONENT_CONTRACTS` covers all 104 components; `CATALOG_COMPONENT_CONTRACTS` adds the other 90 while preserving the core registry. Catalog descriptors include explicit provider/native/content ownership and region limitations. All three presets include complete catalog recipes.
```ts
const system = defineDesignSystem({
schemaVersion: 1,
id: "company",
name: "Company",
version: "1.0.0",
extends: ["soft"],
tokens: {},
recipes: {
Button: {
contractVersion: 1,
base: { control: { borderWidth: "2px" } },
variants: [
{
when: { variant: "outline", size: "sm" },
parts: { control: { borderWidth: "3px" } },
},
],
states: [{ state: "disabled", parts: { control: { opacity: 0.55 } } }],
},
},
});
// theme.designSystem = { system, registry: { soft: SOFT_DESIGN_SYSTEM } }
```
Recipes merge across inherited systems: base properties merge per part; variant and state arrays append in declared order. Compilation applies base, matching variant/compound rules, then state rules. Conditions use zero-specificity selectors so rule ordering is meaningful. Unsupported components, versions, parts, axes, states, properties, token types and unsafe values fail validation. Property references use the same `{ ref }` syntax; references mapped to legacy variables retain explicit app token precedence. Rotation is a literal angle within ±8 degrees, permitted only on Button control and Card panel; it composes with existing pressed transforms. `backgroundImage` only accepts `none` to safely remove a legacy gradient.
Owner/part hooks are direct: `data-wrn-component` identifies the root; each part has `data-wrn-owner`, `data-wrn-part` and copies of relevant `data-wrn-axis-*` values. Ancestor variant selectors are not used, preventing nested-component leakage. Explicit pill, nondefault size/color, radius/shadow and field variant choices are guarded where applicable. This does not claim that arbitrary application selectors outrank a recipe; use app token overrides or deliberate selectors for property-level exceptions.
`resolveThemeConfig` includes an optional `recipeCss` field. The application SSR pipeline emits it after extracted component styles in a nonce-tagged `data-wrn-design-recipes` style element, for both development and production. Custom SSR consumers must pass `componentRecipeCss: theme.recipeCss` to `renderDocument`/`renderDocumentStream`. The theme CSS endpoint remains token-only. No recipe block is emitted without recipes.
Scoped compilation accepts a unique island identifier, e.g. `scope: "billing-preview"`. Put `data-wrn-design="billing-preview"` and `data-theme` on the island owner; nested islands must use distinct identifiers. Recipe selectors stop at nested owners, independent of stylesheet order. Reusing the same identifier across nested islands is unsupported. Global recipes exclude explicitly scoped islands. Current Modal/ContextMenu panels stay in their owner tree and inherit the island; future portalled implementations must preserve this context explicitly. Ejected components without hooks are application-owned and do not receive recipes automatically.
For the interactive developer preview, run `bun run scripts/preview-design-systems.ts` from the repository root. It renders the same content through actual components and supports mode changes and linked/inline token CSS. It uses test rendering utilities and is not shipped to application clients.
Imported color tokens suppress the implicit default accent so the built-in blue accent does not overwrite the brand. Explicit `theme.accent.default` and user-selected accents retain their existing behavior. This does not automatically generate all hover/active/contrast colors: supply each required semantic role. Existing defaults fill unspecified roles.
## Document format
Required fields: `schemaVersion: 1`, `id`, `name`, `version`, and `tokens`. Optional: `description`, `extends`, `defaultMode`, `modes`, `densities`, `recipes`. The published `@wrnexus/design-system/schema.json` is structural; runtime validation additionally enforces CSS grammar, graph semantics and component contracts. No remote schema fetch is performed.
Each flat, namespaced token has `type`, `value`, optional `legacy` suffix and `description`. Types: `color`, `dimension`, `number`, `fontFamily`, `duration`, `shadow`, `fontWeight`, `colorScheme`. An alias is exactly `{ "ref": "other.token" }` and must reference the same type. Modes and densities contain typed token maps, not partial token objects. Overridden tokens retain inherited metadata when omitted; their type cannot change.
CSS values use a deliberately bounded subset: basic hex/named and numeric color functions; unit-bearing dimensions or string `"0"`; finite numbers; simple font-family lists; durations; standard numeric shadows; font weights; light/dark color schemes. Unsupported expressions such as `var()`, `url()` and `calc()` are rejected. Use typed aliases for references. Future value grammar expansion can be additive.
`legacy` explicitly maps a token to an existing variable, such as `radius-sm`. `color-scheme` requires type `colorScheme` and emits the native CSS property. No implicit semantic mapping is guessed. The standalone compiler emits every token as `--wrn-ds-<name-with-dots-replaced-by-hyphens>` plus mapped legacy variables; collisions are errors. The legacy adapter exports mapped variables only.
## Engine APIs
```ts
import {
validateDesignSystem,
resolveDesignSystem,
compileDesignSystem,
adaptLegacyTheme,
} from "@wrnexus/design-system";
const options = { registry: { company: companySystem }, mode: "dark", density: "compact" };
const issues = validateDesignSystem(system, options);
const resolved = resolveDesignSystem(system, options);
const { css } = compileDesignSystem(system, options);
const preview = compileDesignSystem(system, { ...options, scope: "preview" });
const theme = adaptLegacyTheme(system, existingThemeConfig, options);
```
Registry IDs must match document IDs. Parents merge depth-first in declared order, then child tokens. Selected mode overrides apply across layers, followed by selected density. Aliases resolve after these changes. Explicit app theme overrides are last in the adapter. Default mode is inherited from the last applicable layer; there is no implicit density. All operations leave inputs unchanged. Resolved tokens include source and direct-reference provenance; follow the referenced token for the rest of the alias chain.
`validateDesignSystem(unknown, options)` returns structured diagnostics and checks all declared mode/density combinations, including output collisions. It returns the first graph/compilation failure after structural validation. `assertDesignSystem` and `defineDesignSystem` validate structure/typed values only; use full validation before saving imported drafts. Resolution validates the selected configuration. Invalid compilation throws `DesignSystemError` with stable codes and paths. Resource limits bound inheritance, alias depth and validation combinations.
Standalone CSS emits the selected/default configuration at `:root` and mode blocks on `:root[data-theme]`. Optional `scope` uses `[data-wrn-design="name"]` and expects the mode attribute on that same owner. Density is selected at compile time. Runtime density switching and propagation into externally portalled overlays are future work. Application integration uses the existing global theme runtime and SSR asset delivery; do not include both standalone CSS and the adapter unless you deliberately manage their precedence.
## CLI
```sh
wrnexus design validate system.json
wrnexus design inspect system.json --mode=dark --density=compact
wrnexus design compile system.json --registry=systems.json
```
`systems.json` maps parent IDs to full documents. Commands read bounded JSON inputs, never execute user files, and print output to stdout; they do not write, publish or deploy. Inspection includes resolved values and provenance. Validation reports nonzero failure through the existing CLI error path.
See `examples/square.json` for a complete document. The engine root can be bundled for the browser without filesystem or provider imports. No studio, AI credentials or network access is required.
## Validation scope
Tests cover schema/value rejection, references, inheritance, axes, collisions, deterministic CSS, browser bundling, CLI behavior, contracts, explicit component choices, state ordering, nested islands, hydration, late SSR recipe insertion and baseline compatibility. Phase 2 additionally verifies all three presets in a real browser, including light/dark, linked/inline delivery, form input, switches, dialogs/focus, menus and 390px layouts. See the Phase 2 report under `docs/plans` for results and screenshot locations. Phase 3 extends source hooks, contracts and recipes to all 104 components and verifies all catalog default states across three systems and two modes. See its implementation report for limits and evidence. Complete accessibility/visual certification remains separate work.
## Local authoring
Run `bun run design:studio` from the repository root for the optional non-AI editor. See [Design Studio](../design-studio/README.md) for drafts, previews, imports, exports and application revisions. Applications continue using this engine without Studio runtime dependencies.
## Packaging, shared designs and recovery
See [RELEASE.md](RELEASE.md) for installed Studio/MCP usage, deterministic shared design packages, explicit upgrades, storage migration and the local release workflow. Existing APIs and default component behavior remain available.
+72
View File
@@ -0,0 +1,72 @@
# Design release and upgrade guide
These packages support Bun 1.3 or newer. The design engine and Studio root/upgrade APIs are browser safe. Studio's server, CLI and agent service are local authoring tools; an application only needs the engine, theme adapter and UI components.
## Installed use
Install `@wrnexus/design-system` for the data engine. Add `@wrnexus/design-studio` for authoring; the installed `wrnexus-design-studio --app-root EXISTING_APP` binary binds to loopback port 4320 (`DESIGN_STUDIO_PORT` overrides it). Without `--app-root`, only exports are enabled. No AI key is needed. Existing `createStudioHandler` programmatic integration remains available from `@wrnexus/design-studio/server`.
Install `@wrnexus/mcp` for the existing eight framework tools. Studio is an **optional peer**, so a normal MCP-only installation does not install or load Studio. Install compatible Studio explicitly and use `wrnexus-mcp --root=EXISTING_APP --design` for inspection or `--design-write` for draft and bundle mutations. Missing Studio produces `WRN-MCP-DESIGN-INSTALL` when a design operation is invoked. The default server still exposes eight tools.
Studio packages ship compiled JS/declarations, component metadata through declared dependencies, and a prebuilt browser client. Published packages do not require a sibling source checkout. Application configuration and routes are never rewritten implicitly.
## Share a pinned design
```ts
import { createDesignPackage, readDesignPackage } from "@wrnexus/design-system/package";
// Run after validating/reviewing your self-contained canonical design:
const artifact = await createDesignPackage(system, "Reviewed brand release");
const source = JSON.stringify(artifact);
// Store this JSON with your application or distribute it in your own versioned package.
const verified = await readDesignPackage(source);
const theme = { designSystem: { system: verified.package.system } };
```
Assign that theme through the existing app configuration. Alternatively use `compileDesignSystem` to generate standalone CSS; avoid loading both integrations. Explicit app tokens and instance props retain precedence.
The envelope has format version 1, contract version 1, the canonical schema-v1 design and a deterministic SHA-256 content digest. Object key order does not change the pin. The digest detects changed content; it is not a publisher signature. Use a trusted distribution source and pin the artifact together with engine/UI package versions and the app lockfile. Version-1 contracts do not replace visual regression review after dependency updates.
Bundles are self-contained: unresolved external inheritance is rejected. Resolve and flatten your inherited specification before sharing it. Names and types remain canonical; there is no automatic token renaming. Package source is capped at 2 MB and release notes at 2,000 characters.
## Review an upgrade
```ts
import { planDesignUpgrade, applyDesignUpgrade } from "@wrnexus/design-studio/upgrades";
const plan = await planDesignUpgrade(session, incomingJson);
// Review plan.changes, diagnostics, migrations and the matching page previews.
if (plan.canApply) await applyDesignUpgrade(session, incomingJson, plan.baseRevision);
// Existing session.undo()/redo() and autosave still apply.
```
Planning does not mutate the session. Adoption rereads and verifies the package, checks system identity and the exact current design/lock revision, then uses the existing history mechanism. Invalid source must be resolved first. Locked decisions and their effective alias/recipe dependencies remain protected. Another design id requires an explicit import. Version numbers do not trigger automatic downloads or updates: a supported version with the same id may be adopted explicitly after review, including a deliberate rollback.
MCP provides `wrnexus_design_package` to export a verified package, `wrnexus_design_upgrade_plan` to review incoming `source`, and `wrnexus_design_upgrade` with `source` plus the latest `baseRevision` to adopt it. Existing draft/application revisions remain separate. Application changes still require `baseApplicationRevision`; exporting or upgrading a draft never deploys it.
## Migration and recovery
- Existing canonical schema-v1 JSON can be wrapped through `readDesignPackage`; it reports `canonical-v1-to-package-v1`. Existing canonical/legacy/token imports continue to use the existing explicit import APIs.
- Old Studio browser drafts and agent state without a storage version remain readable. Successful subsequent saves add storage version 1, retaining source history, invalid raw edits, locks and saved pages.
- Unknown package formats or contract versions fail closed. Unknown/faulty browser storage is kept byte-for-byte: `session.recoverySource` exposes the saved bytes and automatic writes stay blocked until storage is deliberately reset after backup. Keep the original browser storage key `wrnexus.design-studio.v1`; do not clear it before exporting recovery data. If you are using the UI, back up that local-storage value before resetting it; new package/upgrade APIs are programmatic, not additional Studio controls.
- Unknown agent-state versions also fail closed; `STATE.json` is not rewritten. Back up `.wrnexus/design-studio` before changing authoring versions. Older software cannot promise to read newer formats; retain a supported tool version alongside the backup.
- Agent state lives under the configured app's `.wrnexus/design-studio/agent`; browser local storage remains separate. Application revisions are immutable bundles selected by `CURRENT.json`. Existing application pointers and bundles need no migration.
- On a crash-held `LOCK` or `APPLY.lock`, stop all writers and back up the managed directory before removing the empty lock directory. Locks are never stolen automatically. Do not edit saved state with active writers.
Upgrading Studio does not apply a theme, alter app configuration or rewrite route files. Exported pages and generated theme bundles still require explicit application integration. Preview execution remains limited to trusted shipped components; submitted arbitrary source is only statically checked.
## Local release workflow (maintainers)
From the framework repository:
```sh
bun run design:release:plan
bun run design:release:stage
bun run design:release:check
# Full repository production checks followed by installed-consumer checks:
bun run design:release:gate
```
Staging builds the complete declared local dependency closure under `.publish-design`, preserving the standard `.publish` staging area. It validates assets/declarations, rewrites workspace ranges and preserves optional-peer metadata. Checks create real npm tarballs and install them into temporary consumers with lifecycle scripts disabled. They exercise two independent apps, shared pins, upgrades/recovery, browser-safe exports, prebuilt Studio assets and CLI, real component previews, installed types, MCP stdio and a separate MCP-only installation.
These commands never authenticate, bump source versions, publish or deploy. Output reports and tarballs are under `output/design-system-phase-7`. A successful packaging report is **not** a publication approval or a passing repository-wide production gate. Before publishing, confirm unused package versions against the target registry, run the existing version/release process intentionally, restage, rerun all production and consumer gates, and review the package integrity manifest. The default staged access remains restricted. Do not automatically switch package access or publish dependencies to a public registry.
@@ -0,0 +1,27 @@
{
"schemaVersion": 1,
"id": "square",
"name": "Square",
"version": "1.0.0",
"defaultMode": "dark",
"tokens": {
"brand.primary": { "type": "color", "value": "#2563eb" },
"action.primary": {
"type": "color",
"value": { "ref": "brand.primary" },
"legacy": "color-primary"
},
"control.radius": { "type": "dimension", "value": "0", "legacy": "radius-sm" },
"panel.radius": { "type": "dimension", "value": "0", "legacy": "radius-md" },
"dialog.radius": { "type": "dimension", "value": "0", "legacy": "radius-lg" }
},
"modes": {
"light": {},
"dark": { "brand.primary": { "type": "color", "value": "#60a5fa" } }
},
"densities": {
"compact": {
"control.height": { "type": "dimension", "value": "2rem", "legacy": "control-height" }
}
}
}
+16
View File
@@ -0,0 +1,16 @@
{
"name": "@wrnexus/design-system",
"version": "0.1.0",
"description": "Typed, deterministic design tokens and legacy WrNexus theme integration.",
"type": "module",
"main": "src/index.ts",
"exports": {
".": "./src/index.ts",
"./schema.json": "./schema.json",
"./package": "./src/package.ts"
},
"files": [
"schema.json",
"RELEASE.md"
]
}
+568
View File
@@ -0,0 +1,568 @@
{
"$schema": "https://json-schema.org/draft/2020-12/schema",
"$id": "https://wrnexus.dev/schemas/design-system-v1.json",
"title": "WrNexus Design System v1 (tokens and core component recipes)",
"type": "object",
"additionalProperties": false,
"required": ["schemaVersion", "id", "name", "version", "tokens"],
"properties": {
"schemaVersion": {
"const": 1
},
"id": {
"$ref": "#/$defs/name"
},
"name": {
"type": "string",
"minLength": 1
},
"version": {
"type": "string",
"minLength": 1
},
"description": {
"type": "string"
},
"extends": {
"type": "array",
"items": {
"$ref": "#/$defs/name"
}
},
"tokens": {
"$ref": "#/$defs/tokens"
},
"defaultMode": {
"$ref": "#/$defs/name"
},
"modes": {
"$ref": "#/$defs/axis"
},
"densities": {
"$ref": "#/$defs/axis"
},
"recipes": {
"type": "object",
"additionalProperties": {
"$ref": "#/$defs/recipe"
}
}
},
"$defs": {
"name": {
"type": "string",
"pattern": "^[a-zA-Z][a-zA-Z0-9_.-]*$"
},
"axis": {
"type": "object",
"propertyNames": {
"$ref": "#/$defs/name"
},
"additionalProperties": {
"$ref": "#/$defs/tokens"
}
},
"tokens": {
"type": "object",
"propertyNames": {
"$ref": "#/$defs/name"
},
"additionalProperties": {
"$ref": "#/$defs/token"
}
},
"token": {
"type": "object",
"additionalProperties": false,
"required": ["type", "value"],
"properties": {
"type": {
"enum": [
"color",
"dimension",
"number",
"fontFamily",
"duration",
"shadow",
"fontWeight",
"colorScheme"
]
},
"value": {
"oneOf": [
{
"type": "string",
"minLength": 1,
"maxLength": 2048
},
{
"type": "number"
},
{
"type": "object",
"additionalProperties": false,
"required": ["ref"],
"properties": {
"ref": {
"$ref": "#/$defs/name"
}
}
}
]
},
"legacy": {
"type": "string",
"pattern": "^[a-z][a-z0-9-]*$"
},
"description": {
"type": "string"
}
}
},
"recipeStyles": {
"type": "object",
"additionalProperties": false,
"properties": {
"borderRadius": {
"oneOf": [
{
"type": "string"
},
{
"type": "number"
},
{
"type": "object",
"additionalProperties": false,
"required": ["ref"],
"properties": {
"ref": {
"$ref": "#/$defs/name"
}
}
}
]
},
"borderWidth": {
"oneOf": [
{
"type": "string"
},
{
"type": "number"
},
{
"type": "object",
"additionalProperties": false,
"required": ["ref"],
"properties": {
"ref": {
"$ref": "#/$defs/name"
}
}
}
]
},
"borderColor": {
"oneOf": [
{
"type": "string"
},
{
"type": "number"
},
{
"type": "object",
"additionalProperties": false,
"required": ["ref"],
"properties": {
"ref": {
"$ref": "#/$defs/name"
}
}
}
]
},
"borderStyle": {
"oneOf": [
{
"type": "string"
},
{
"type": "number"
},
{
"type": "object",
"additionalProperties": false,
"required": ["ref"],
"properties": {
"ref": {
"$ref": "#/$defs/name"
}
}
}
]
},
"boxShadow": {
"oneOf": [
{
"type": "string"
},
{
"type": "number"
},
{
"type": "object",
"additionalProperties": false,
"required": ["ref"],
"properties": {
"ref": {
"$ref": "#/$defs/name"
}
}
}
]
},
"paddingInline": {
"oneOf": [
{
"type": "string"
},
{
"type": "number"
},
{
"type": "object",
"additionalProperties": false,
"required": ["ref"],
"properties": {
"ref": {
"$ref": "#/$defs/name"
}
}
}
]
},
"paddingBlock": {
"oneOf": [
{
"type": "string"
},
{
"type": "number"
},
{
"type": "object",
"additionalProperties": false,
"required": ["ref"],
"properties": {
"ref": {
"$ref": "#/$defs/name"
}
}
}
]
},
"gap": {
"oneOf": [
{
"type": "string"
},
{
"type": "number"
},
{
"type": "object",
"additionalProperties": false,
"required": ["ref"],
"properties": {
"ref": {
"$ref": "#/$defs/name"
}
}
}
]
},
"minHeight": {
"oneOf": [
{
"type": "string"
},
{
"type": "number"
},
{
"type": "object",
"additionalProperties": false,
"required": ["ref"],
"properties": {
"ref": {
"$ref": "#/$defs/name"
}
}
}
]
},
"color": {
"oneOf": [
{
"type": "string"
},
{
"type": "number"
},
{
"type": "object",
"additionalProperties": false,
"required": ["ref"],
"properties": {
"ref": {
"$ref": "#/$defs/name"
}
}
}
]
},
"backgroundColor": {
"oneOf": [
{
"type": "string"
},
{
"type": "number"
},
{
"type": "object",
"additionalProperties": false,
"required": ["ref"],
"properties": {
"ref": {
"$ref": "#/$defs/name"
}
}
}
]
},
"backgroundImage": {
"oneOf": [
{
"type": "string"
},
{
"type": "number"
},
{
"type": "object",
"additionalProperties": false,
"required": ["ref"],
"properties": {
"ref": {
"$ref": "#/$defs/name"
}
}
}
]
},
"fontFamily": {
"oneOf": [
{
"type": "string"
},
{
"type": "number"
},
{
"type": "object",
"additionalProperties": false,
"required": ["ref"],
"properties": {
"ref": {
"$ref": "#/$defs/name"
}
}
}
]
},
"fontSize": {
"oneOf": [
{
"type": "string"
},
{
"type": "number"
},
{
"type": "object",
"additionalProperties": false,
"required": ["ref"],
"properties": {
"ref": {
"$ref": "#/$defs/name"
}
}
}
]
},
"fontWeight": {
"oneOf": [
{
"type": "string"
},
{
"type": "number"
},
{
"type": "object",
"additionalProperties": false,
"required": ["ref"],
"properties": {
"ref": {
"$ref": "#/$defs/name"
}
}
}
]
},
"letterSpacing": {
"oneOf": [
{
"type": "string"
},
{
"type": "number"
},
{
"type": "object",
"additionalProperties": false,
"required": ["ref"],
"properties": {
"ref": {
"$ref": "#/$defs/name"
}
}
}
]
},
"outlineColor": {
"oneOf": [
{
"type": "string"
},
{
"type": "number"
},
{
"type": "object",
"additionalProperties": false,
"required": ["ref"],
"properties": {
"ref": {
"$ref": "#/$defs/name"
}
}
}
]
},
"opacity": {
"oneOf": [
{
"type": "string"
},
{
"type": "number"
},
{
"type": "object",
"additionalProperties": false,
"required": ["ref"],
"properties": {
"ref": {
"$ref": "#/$defs/name"
}
}
}
]
},
"rotate": {
"oneOf": [
{
"type": "string"
},
{
"type": "number"
},
{
"type": "object",
"additionalProperties": false,
"required": ["ref"],
"properties": {
"ref": {
"$ref": "#/$defs/name"
}
}
}
]
}
}
},
"recipeParts": {
"type": "object",
"propertyNames": {
"$ref": "#/$defs/name"
},
"additionalProperties": {
"$ref": "#/$defs/recipeStyles"
}
},
"recipe": {
"type": "object",
"additionalProperties": false,
"required": ["contractVersion"],
"properties": {
"contractVersion": {
"const": 1
},
"base": {
"$ref": "#/$defs/recipeParts"
},
"variants": {
"type": "array",
"items": {
"type": "object",
"additionalProperties": false,
"required": ["when", "parts"],
"properties": {
"when": {
"type": "object",
"minProperties": 1,
"additionalProperties": {
"type": "string"
}
},
"parts": {
"$ref": "#/$defs/recipeParts"
}
}
}
},
"states": {
"type": "array",
"items": {
"type": "object",
"additionalProperties": false,
"required": ["state", "parts"],
"properties": {
"state": {
"type": "string"
},
"parts": {
"$ref": "#/$defs/recipeParts"
}
}
}
}
}
}
},
"$comment": "Runtime validation additionally checks typed CSS values, references, inheritance, contract parts/axes/states, property constraints, and output collisions. Executable imports are unsupported."
}
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;
}
+92
View File
@@ -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;
+9
View File
@@ -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
+41
View File
@@ -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";
+96
View File
@@ -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: [] };
}
+255
View File
@@ -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;
+312
View File
@@ -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 ?? [])],
};
}
}
+117
View File
@@ -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,
};
}
+84
View File
@@ -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>>;
}
+47
View File
@@ -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;
}
}
+221
View File
@@ -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);
}
+188
View File
@@ -0,0 +1,188 @@
import { describe, test, expect } from "bun:test";
import {
adaptLegacyTheme,
compileDesignSystem,
defineDesignSystem,
resolveDesignSystem,
validateDesignSystem,
DesignSystemError,
type DesignSystem,
} from "../src/index.ts";
const system = (): DesignSystem => ({
schemaVersion: 1,
id: "workshop",
name: "Workshop",
version: "1.0.0",
defaultMode: "dark",
tokens: {
"brand.blue": { type: "color", value: "#2563eb" },
"action.primary": { type: "color", value: { ref: "brand.blue" }, legacy: "color-primary" },
"control.radius": { type: "dimension", value: "0", legacy: "radius-sm" },
"body.font": { type: "fontFamily", value: '"Inter", sans-serif', legacy: "font-sans" },
},
modes: {
light: { "brand.blue": { type: "color", value: "#1d4ed8" } },
dark: { "brand.blue": { type: "color", value: "#60a5fa" } },
},
densities: { compact: { "control.radius": { type: "dimension", value: "2px" } } },
});
describe("design-system engine", () => {
test("resolves references after mode/density overrides and records provenance", () => {
const result = resolveDesignSystem(system(), { mode: "light", density: "compact" });
expect(result.tokens["action.primary"]).toEqual({
type: "color",
value: "#1d4ed8",
legacy: "color-primary",
reference: "brand.blue",
source: "workshop.tokens.action.primary",
});
expect(result.tokens["control.radius"]?.value).toBe("2px");
expect(result.tokens["control.radius"]?.legacy).toBe("radius-sm");
});
test("merges extensions in declared order, then selected mode", () => {
const base = system();
base.id = "base";
const second: DesignSystem = {
...base,
id: "second",
tokens: { "control.radius": { type: "dimension", value: "4px" } },
modes: {},
};
const child: DesignSystem = {
schemaVersion: 1,
id: "child",
name: "Child",
version: "1",
extends: ["base", "second"],
tokens: {},
};
expect(
resolveDesignSystem(child, { registry: { base, second } }).tokens["control.radius"]?.value,
).toBe("4px");
expect(
resolveDesignSystem(child, { registry: { base, second } }).tokens["action.primary"]?.value,
).toBe("#60a5fa");
});
test("rejects missing parents, extension cycles and alias cycles", () => {
const a = system();
a.extends = ["missing"];
expect(() => resolveDesignSystem(a)).toThrow("WRN-DS-EXTENDS-MISSING");
a.extends = ["workshop"];
expect(() => resolveDesignSystem(a, { registry: { workshop: a } })).toThrow(
"WRN-DS-EXTENDS-CYCLE",
);
const b = system();
delete b.defaultMode;
delete b.modes;
b.tokens["brand.blue"]!.value = { ref: "action.primary" };
expect(() => resolveDesignSystem(b)).toThrow("WRN-DS-REFERENCE-CYCLE");
});
test("rejects missing references, type mismatches and unknown axes", () => {
const a = system();
a.tokens["action.primary"]!.value = { ref: "missing" };
expect(() => resolveDesignSystem(a)).toThrow("WRN-DS-REFERENCE-MISSING");
a.tokens["action.primary"]!.value = { ref: "control.radius" };
expect(() => resolveDesignSystem(a)).toThrow("WRN-DS-REFERENCE-TYPE");
expect(() => resolveDesignSystem(system(), { mode: "unknown" })).toThrow("WRN-DS-MODE");
expect(() => resolveDesignSystem(system(), { density: "unknown" })).toThrow("WRN-DS-DENSITY");
a.modes!.dark!["control.radius"] = { type: "color", value: "#fff" };
expect(() => resolveDesignSystem(a)).toThrow("WRN-DS-TYPE");
});
test("reports stable schema diagnostics and rejects unsafe values", () => {
expect(validateDesignSystem(null)[0]?.code).toBe("WRN-DS-SCHEMA");
for (const value of [
"red;}body{display:none",
"url(https://example.com)",
"var(--arbitrary)",
"#fff</style>",
"/* hidden */#fff",
]) {
const a = system();
a.tokens["brand.blue"]!.value = value;
expect(validateDesignSystem(a).some((d) => d.code === "WRN-DS-VALUE")).toBe(true);
}
expect(() => defineDesignSystem({ ...system(), schemaVersion: 2 } as never)).toThrow(
DesignSystemError,
);
expect(validateDesignSystem({ ...system(), layouts: {} })[0]?.code).toBe("WRN-DS-FIELD");
const a = system();
Object.defineProperty(a.tokens, "constructor", {
value: { type: "number", value: 2 },
enumerable: true,
});
expect(validateDesignSystem(a).some((d) => d.code === "WRN-DS-NAME")).toBe(true);
});
test("compiles deterministic CSS regardless of token insertion order", () => {
const a = system();
const b = system();
b.tokens = Object.fromEntries(Object.entries(b.tokens).reverse());
expect(compileDesignSystem(a).css).toBe(compileDesignSystem(b).css);
expect(compileDesignSystem(a).css).toContain("--wrn-radius-sm:0;");
expect(compileDesignSystem(a).css).toContain(':root[data-theme="light"]');
expect(compileDesignSystem(a, { scope: "preview" }).css).toStartWith(
'[data-wrn-design="preview"]',
);
expect(() => compileDesignSystem(a, { scope: 'bad"]{}' })).toThrow("WRN-DS-NAME");
});
test("rejects output collisions instead of silently choosing a token", () => {
const a = system();
a.tokens["control-radius"] = { type: "dimension", value: "3px" };
expect(() => compileDesignSystem(a)).toThrow("WRN-DS-COLLISION");
delete a.tokens["control-radius"];
a.tokens.other = { type: "dimension", value: "3px", legacy: "radius-sm" };
expect(() => adaptLegacyTheme(a, {})).toThrow("WRN-DS-COLLISION");
});
test("adapter preserves app configuration and explicit mode overrides without mutation", () => {
const app = { default: "light", themes: { light: { "radius-sm": "9px" } }, palette: "rose" };
const before = JSON.stringify(app);
const result = adaptLegacyTheme(system(), app);
expect(result.default).toBe("light");
expect(result.palette).toBe("rose");
expect(result.themes?.light?.["radius-sm"]).toBe("9px");
expect(result.themes?.dark?.["radius-sm"]).toBe("0");
expect(JSON.stringify(app)).toBe(before);
});
test("semantic validation checks unselected modes and densities", () => {
const a = system();
a.densities!.spacious = { bad: { type: "color", value: { ref: "missing" } } };
expect(validateDesignSystem(a)[0]?.code).toBe("WRN-DS-REFERENCE-MISSING");
delete a.densities;
a.modes!.light!.bad = { type: "color", value: { ref: "missing" } };
expect(validateDesignSystem(a)[0]?.code).toBe("WRN-DS-REFERENCE-MISSING");
});
test("validates typed dimensions, typography, duration, shadows and color scheme", () => {
const a: DesignSystem = {
schemaVersion: 1,
id: "types",
name: "Types",
version: "1",
tokens: {
size: { type: "dimension", value: "1.5rem" },
weight: { type: "fontWeight", value: 600 },
time: { type: "duration", value: "150ms" },
alpha: { type: "number", value: 0.5 },
shadow: { type: "shadow", value: "0 2px 4px rgba(0, 0, 0, 0.2)" },
scheme: { type: "colorScheme", value: "dark", legacy: "color-scheme" },
color: { type: "color", value: "oklch(65% 0.2 250 / 90%)" },
},
};
expect(validateDesignSystem(a)).toEqual([]);
expect(compileDesignSystem(a).css).toContain("color-scheme:dark;");
a.tokens.shadow!.value = "2px invalid";
expect(validateDesignSystem(a)[0]?.code).toBe("WRN-DS-VALUE");
});
test("browser entry bundles without filesystem or provider dependencies", async () => {
const build = await Bun.build({
entrypoints: [
new URL("../src/index.ts", import.meta.url).pathname.replace(/^\/([A-Za-z]:)/, "$1"),
],
target: "browser",
});
expect(build.success).toBe(true);
const output = await build.outputs[0]!.text();
expect(output).not.toContain("node:fs");
expect(output).not.toContain("ANTHROPIC_API_KEY");
});
});
@@ -0,0 +1,99 @@
import { test, expect } from "bun:test";
import {
CORE_DESIGN_SYSTEMS,
compileDesignSystem,
validateDesignSystem,
resolveDesignSystem,
type DesignSystem,
} from "../src/index.ts";
const sample = (): DesignSystem => ({
schemaVersion: 1,
id: "recipe",
name: "Recipe",
version: "1",
tokens: { radius: { type: "dimension", value: "3px" } },
recipes: {
Button: { contractVersion: 1, base: { control: { borderRadius: { ref: "radius" } } } },
},
});
test("three presets compile deterministically in both modes", () => {
for (const system of Object.values(CORE_DESIGN_SYSTEMS)) {
expect(validateDesignSystem(system)).toEqual([]);
for (const mode of ["light", "dark"])
expect(compileDesignSystem(system, { mode }).recipeCss).toBe(
compileDesignSystem(structuredClone(system), { mode }).recipeCss,
);
}
});
test("contracts reject unknown components, parts, axes, states, versions and executable style values", () => {
for (const recipes of [
{ Unknown: { contractVersion: 1 } },
{ Button: { contractVersion: 2 } },
{ Button: { contractVersion: 1, base: { missing: { color: "#fff" } } } },
{ Button: { contractVersion: 1, base: { control: { position: "fixed" } } } },
{ Button: { contractVersion: 1, base: { control: { color: "red;}body{display:none" } } } },
{
Button: {
contractVersion: 1,
variants: [{ when: { variant: "invented" }, parts: { control: { color: "#fff" } } }],
},
},
{
Button: {
contractVersion: 1,
states: [{ state: "checked", parts: { control: { color: "#fff" } } }],
},
},
{ Modal: { contractVersion: 1, base: { panel: { rotate: "2deg" } } } },
])
expect(validateDesignSystem({ ...sample(), recipes })[0]?.code).toBe("WRN-DS-RECIPE");
});
test("recipe token references are checked against property types and missing tokens", () => {
const system = sample();
system.recipes!.Button!.base!.control!.borderRadius = { ref: "missing" };
expect(validateDesignSystem(system)[0]?.code).toBe("WRN-DS-REFERENCE-MISSING");
system.tokens.radius = { type: "color", value: "#fff" };
system.recipes!.Button!.base!.control!.borderRadius = { ref: "radius" };
expect(validateDesignSystem(system)[0]?.code).toBe("WRN-DS-REFERENCE-TYPE");
});
test("inheritance merges base properties and preserves ordered compound/state rules", () => {
const parent = sample();
parent.id = "parent";
const child: DesignSystem = {
...sample(),
extends: ["parent"],
recipes: {
Button: {
contractVersion: 1,
base: { control: { fontWeight: 700 } },
variants: [
{ when: { variant: "outline", size: "sm" }, parts: { control: { borderWidth: "2px" } } },
],
states: [{ state: "disabled", parts: { control: { opacity: 0.5 } } }],
},
},
};
const resolved = resolveDesignSystem(child, { registry: { parent } });
expect(resolved.recipes.Button?.base?.control).toEqual({
borderRadius: { ref: "radius" },
fontWeight: 700,
});
const css = compileDesignSystem(child, { registry: { parent } }).recipeCss;
expect(css.indexOf("font-weight:700")).toBeLessThan(css.indexOf("border-width:2px"));
expect(css.indexOf("border-width:2px")).toBeLessThan(css.indexOf("opacity:0.5"));
expect(css).not.toContain("!important");
});
test("mode-dependent recipe values retain mode rules while identical values are deduplicated", () => {
const system = sample();
system.modes = {
light: { radius: { type: "dimension", value: "3px" } },
dark: { radius: { type: "dimension", value: "9px" } },
};
system.defaultMode = "light";
const css = compileDesignSystem(system).recipeCss;
expect(css).toContain('[data-theme="dark"]');
expect(css).toContain("9px");
expect(css).not.toContain('[data-theme="light"]');
});