Skip to main content
Auric Artisan · Documentation

Design System Generator Developer Reference

A source-level map of tool/design-engine/index.html, js/tool/design-engine/design-engine.js and js/tool/design-engine/advanced-exports.js: colour maths, the generation pipeline, token shapes, exporters, the live playground and every integration seam.

Updated: September 3, 2026 Route: /tool/design-engine/ Runtime: design-engine.js Reading time: about 30 minutes Author: Chirag Bansal
Back to Documentation Auric Artisan Home

Overview

The Design System Generator composes a whole design system in the browser from three inputs — industry, visual style and a WCAG contrast target — plus ten optional advanced overrides. There is no backend. The output is deterministic given the inputs, which is what makes the share link reproduce a system exactly.

This reference is for someone reading or extending the code. It states the module boundary, the colour maths, the knowledge bases the generator draws on, the shape of the system object, how role tokens are solved against a contrast requirement, how tokens are flattened for twelve exporters, how the component playground layers preview-only overrides, and where the tool touches the rest of the site. Where a limit exists, it is written down rather than smoothed over.

Table of contents

  1. 1. Runtime overview and file map
  2. 2. DOM contract and configuration inputs
  3. 3. Colour maths and gamut mapping
  4. 4. Knowledge bases: industry, style, fonts, icons
  5. 5. generate(cfg): the composition pipeline
  6. 6. Type, spacing, radius, shadow and motion scales
  7. 7. Contrast audit and quality score
  8. 8. Token flattening and the twelve exporters
  9. 9. Component playground and the preview contract
  10. 10. Integrations: workspace, bridge, font and icon CDNs
  11. 11. State, storage keys and the URL contract
  12. 12. Tests, performance and known limits

1. Runtime overview and file map

+

Two ES modules do the work. design-engine.js is an IIFE loaded with type="module"; it owns the DOM, the state and every renderer. advanced-exports.js is pure and DOM-free, with its own duplicate hex/OKLCH/contrast maths so that it can run under node --test. The only import between them is one line at the top of the engine:

import { ADVANCED_EXPORTS, apcaLc, apcaRating } from "/js/tool/design-engine/advanced-exports.js";

Nothing else on the page is tool-specific. The route also loads /js/unified.js, the two Auric CDN scripts, and a generated inline theme bootstrap.

FileRole
tool/design-engine/index.html Route, SEO and JSON-LD, masthead and header actions, the hidden config inputs, tab bar, the two rail hosts, seven tab panels and four view panels, plus the three CVD feColorMatrix filter definitions.
js/tool/design-engine/design-engine.js 3,222 lines. Colour maths, knowledge bases, generate(), the fourteen panel renderers, the component gallery, exporters, wiring and boot.
js/tool/design-engine/advanced-exports.js 323 lines. APCA Lc, its rating bands, and five exporters (modern CSS, Android, SwiftUI, Flutter, Compose) plus the ADVANCED_EXPORTS registry.
css/design-engine.css 2,782 lines of generator-unique styling — the de-* result chrome, 176 distinct dep-* classes for the component gallery, and the depc-* playground controls.
/css/analyzer.css The shared tool shell: .anz-panel, .anz-card, .anz-empty, .anz-ring. The workbench's own layout — .de-tabs, .de-band, .de-rail, .de-stage — lives in /css/design-engine.css.
/css/color-science-lab.css The .ax-lab token deck applied to the results container.
/js/components/dropdown.js The site's SelectPicker, which upgrades the brief's five <select>s in place. The tool wires no dropdown behaviour of its own.
/js/library/api.js (window.AALibrary) Workspace save, query, remove and asset events. The declarative attachTool binding is defined in /js/library/integrate.js and merged onto the same window.AALibrary object.
https://fonts.auricartisan.com/font-cdn.js Exposes window.AuricFonts; loads the chosen families from the self-hosted font library.
https://icons.auricartisan.com/icon-cdn.js Exposes window.AuricIcons; upgrades inline preview icons to the recommended set.
tests/design-engine/advanced-exports.test.js The only automated coverage. Run with npm run test:design-engine.

The tool is registered in data/collections.json under the category “Design Systems”, complexity “basic”, and is fully translated for Hindi at data/i18n/hi/pages/tool__design-engine.json.

2. DOM contract and configuration inputs

+

The runtime reads fixed ids. boot() returns immediately if #de-app is absent, so the module is inert on any other page. Rename an id only with a matching change in the runtime.

Configuration inputs

All thirteen config values are read in one place, readConfig(). Industry, style, harmony, corner and ratio are <select>s that buildBrief() renders into the brief rail; the segmented choices write to hidden <input>s; the seed pair and the token prefix are ordinary fields. The ramp-shape and token-naming overrides are covered in sections 3 and 8.

IdValuesDefaultEffect
#de-industry15 keys, auto, fintech…wellnessautoSeeds hue, chroma appetite, mood string and the tag set used to score fonts and icons.
#de-style10 keys, modern…futuristicmodernSaturation multiplier, corner family, shadow preset, heading/body weight, type ratio, motion personality.
#de-a11yAA-large / AA / AAAAAAMaps to 3 / 4.5 / 7 and becomes meta.target.
#de-seed, #de-seed-hex<input type="color"> and a text field#2f6df0 and the literal string autoA valid hex overrides the industry hue. auto or empty means derive it.
#de-modelight / dark / bothbothlight suppresses the dark role block, the dark token group and the preview theme switch.
#de-densitycompact / comfortable / spaciouscomfortableBase font size 15 / 16 / 17 px and a 0.9 / 1.0 / 1.15 spacing multiplier.
#de-harmonyauto or one of six schemesautoHue offsets for the secondary and accent ramps.
#de-ratioauto or 1.125 / 1.2 / 1.25 / 1.333 / 1.414 / 1.5 / 1.618autoModular ratio for the type scale.
#de-cornerauto / sharp / soft / rounded / pillautoSelects one of the four CORNER radius families.
function readConfig() {
  const hex = (ID("de-seed-hex")?.value || "auto").trim();
  return {
    industry: ID("de-industry")?.value || "auto",
    rampSpan: ID("de-ramp-span")?.value || "default",
    rampPeak: ID("de-ramp-peak")?.value || "default",
    tokenPrefix: (ID("de-token-prefix")?.value || "").trim(),
    tokenCase: ID("de-token-case")?.value || "kebab",
    style:    ID("de-style")?.value    || "modern",
    a11y:     ID("de-a11y")?.value     || "AAA",
    seedHex:  (hex === "auto" || hex === "") ? null : hex,
    mode:     ID("de-mode")?.value     || "both",
    density:  ID("de-density")?.value  || "comfortable",
    harmony:  ID("de-harmony")?.value  || "auto",
    ratio:    ID("de-ratio")?.value    || "auto",
    corner:   ID("de-corner")?.value   || "auto",
  };
}

Structural ids and attributes

SelectorPurpose
#de-appRoot container. Gains .de-has-system after the first successful generate.
#de-surprise, #de-share, #de-saveHeader actions. There is no Generate button: every control change regenerates the system.
#de-expandExpand / Collapse. Hides both rails so the panel gets the full width; remembered in localStorage as de_expanded.
#de-brief, #de-audit-railThe two rails. The brief writes to the hidden config inputs; the audit re-renders on every generation.
#de-result-search, #de-result-clearThe find-token filter.
.de-tab[data-tab]Seven tabs: overview, color, type, icons, scales, components, tokens. Spacing, radius, shadows and motion share the Scales panel; export folded into Tokens.
#p-overview … #p-tokensThe seven panels, each rebuilt with innerHTML. #p-compare, #p-workspace, #p-audit and #p-help are views, reached from the rails and the header rather than the tab rail; switchTab() maps the old fourteen names onto them.
#a11y-badge, #saved-badgeFailing-pairing count and saved-asset count. The current markup carries neither, so both writes are skipped.
[data-view]Opens a view — compare, workspace, audit or help. data-view="back" returns to the last tab.
#cvd-deuteranopia, #cvd-protanopia, #cvd-tritanopiaSVG filter definitions used by the colour-vision preview.

The dropdown seam

Writing a value back into a control is setConfig(inputId, value), kept under its old name setDropdown for applyConfig() and surprise(), which still pass a third label argument it ignores. It sets the input's value, moves the selected attribute on a <select>'s options so the SelectPicker follows, and dispatches a bubbling change event. Because it dispatches change, calling it while a system exists will trigger the debounced regeneration described in section 11.

3. Colour maths and gamut mapping

+

An internal Color IIFE holds every conversion the engine needs. It is exposed on the debug API, so you can call it from the console without regenerating.

SignatureReturns
hexToRgb(hex){ r, g, b } in 0–1. Accepts 3- or 6-digit hex, with or without #.
rgbToHex({ r, g, b })Six-digit lowercase hex, clamped.
isHex(s)true for /^#?([0-9a-f]{3}|[0-9a-f]{6})$/i.
oklchToHex(L, C, h)Gamut-mapped hex. See below.
hexToOklch(hex){ L, C, h } with h normalised to 0–360.
luminance(hex)WCAG relative luminance.
contrast(h1, h2)WCAG 2.1 ratio, order-independent.
onColor(bg, dark = "#0a0a0a", light = "#ffffff")Whichever of the two has the higher ratio on bg.
hexToHslString(hex)"H S% L%", the modern CSS component form.
withAlpha(hex, a)rgba(r, g, b, a) with 0–255 channels.

sRGB to OKLab uses the standard LMS matrices with a cube-root nonlinearity, and the inverse cubes back. The part that matters for output quality is the gamut map: an OKLCH triple whose chroma pushes it outside sRGB has that chroma binary-searched down until it fits, so no ramp step is ever produced by channel clipping.

const inGamut = ({ r, g, b }) => {
  const e = 0.0008;
  return r >= -e && r <= 1 + e && g >= -e && g <= 1 + e && b >= -e && b <= 1 + e;
};

const oklchToHex = (L, C, h) => {
  let rgb = oklchToRgb(L, C, h);
  if (!inGamut(rgb)) {
    let lo = 0, hi = C;
    for (let i = 0; i < 18; i++) {
      const mid = (lo + hi) / 2;
      inGamut(oklchToRgb(L, mid, h)) ? (lo = mid) : (hi = mid);
    }
    rgb = oklchToRgb(L, lo, h);
  }
  return rgbToHex(rgb);
};

Eighteen iterations resolve chroma to roughly one part in 260,000 of the starting value, which is well below a hex step. Lightness and hue are never touched, so the perceptual position of a step is preserved even when its chroma is reduced. The modern-CSS exporter later re-states every colour as oklch() behind a P3 media query precisely to give that lost chroma back on capable displays.

Ramp construction

const RAMP_STEPS = [50, 100, 200, 300, 400, 500, 600, 700, 800, 900, 950];
const RAMP_L     = [0.971, 0.936, 0.882, 0.806, 0.717, 0.640, 0.560, 0.478, 0.396, 0.320, 0.258];
const RAMP_C     = [0.32, 0.48, 0.66, 0.83, 0.95, 1.00, 0.97, 0.90, 0.80, 0.66, 0.54];

const buildRamp = (hue, chroma) => {
  const r = {};
  RAMP_STEPS.forEach((s, i) => { r[s] = Color.oklchToHex(RAMP_L[i], chroma * RAMP_C[i], hue); });
  return r;
};

const NEUTRAL_STEPS = [0, 50, 100, 200, 300, 400, 500, 600, 700, 800, 900, 950, 1000];
const NEUTRAL_L     = [1.0, 0.985, 0.967, 0.928, 0.872, 0.790, 0.690, 0.585, 0.486, 0.392, 0.305, 0.238, 0.165];

At the default ramp shape the lightness anchors are fixed and only chroma and hue vary between ramps; the Ramp shape override (rampSpan tight / default / wide, rampPeak flat / default / vivid) rescales both curves for the three brand ramps through buildRampShaped(), while the semantic ramps stay on the canonical curve. The chroma curve peaks at step 500 and tapers at both ends, which keeps the pale end from looking dirty and the dark end from looking muddy. buildNeutral(hue, tint) uses the same machinery at a near-zero chroma so greys carry the brand temperature, then hard-sets step 0 to #ffffff.

Step pickers

FunctionBehaviour
pickStep(ramp, bg, target, steps) Scans darkest first and returns the first step meeting target on bg; falls back to the darkest step. Used for dark-theme text.
pickStepLight(ramp, bg, target, steps) Scans lightest first, so the returned step is the lightest one that still passes. This is what makes the three light-theme text tiers read as a hierarchy rather than three near-blacks.
pickPrimary(ramp, bg, label) Walks the preference order [600, 700, 500, 800, 400, 900, 300, 950, 200] and returns the first step that is at least 3:1 against bg and admits a black-or-white label at label. Falls back to whichever of 700 and 400 is more visible on bg.

4. Knowledge bases: industry, style, fonts, icons

+

Four hand-written tables carry all the taste in the tool. They are plain objects at the top of design-engine.js; adding an entry needs no other change, because buildBrief() builds the Industry and Style lists from the tables themselves.

INDUSTRY (15 entries)

fintech: {
  label: "Fintech",
  hue: 258,            // OKLCH degrees
  chroma: 0.13,        // chroma appetite
  mood: "trustworthy, precise, secure",
  tags: ["geometric", "neutral", "professional"],
  emoji: "🏦",
}

Hues run from food at 18° through luxury at 44°, wellness at 138°, healthcare at 192°, saas at 264° to creative at 320°. Chroma appetite runs 0.10 (luxury, real estate, wellness) to 0.19 (gaming).

STYLE (10 entries)

FieldMeaningRange in the table
satMulMultiplies the industry chroma.0.62 (minimal) to 1.28 (bold)
cornerDefault CORNER family.sharp, soft, rounded
shadowDefault SHADOW_PRESETS key.subtle, soft, balanced, deep, hard, glow
headWeight / bodyWeightWeights bound into the type roles.600–900 / 400–500
ratioDefault modular ratio.1.2 to 1.5
motionMOTION key.subtle, smooth, snappy, bouncy
tagsJoined with the industry tags for font and icon scoring.—

FONTS (16 pairings) and ICONSETS (7 families)

Both are picked by the same rule: build new Set([...ind.tags, ...sty.tags]), count how many of a candidate's tags are in that set, and keep the highest. The comparison is if (s > bestScore), so a tie resolves to the earlier declaration — order in the array is meaningful.

function pickFont(ind, sty) {
  const want = new Set([...ind.tags, ...sty.tags]);
  let best = FONTS[0], bestScore = -1;
  for (const f of FONTS) {
    let s = f.tags.reduce((acc, t) => acc + (want.has(t) ? 1 : 0), 0);
    if (s > bestScore) { bestScore = s; best = f; }
  }
  return best;
}

A pairing carries heading, body and mono family names, the available weights in w, its tags and a one-line note shown in the Type panel. fontStack(name, kind) turns a family name into a CSS stack: known families come from SYSTEM_STACK, anything matching /serif|playfair|fraunces|cormorant|dm serif/i gets a serif fallback chain, kind === "mono" gets a monospace chain, and everything else gets the sans chain.

The seven icon families are Feather, Lucide, Heroicons, Phosphor, Tabler, Remix Icon and Material Symbols. After scoring, pickIcons nudges the stroke width:

const stroke = sty.label === "Minimal" || sty.label === "Elegant"
             ? Math.max(1.5, best.stroke - 0.25)
             : sty.label === "Bold" || sty.label === "Brutalist"
             ? best.stroke + 0.25
             : best.stroke;
return { key: bestKey, ...best, stroke: round(stroke, 2) };

Harmony, corner, shadow and motion tables

const HARMONY_OFFSETS = {
  complementary: { sec: 180, acc: 150 },
  analogous:     { sec: 32,  acc: -32 },
  triadic:       { sec: 120, acc: 240 },
  split:         { sec: 150, acc: 210 },
  tetradic:      { sec: 90,  acc: 180 },
  monochrome:    { sec: 0,   acc: 0 },
};

const autoHarmony = (style) => ({
  minimal: "monochrome", corporate: "analogous", bold: "complementary",
  playful: "triadic",    elegant: "analogous",   brutalist: "complementary",
  creative: "split",     futuristic: "split",
}[style] || "analogous");
Familynonesmmdlgxl2xl3xlpill
sharp01234689999
soft03681216229999
rounded0610142028369999
pill08142232999999999999
Motion presetfastbasesloweasespring
subtle120200320cubic-bezier(.4,0,.2,1)cubic-bezier(.4,0,.2,1)
smooth150250400cubic-bezier(.4,0,.2,1)cubic-bezier(.34,1.2,.64,1)
snappy110180280cubic-bezier(.2,0,0,1)cubic-bezier(.3,1.3,.5,1)
bouncy160280460cubic-bezier(.34,1.56,.64,1)cubic-bezier(.34,1.7,.5,1)

5. generate(cfg): the composition pipeline

+

generate(cfg) is the single entry point and the only function that produces a system. It is pure with respect to the DOM — it reads nothing and writes nothing — apart from new Date().toISOString() in meta.generatedAt, which is the only source of run-to-run variation.

{
  meta: {
    industry, industryLabel, industryEmoji,
    style, styleLabel, a11y, target, targetLarge,
    harmony, density, ratio, corner, mode,
    seed, hue, mood, rampSpan, rampPeak, tokenPrefix, tokenCase, generatedAt,
    a11yReport,   // attached after the system is built
    a11yReportDark,
    score,        // attached after a11yReport
    scoreParts,
  },
  color: { primary, secondary, accent, neutral, semantic, light, dark, gradients, hues },
  type, icons, spacing,
  radius: { name, scale },
  shadow: { color, scale },
  motion, zIndex, breakpoints,
}

Step 1 — seed and chroma

if (seedHex && Color.isHex(seedHex)) {
  const o = Color.hexToOklch(seedHex);
  hue   = o.h;
  baseC = clamp(o.C * 1.0, 0.05, 0.21);
} else {
  hue     = ind.hue;
  baseC   = clamp(ind.chroma * sty.satMul, 0.03, 0.22);
  seedHex = Color.oklchToHex(0.64, baseC, hue);
}

A supplied seed contributes only hue and chroma; its lightness is discarded, because the ramp supplies lightness. The clamps stop a fully desaturated or fully saturated seed from producing a ramp with no usable middle.

Step 2 — harmony and the three brand ramps

cfg.harmony === "auto" resolves through autoHarmony(cfg.style). Secondary and accent hues are the primary hue plus the offsets, modulo 360. Secondary chroma is baseC × 0.55 under monochrome and baseC × 0.92 otherwise; accent chroma is clamp(baseC × 1.05, 0.06, 0.22).

Step 3 — neutrals and semantics

The neutral tint is clamp(baseC × 0.06, 0.004, 0.018) — enough to carry brand temperature, not enough to read as a colour. The four semantic families are built at canonical hues with chroma 0.15:

KeyHuesolidbgbordertextfgOnSolid
success145°step 600step 50step 200step 700onColor(solid)
warning75°step 500step 50step 200step 700onColor(solid)
danger27°step 600step 50step 200step 700onColor(solid)
info240°step 600step 50step 200step 700onColor(solid)

Warning takes step 500 rather than 600 because yellow at 600 is too dark to read as a warning. Each semantic entry keeps its whole 11-step ramp alongside the four derived values.

Step 4 — role tokens

buildRoles(dark) runs twice. The light branch works against #ffffff; the dark branch against neutral[950]. Both produce the same key set.

// light
const textStep   = pickStepLight(neutral, bg, target, NEUTRAL_STEPS);
const mutedStep  = pickStepLight(neutral, bg, 4.5,    NEUTRAL_STEPS);
const subtleStep = pickStepLight(neutral, bg, 3,      NEUTRAL_STEPS);
const primStep   = pickPrimary(primary, bg, Math.max(target, 4.5));
const hoverStep  = primStep <= 600 ? Math.min(primStep + 100, 950)
                                   : Math.max(primStep - 100, 200);
RoleLight valueDark value
bg#ffffffneutral[950]
bg-subtle / bg-mutedneutral[50] / neutral[100]neutral[900] / oklchToHex(0.27, tint, hue)
surface / elevated#ffffff / #ffffffneutral[900] / neutral[800]
border / border-strongneutral[200] / neutral[300]rgba(255,255,255,.10) / rgba(255,255,255,.20)
text / text-muted / text-subtleSolved at target / 4.5 / 3, lightest passing stepSolved at target (against the lightest dark surface) / 4.5 / 3, darkest passing step
primarypickPrimary(primary, bg, max(target, 4.5))pickPrimary(primary, bg, 4.5)
primary-hoverprimary[hoverStep]primary[max(primStep - 100, 200)]
primary-activeprimary[min(primStep + 100, 950)]primary[min(primStep + 100, 900)]
primary-subtleprimary[50]withAlpha(prim, 0.16)
on-primaryColor.onColor(prim) in both themes
ringwithAlpha(primary[500], 0.4)withAlpha(prim, 0.5)

Both branches also leave _primStep (and _textStep in light) on the object. The underscore prefix is a marker: toJSON's internal clean() strips any key starting with _ before serialising, so these never reach an export.

Note the asymmetry in the light branch: the primary fill is solved with Math.max(target, 4.5), not target. Button labels are normal text, so even on the AA-large (3:1) setting the fill must still admit a 4.5:1 label.

Step 5 — gradients and the rest

Five gradients are emitted: brand (135°, primary 500 to accent 500), brand-soft (primary 400 to secondary 400), sunrise (120°, accent 400 to primary 600), mesh (three radial layers over primary[100]) and subtle (neutral 50 to neutral 100). Type, icons, spacing, radius, shadow and motion follow, then the two bonus scales:

zIndex = { base: 0, dropdown: 1000, sticky: 1100, banner: 1200, overlay: 1300,
           modal: 1400, popover: 1500, toast: 1600, tooltip: 1700 };
breakpoints = { sm: 640, md: 768, lg: 1024, xl: 1280, "2xl": 1536 };

Finally sys.meta.a11yReport = a11yMatrix(sys, "light"), sys.meta.a11yReportDark = a11yMatrix(sys, "dark"), sys.meta.score = scoreSystem(sys) and sys.meta.scoreParts are attached, in that order — the score reads the light report.

6. Type, spacing, radius, shadow and motion scales

+

buildType(font, ratio, base, sty)

Thirteen named sizes are produced from a modular ratio applied over fractional step positions. The fractional positions, multiplied by 0.42, give a scale that is gentler than a pure power sequence at the small end and steeper at the display end.

const names = ["2xs","xs","sm","base","md","lg","xl","2xl","3xl","4xl","5xl","6xl","7xl"];
const steps = [-2.4, -1.6, -0.8, 0, 0.7, 1.4, 2.3, 3.3, 4.5, 5.8, 7.2, 8.8, 10.6];

const px = base * Math.pow(ratio, steps[i] * 0.42);
const fluid = r >= 28
  ? `clamp(${round(r * 0.66, 0)}px, ${round(r / 16 * 0.6 + 1, 2)}rem + 1.6vw, ${round(r, 0)}px)`
  : `${round(r, 0)}px`;
sizes[n] = { px, rem, fluid, lh: round(clamp(1.65 - (r - base) * 0.006, 1.05, 1.7), 2) };

Line height falls linearly as size grows and is clamped to 1.05–1.7. Only sizes of 28 px or more get a fluid clamp(); smaller sizes stay fixed, because fluid body text is a readability problem rather than a feature.

Twelve semantic roles bind a size name, a weight, a line height, tracking and which of the three families to use:

RoleSizeWeightLine heightTrackingFamily
display6xlmin(headWeight + 100, 900)1.05-0.03emheading
h15xlheadWeight1.1-0.02emheading
h24xlheadWeight1.15-0.02emheading
h33xlheadWeight1.2-0.01emheading
h42xlmax(headWeight - 100, 600)1.25-0.01emheading
h5xl6001.30heading
body-lglgbodyWeight1.60body
bodybasebodyWeight1.60body
smallsmbodyWeight1.50.005embody
captionxs5001.40.02embody
overline2xs7001.30.12embody
codesm4001.50mono

The returned type object also carries leading (none 1, tight 1.15, snug 1.3, normal 1.5, relaxed 1.65, loose 2) and tracking (tighter -0.05em through widest 0.12em), the pairing id, the note, and the available weights.

buildSpacing(unit, density)

const mult = density === "spacious" ? 1.15 : density === "compact" ? 0.9 : 1;
const keys = [0, "px", 0.5, 1, 1.5, 2, 2.5, 3, 4, 5, 6, 8, 10, 12, 16, 20, 24, 32, 40];
// "0" -> "0px", "px" -> "1px", otherwise key * unit * mult, rounded to 0.1px

The unit is 4 px for compact and comfortable, 5 px for spacious, so spacious gets both a larger unit and a larger multiplier. Nineteen keys are produced.

buildShadows(presetName, hue, tint)

The shadow colour is brand-tinted at oklchToHex(0.32, max(tint × 2, 0.02), hue), which is why elevation on a generated system never looks like plain black at 10% opacity. Each preset supplies a spread multiplier and an alpha:

PresetspreadalphaNotes
subtle0.50.05—
soft0.80.07—
balanced1.00.09Default fallback.
deep1.350.14—
hard1.00.9hard: true — offset-only, no blur, neo-brutalist.
glow1.10.10glow: true — appends a 24 px brand glow to xl and 2xl.

Non-hard presets compose two rgba layers for sm through 2xl — xs, inner and primary stay single-layer — with offsets and blurs scaled by spread and opacities scaled by alpha. The scale keys are xs, sm, md, lg, xl, 2xl, inner and primary; primary is a coloured drop shadow derived from the brand hue rather than from the neutral shadow colour.

7. Contrast audit and quality score

+

a11yMatrix(sys)

Twelve pairings are checked, each against a requirement chosen by content type rather than one blanket number. Every row reports both the WCAG 2.1 ratio and the APCA Lc with a rating band.

RowForegroundBackgroundRequirementKind
Body texttextbgmeta.targetnormal text
Muted texttext-mutedbg4.5AA body
Subtle texttext-subtlebg3large text
Text on surfacetextsurfacemeta.targetnormal text
Text on muted bgtextbg-mutedmeta.targetnormal text
On-primary labelon-primaryprimary4.5button label
Primary vs bgprimarybg3UI element
Strong borderborder-strongbg1.4decorative
Success / Warning / Danger / Info solidfgOnSolidsolid3UI element
{ theme, rows: [{ label, theme, fgRole, bgRole, fg, bg, translucent, ratio, req, kind, pass, apca, apcaLabel }], fails }

rep.fails drives the count in the audit rail and the first term of the quality score. a11yMatrix(sys, theme) runs twice — meta.a11yReport for the light roles and meta.a11yReportDark for the dark ones, which used to ship unaudited. Translucent role tokens are composited with Color.flatten() before they are measured, because contrast() takes hex and would otherwise read rgba(255,255,255,.2) as pure black.

APCA

apcaLc(textHex, bgHex) in advanced-exports.js implements APCA-W3 0.1.9 with its published constants: trc 2.4, normBG .56, normTXT .57, revTXT .62, revBG .65, blkThrs .022, blkClmp 1.414, scale 1.14, lo .027, deltaY .0005, loClip .1. It returns a signed Lc rounded to one decimal — positive for dark text on a light background — and exactly 0 when the two luminances differ by less than deltaY. The engine takes Math.abs() before display.

apcaRating(lc) bands the absolute value at 90 (fine print), 75 (body text), 60 (fluent text), 45 (large/bold), 30 (headlines), 15 (non-text) and otherwise “insufficient”. The test suite pins the implementation to its reference values: black on white ≈ 106.0, white on black ≈ −107.9, and #888 on white ≈ 63.1.

scoreSystem(sys)

Four weighted components, summed, rounded and clamped to 40–100.

const passRate  = (rep.rows.length - rep.fails) / rep.rows.length;
const a11y      = passRate * 55;                                   // 0..55
const ordered   = lum(L.text) < lum(L["text-muted"])
               && lum(L["text-muted"]) < lum(L["text-subtle"]);
const hierarchy = ordered ? 12 : 5;                                // 12 or 5
const vibrancy  = clamp(Color.hexToOklch(primary[500]).C / 0.16, 0, 1) * 18;  // 0..18
const margin    = clamp((Color.contrast(L.text, L.bg) - t) / t, 0, 1) * 15;   // 0..15

return clamp(Math.round(a11y + hierarchy + vibrancy + margin), 40, 100);

The floor of 40 means the ring is a comparison instrument, not an absolute grade: a system with every pairing failing still reads 40. ring(score) draws it as an SVG stroke-dasharray arc coloured with --color-success at 85 and above, --color-warning at 65 and above, --color-danger below that.

Colour-vision preview

renderA11y() also renders the six-swatch core palette four times — normal, deuteranopia, protanopia, tritanopia — through the feColorMatrix filters declared in the page shell. The strip contains light.primary, secondary[500], accent[500] and the three non-info semantic solids.

8. Token flattening and the twelve exporters

+

flattenTokens(sys) is the single source every exporter reads. It returns a flat array in a fixed order:

[{ name: "--color-primary-500", value: "#1f6feb", group: "Color · Primary" }, …]
Group stringCountContents
Color · Primary / Secondary / Accent11 each--color-<ramp>-<step>
Color · Neutral13--color-neutral-0 … -1000
Color · Semantic16solid, -bg, -border, -text for four keys
Color · Roles (light)14--color-bg through --color-ring
Color · Roles (dark)14Same names with a -dark suffix. Omitted when meta.mode === "light".
Color · Gradients5--gradient-brand and friends
Typography3--font-heading, --font-body, --font-mono
Typography · Size13--text-2xs … --text-7xl, in px
Typography · Leading / Tracking6 each—
Spacing19--space-0 … --space-40
Radius89999 is emitted as 9999px
Shadow8Full CSS shadow strings
Motion5Three durations in ms, --ease-standard, --ease-spring
Z-index9—
Breakpoints5—

That is 177 tokens in Light + Dark mode and 163 in light-only. The group strings are load-bearing: exporters filter on them with tests like t.group === "Color · Roles (light)" and /Roles \(dark\)/.test(t.group). Renaming a group silently breaks whichever exporters matched it.

Token names are the default kebab form unless the Token naming override is set: renameToken() applies tokenPrefix and tokenCase (kebab, camel, snake or pascal) to every name as it is pushed, so all twelve exporters inherit it. The group strings are not renamed.

The EXPORTS registry

Each entry is { label, lang, file, fn } where fn(sys) => string. The five advanced entries are wrapped so that the signature matches: the registry in advanced-exports.js takes (tokens, meta), and advancedEntries adapts each one to (sys) by calling flattenTokens(sys) for it.

KeyLabelFileProduced byWhat it carries
cssCSStokens.csstoCSSEverything in :root, plus a [data-theme="dark"], .dark block with the -dark suffix stripped.
css-modernCSS (modern)tokens.modern.csstoCssModernRoles as light-dark() under color-scheme: light dark, plus a P3 oklch() refinement layer.
scssSCSS_tokens.scsstoSCSSFlat $variables plus a $tokens Sass map. Dark roles dropped.
tailwindTailwind v3tailwind.config.jstoTailwindtheme.extend colours, fontFamily, fontSize, spacing, borderRadius, boxShadow.
tailwind4Tailwind v4theme.csstoTailwindV4An @theme block with names remapped to Tailwind's namespaces.
figmaFigma / W3Cdesign-tokens.jsontoFigma$type/$value Design Tokens for Figma Variables or Tokens Studio.
jsonJSONdesign-system.jsontoJSONThe full system including $meta, roles, gradients, motion, z-index and breakpoints.
tsTypeScripttokens.tstoTSexport const tokens = … as const over the JSON payload, plus a Tokens type.
androidAndroid XMLcolors.xmltoAndroidXmlcolors.xml and dimens.xml resource blocks, sp for --text-* and dp otherwise.
swiftuiSwiftUIDesignTokens.swifttoSwiftUIA Color extension plus a DesignTokens enum of CGFloats.
flutterFlutterapp_tokens.darttoFlutterAn AppTokens class with Color(0xFFRRGGBB) constants and double dimensions.
composeComposeTokens.kttoComposeKotlinKotlin vals with Color(0xFF…) and .dp.

How the native exporters filter and rename

function colorTokens(tokens) { return tokens.filter(t => isColorValue(t.value)); }
function dimTokens(tokens) {
  return tokens.filter(t => pxValue(t.value) != null &&
    /^--(space|radius|text|breakpoint)-/.test(t.name));
}

// "--color-primary-500" -> ["color","primary","500"]
const parts  = name => String(name).replace(/^--/, '').split('-').filter(Boolean);
const snake  = name => parts(name).join('_');                       // Android
const camel  = name => parts(name)
  .map((p, i) => i === 0 ? p : p[0].toUpperCase() + p.slice(1))
  .join('');                                                        // SwiftUI, Flutter
const pascal = name => { const c = camel(name); return c[0].toUpperCase() + c.slice(1); }; // Compose

isColorValue accepts only #rgb or #rrggbb. Anything else — an rgba(), a gradient, a shadow string, a font stack — is dropped without warning. Section 12 lists what that costs.

Modern CSS

toCssModern pairs each Color · Roles (light) token with its -dark twin, emits light-dark(light, dark), passes non-role tokens through unchanged, and then re-states every colour token as oklch() inside a nested guard:

@media (color-gamut: p3) {
  @supports (color: oklch(0% 0 0)) {
    :root {
      --color-primary-500: oklch(62.3% 0.180 256.0);
      /* … */
    }
  }
}

The oklch() value is computed from the hex, so it recovers the chroma the sRGB round-trip clipped rather than inventing new colour.

Download actions

#de-copy-export copies the current text. #de-download-export writes a Blob under the format's own filename. #de-download-all calls downloadAll(), which concatenates all twelve with /* ===== file (Label) ===== */ separators into design-system-bundle.txt. It is a text file, not an archive; the button label says so.

9. Component playground and the preview contract

+

The Components tab renders a gallery from the generated tokens and lets you re-skin it without touching the system. Every override lives in one module-level object:

const PV = {
  theme:    "light",   // light | dark
  density:  "cozy",    // compact | cozy | spacious
  radius:   "auto",    // auto | sharp | soft | rounded | pill
  shadow:   "auto",    // none | flat | auto | deep
  shape:    "auto",    // button shape: auto | square | pill
  viewport: "full",    // full | desktop | tablet | mobile
  category: "all",     // all | nav | buttons | forms | feedback | data | overlay | commerce | marketing | industry
  scene:    null,      // industry key for the showcase
};

PV never reaches SYS. previewVars() reads both and returns 46 CSS custom properties, which repaintComponents() writes as a single inline style attribute on #de-preview before replacing its innerHTML with the selected categories.

PrefixProperties
Colour roles--c-bg, --c-surface, --c-elevated, --c-muted, --c-subtle, --c-text, --c-text-muted, --c-border, --c-border-strong, --c-primary, --c-primary-hover, --c-on-primary, --c-primary-subtle, --c-ring
Brand and semantic--c-secondary, --c-accent, --c-grad, --c-grad-soft, and --c-success/-bg, --c-warning/-bg, --c-danger/-bg, --c-info/-bg
Type--f-head, --f-body, --f-mono, --head-w
Shape--r-sm, --r-md, --r-lg, --r-xl, --r-pill, --dep-btn-r
Elevation--sh-sm, --sh-md, --sh-lg, --sh-primary
Density--dep-pad, --dep-gap, --dep-fs, --dep-h
Motion--ease, --dur

In dark theme, --c-secondary and --c-accent shift from step 600 to step 400, and the four semantic backgrounds become 16% alpha washes of their solids instead of the light step-50 tints.

Override tables

ControlValues
Density (--dep-pad / -gap / -fs / -h)compact 12/10/12/34 px, cozy 16/14/13/38 px, spacious 22/20/14/44 px
Corners (sm/md/lg/xl)sharp 1/2/3/5, soft 5/8/12/16, rounded 9/13/18/26, pill 14/22/28/36
Elevationnone zeroes all four; flat substitutes single-layer shadows; deep shifts the scale up one level (md becomes sm, lg becomes md, xl becomes lg); auto uses sm/md/lg unchanged.
Buttons (--dep-btn-r)auto follows --r-md, square is 1px, pill is 9999px
ViewportPure CSS: .de-preview-stage[data-vp] caps width at 1100 / 768 / 390 px for desktop / tablet / mobile, and collapses .dep-grid to one column.

[data-pv-reset] restores density to cozy and radius, shadow and shape to auto, and viewport to full. It deliberately leaves theme and category alone.

Gallery structure

const CAT_ORDER = ["nav", "buttons", "forms", "feedback", "data",
                   "overlay", "commerce", "marketing", "industry"];
// COMP[key]() returns an HTML string for that category.
el.innerHTML = (PV.category === "all" ? CAT_ORDER : [PV.category])
  .map((k) => (COMP[k] ? COMP[k]() : "")).join("");

The industry category renders one of fourteen DEP_SCENES recipes — app name, four nav labels, a headline metric triple, CTA text, an icon key, three data rows, a trust chip and a section note — so each industry gets an on-brand app surface. [data-pv-scene] chips switch the scene independently of the generated industry.

Event delegation

wireComponents() installs four document-level listeners, each scoped by e.target.closest("#p-components"): click for every interaction, input for range sliders (writing --val and the [data-dep-out] readout), keydown for Enter and Space on [data-dep="switch"], and a document-wide outside-click closer for [data-dep-dd].open. Because the listeners are delegated and the markup is rebuilt wholesale, no rebinding is needed after a repaint.

data-depBehaviour
tabSwitches .dep-tab and the matching .dep-tabpanel inside [data-dep-tabs].
segbtn / segswapSegmented control; segswap also rewrites the .dep-amount figure and its suffix.
pagePagination inside [data-dep-pager], understanding prev and next.
dd / dd-pickOpens a dropdown (closing any sibling) and picks an item into .dep-dd-lbl.
stepStepper; clamps at zero.
rateStar rating; writes [data-dep-rating].dataset.val.
switchToggles data-on and aria-checked. Also reachable from the keyboard.
likeToggles .is-on.
loadbtn / cartTemporary loading and confirmation states, restored after 1300 ms and 1200 ms.
toastStacks a toast into .dep-toast-stack; data-variant="danger" selects the error style.
dismissFades and removes the nearest [data-dismissable].
modal-open / modal-closeAdds or removes .open on #dep-<target>. A click on the .dep-modal backdrop also closes.
accToggles .open on the nearest .dep-acc-item.
chip-xRemoves the nearest .dep-tag.

Both .de-preview-stage and #de-preview carry data-aa-no-cv, which is in the CV_SKIP_INSIDE list of js/common/lazy-rendering.js. That keeps the site-wide content-visibility: auto pass from virtualising the live gallery.

10. Integrations: workspace, bridge, font and icon CDNs

+

Workspace (window.AALibrary)

The tool is not listed in js/library/tool-bindings.js; it registers itself at runtime in boot(). Every call is optional-chained and wrapped in try, so the tool degrades to a toast when the library is absent rather than throwing.

L.registerTool({ id: "design-engine", name: "Design System Generator",
                 href: "/tool/design-engine/", category: "Design" });
L.registerAssetType({ type: "design-system", label: "Design System",
                      noun: "system", icon: "🎛️", tool: "design-engine" });
L.attachTool({
  matchPath: /^\/tool\/design-engine\/?/,
  asset_type: "design-system",
  label: "Save design system",
  capture: () => ({ name, asset_type, preview_data, asset_data }),
  restore: (asset) => { applyConfig(asset.asset_data.config); doGenerate(); return true; },
});

capture() returns null when no system exists. Otherwise preview_data holds colors (the first 16 of the palette), color and a summary string; asset_data holds the input config, the score, the palette, the three fonts and the icon family label. The config — not the generated system — is what is stored, which is why a restored asset regenerates rather than replays.

MethodBehaviour
Workspace.register()Idempotent; guarded by this.registered.
Workspace.save(sys, cfg)Calls L.save(…), toasts, re-renders the Saved panel and returns the asset. Toasts “Workspace library isn't available on this page” and returns null if the library is missing.
Workspace.list()L.query({ tool: "design-engine" }), or an empty array.
Workspace.remove(id)L.remove(id), then re-render and toast.
Workspace.openAsset(id)L.openAsset(id), falling back to opening the workspace route.
Workspace.open()Navigates to /library/workspace/.

boot() subscribes to asset events so the Saved panel stays current when an asset is added or removed elsewhere:

Lib()?.on?.(Lib()?.events?.ASSET_ADDED   || "asset:added",   () => renderSaved());
Lib()?.on?.(Lib()?.events?.ASSET_REMOVED || "asset:removed", () => renderSaved());

restoreSaved(id) reads the asset with L.get(id), applies its config, regenerates and switches to the Overview tab.

Cross-tool bridge

Bridge.handoff(sys) writes a payload to sessionStorage["aa_handoff_palette"] before every hop:

{ source: "design-engine", seed, palette: […12 hexes],
  fonts: { heading, body, mono }, meta, ts }
MethodTarget
openColorTool(slug)Six in-page colour tools via window.AuricColorTools.openWith(slug, hex) — Harmony Studio, Gradient Library, Accessibility Lab, Contrast System, Color Spaces, Color Psychology. Falls back to openWith→open→/tool/basic-tools/?seed=.
openTool(href)Four full generators (Personalization Generator, Color Science Lab, Color Blindness Simulator and the URL Analyzer), opened with ?seed=&palette=&from=design-engine, where palette is the first eight hexes joined with dashes and stripped of #.
openFontLibrary()/tool/font/?q=<heading family>
openIconLibrary()/tool/icon/?set=<icon set key>

paletteHexes(sys) defines the canonical twelve-colour handoff and thumbnail palette, in order: light.primary, secondary[500], accent[500], neutral[700], the four semantic solids, primary[200], primary[700], secondary[300], accent[700].

Font CDN

function ensureFonts(t) {
  const fams = [...new Set([t.heading.name, t.body.name, t.mono.name])].filter(Boolean);
  const key = fams.join("|");
  if (key === loadedFonts) return;   // dedupe across regenerations
  loadedFonts = key;
  window.AuricFonts?.load(fams);     // self-hosted, fonts.auricartisan.com
  // then always write a Google Fonts css2 <link id="de-font-link"> as fallback
}

The fallback link requests weights 400 through 900 with display=swap. The self-hosted base is declared in the page shell as <meta name="auric-font-base" content="https://fonts.auricartisan.com">.

Icon CDN

icon(name, stroke, size) always emits a working inline 24×24 SVG from ICON_PATHS. When window.AuricIcons exists it wraps that SVG in <i class="de-ic" data-icon="<set>:<base>" data-size>, so AuricIcons.inject(scope) can upgrade it in place; if the name is not in the chosen set, the inline SVG simply stays. ICON_NAME maps the twenty preview keys to CDN base names, which is where chevron becomes chevron-right and chart becomes chart-bar.

whenIconsReady(cb) polls every 150 ms up to 40 times. If the CDN arrives after first paint, boot() re-renders once so the wrappers exist, then injects.

11. State, storage keys and the URL contract

+

Module state

VariableMeaning
SYSThe current generated system, or null before the first generate.
exportFormatCurrent export key. Defaults to "css".
PVPreview-only overrides. See section 9.
generatingRe-entry guard around doGenerate().
loadedFontsThe |-joined family key ensureFonts dedupes on.

doGenerate()

There is no Generate button and no delay: the work runs synchronously. If a system exists, its score and token values are kept first as PREV_SCORE and PREV_TOKENS so the audit rail can say what the change cost. Then SYS = generate(readConfig()), renderAll(), .de-has-system added and config persisted. A failure is logged to the console and reported as a toast; the finally block always clears the generating guard.

Storage

StoreKeyContents
localStoragede_lastThe last readConfig() as JSON, written on every successful generate and applied on load when the URL carries no config params.
sessionStorageaa_handoff_paletteThe cross-tool handoff payload described in section 10.

Nothing else is persisted except the Expand preference, kept in localStorage as de_expanded. The playground overrides, the active tab and the export format all reset on reload.

URL contract

function encodeConfig(cfg) {
  const p = new URLSearchParams();
  Object.entries(cfg).forEach(([k, v]) => { if (v != null && v !== "auto" && v !== "") p.set(k, v); });
  return p.toString();
}

Thirteen keys can appear: industry, style, a11y, seedHex, mode, density, harmony, ratio, corner, rampSpan, rampPeak, tokenPrefix and tokenCase. Anything null or literally "auto", or empty, is dropped, so an empty token prefix is left out. share() copies location.origin + location.pathname + "?" + encodeConfig(readConfig()) and history.replaceStates it.

On boot, wire() treats the presence of any of the thirteen keys as a share link and calls applyConfig() from the params; otherwise it reads de_last. Either way it then calls doGenerate(), so the page always shows a system on first paint. applyConfig(cfg) writes each value through setDropdown with a display label from its own labelFor map, and syncs both seed fields when cfg.seedHex is a valid hex. It sets rampSpan, rampPeak and tokenCase only to values their controls offer, and tokenPrefix after stripping everything but letters, digits, hyphens and underscores (40 characters at most), so a share link and de_last both restore the ramp shape and the token naming.

Regeneration triggers

TriggerTiming
change on any of the eleven config inputs; input on #de-token-prefix120 ms debounce, and only once SYS exists; 260 ms for the prefix.
input on #de-seedImmediate, on every colour-picker movement.
change on #de-seed-hexImmediate, after syncing the colour input if the text is a valid hex.
The brief's hue strip and seed Auto button, surprise(), restoreSaved(), workspace restoreImmediate.

Rendering model

renderAll() calls the fourteen render* functions in sequence, then syncBrief() and renderAuditRail() (and renderCompare() when that view is open), then Icons.inject() on #de-app, then updates the token counts and #a11y-badge. Each panel is a full innerHTML rebuild assembled from string helpers — card(title, body, { icon, badge, open, sub }), swatch(), ramp(), kvRow() and ring(). Nothing is diffed, so any DOM state inside a panel is lost on regeneration.

Debug API

window.DesignEngine = {
  generate, flattenTokens, scoreSystem, a11yMatrix, Color,
  exporters: EXPORTS, paletteHexes, Workspace, Bridge,
  get current() { return SYS; },
};

This is the supported way to drive the engine from the console or a test harness. For example, DesignEngine.exporters.tailwind4.fn(DesignEngine.current) returns the Tailwind v4 theme block for whatever is on screen, and DesignEngine.generate({ industry: "luxury", style: "elegant", a11y: "AA", seedHex: null, mode: "both", density: "comfortable", harmony: "auto", ratio: "auto", corner: "auto" }) produces a system without touching the page.

12. Tests, performance and known limits

+

Test coverage

tests/design-engine/advanced-exports.test.js runs with npm run test:design-engine. It covers the APCA reference values, WCAG anchors, the colour helpers, the output shape of all five advanced exporters, the well-formedness of the ADVANCED_EXPORTS registry, and degenerate inputs (an empty token list, an undefined meta).

design-engine.js has no unit tests. It cannot be imported under Node, because its one import specifier is the browser-absolute path /js/tool/design-engine/advanced-exports.js, which Node cannot resolve. The module body itself would survive: every window and document access at load is behind a typeof guard, so boot() simply never runs off-DOM. Extracting the generator into a pure module, the way advanced-exports.js already is, is the obvious way to fix that.

Exporter coverage gaps

These are consequences of how each exporter filters, not bugs waiting on a report. Know them before you ship a token set from one of them.

  • Two roles never reach CSS. The roleMap in flattenTokens lists fourteen keys and omits primary-active and primary-subtle. Both are generated, and both reach the JSON and TypeScript exports through clean(c.light), but neither appears in CSS, SCSS, Tailwind v4 or any native export.
  • Native exporters drop non-hex colours. --color-ring is always an rgba(), and in dark mode --color-border and --color-border-strong are white at 10% and 20%. None of them survive isColorValue, so the Android, SwiftUI, Flutter and Compose output has no ring and no dark borders.
  • Native exporters carry no shadows, gradients, motion, font stacks, leading, tracking or z-index. dimTokens accepts only --space-, --radius-, --text- and --breakpoint- names.
  • Tailwind v3 has no role tokens. toTailwind exports the ramps, semantic solids, fonts, sizes, spacing, radius and shadow, but no bg/surface/text/border roles. Use Tailwind v4 or plain CSS if you need them.
  • The Figma / W3C export is partial. It covers colour ramps, semantic solids, font families, font sizes, spacing, radius and shadow. Roles, gradients, motion, z-index and breakpoints are omitted.
  • “Download all” is not an archive. It is one design-system-bundle.txt with comment separators between the twelve files.

Engine and UI limits

  • Only the light roles are scored. scoreSystem reads sys.color.light and the light report, even when the colour mode is Dark. The dark roles are audited into meta.a11yReportDark and shown in the audit rail and the audit view, but the score and the failing-pairing count say nothing about them.
  • The AA-large target does not relax button labels. pickPrimary is called with Math.max(target, 4.5), so the primary fill is always chosen to admit a 4.5:1 label.
  • Playground state is destroyed by any config change. Panels are rebuilt with innerHTML and the dropdowns regenerate after 120 ms, so an open modal, a typed tag or a toggled switch disappears the moment you touch a control in the brief.
  • There is no Simple / Advanced mode. Every override sits in the brief rail at once; nothing is hidden behind a mode switch.
  • [data-preview-theme] has no markup. The delegated handler and paintPreview() are a back-compat shim; the real theme control is the playground's data-pv="theme" segment.
  • There is no Quick Start strip. The guidance lives in the Help view (data-view="help"), which is static markup in the page shell.
  • Find token is panel-scoped. applySearch queries [data-search] inside .anz-panel.active only, and hides non-matches with .de-hide. Elements without a data-search attribute are invisible to it.
  • Surprise leaves half the advanced overrides alone. surprise() randomises industry, style, accessibility target and harmony — harmony on a coin flip between auto and a random scheme — and puts the seed back to auto, but mode, density, ratio and corner survive from the previous run.
  • Saving needs the library. Without window.AALibrary, Save toasts and the Saved panel in the Workspace view renders a “Workspace unavailable” empty state rather than failing loudly.
  • Type and icon previews depend on remote origins. Fonts come from fonts.auricartisan.com with fonts.googleapis.com as fallback, and icons from icons.auricartisan.com. With all of them unreachable the specimens fall back to system stacks and the icons stay as the built-in inline SVGs — the tokens themselves are unaffected.

Performance notes

A generation is cheap: a few hundred OKLCH conversions, each at most 18 gamut iterations, then string building. The cost is in the rendering — fourteen panels of innerHTML, one of which is the whole component gallery. That is why the config inputs debounce at 120 ms and why the seed colour input, which fires continuously while dragging, is the one place worth watching if you extend the renderers.

When adding a panel or an exporter, the two contracts to respect are the flattenTokens group strings and the (sys) => string exporter signature. A new export target is one entry in EXPORTS (or in ADVANCED_EXPORTS if it can be written purely against (tokens, meta), which also gets it into the test suite for free).