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.
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.
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.
| File | Role |
|---|---|
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.
| Id | Values | Default | Effect |
|---|---|---|---|
#de-industry | 15 keys, auto, fintech…wellness | auto | Seeds hue, chroma appetite, mood string and the tag set used to score fonts and icons. |
#de-style | 10 keys, modern…futuristic | modern | Saturation multiplier, corner family, shadow preset, heading/body weight, type ratio, motion personality. |
#de-a11y | AA-large / AA / AAA | AAA | Maps 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 auto | A valid hex overrides the industry hue. auto or empty means derive it. |
#de-mode | light / dark / both | both | light suppresses the dark role block, the dark token group and the preview theme switch. |
#de-density | compact / comfortable / spacious | comfortable | Base font size 15 / 16 / 17 px and a 0.9 / 1.0 / 1.15 spacing multiplier. |
#de-harmony | auto or one of six schemes | auto | Hue offsets for the secondary and accent ramps. |
#de-ratio | auto or 1.125 / 1.2 / 1.25 / 1.333 / 1.414 / 1.5 / 1.618 | auto | Modular ratio for the type scale. |
#de-corner | auto / sharp / soft / rounded / pill | auto | Selects 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
| Selector | Purpose |
|---|---|
#de-app | Root container. Gains .de-has-system after the first successful generate. |
#de-surprise, #de-share, #de-save | Header actions. There is no Generate button: every control change regenerates the system. |
#de-expand | Expand / Collapse. Hides both rails so the panel gets the full width; remembered in localStorage as de_expanded. |
#de-brief, #de-audit-rail | The two rails. The brief writes to the hidden config inputs; the audit re-renders on every generation. |
#de-result-search, #de-result-clear | The 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-tokens | The 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-badge | Failing-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-tritanopia | SVG 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.
| Signature | Returns |
|---|---|
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
| Function | Behaviour |
|---|---|
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)
| Field | Meaning | Range in the table |
|---|---|---|
satMul | Multiplies the industry chroma. | 0.62 (minimal) to 1.28 (bold) |
corner | Default CORNER family. | sharp, soft, rounded |
shadow | Default SHADOW_PRESETS key. | subtle, soft, balanced, deep, hard, glow |
headWeight / bodyWeight | Weights bound into the type roles. | 600–900 / 400–500 |
ratio | Default modular ratio. | 1.2 to 1.5 |
motion | MOTION key. | subtle, smooth, snappy, bouncy |
tags | Joined 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");
| Family | none | sm | md | lg | xl | 2xl | 3xl | pill |
|---|---|---|---|---|---|---|---|---|
sharp | 0 | 1 | 2 | 3 | 4 | 6 | 8 | 9999 |
soft | 0 | 3 | 6 | 8 | 12 | 16 | 22 | 9999 |
rounded | 0 | 6 | 10 | 14 | 20 | 28 | 36 | 9999 |
pill | 0 | 8 | 14 | 22 | 32 | 9999 | 9999 | 9999 |
| Motion preset | fast | base | slow | ease | spring |
|---|---|---|---|---|---|
subtle | 120 | 200 | 320 | cubic-bezier(.4,0,.2,1) | cubic-bezier(.4,0,.2,1) |
smooth | 150 | 250 | 400 | cubic-bezier(.4,0,.2,1) | cubic-bezier(.34,1.2,.64,1) |
snappy | 110 | 180 | 280 | cubic-bezier(.2,0,0,1) | cubic-bezier(.3,1.3,.5,1) |
bouncy | 160 | 280 | 460 | cubic-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:
| Key | Hue | solid | bg | border | text | fgOnSolid |
|---|---|---|---|---|---|---|
success | 145° | step 600 | step 50 | step 200 | step 700 | onColor(solid) |
warning | 75° | step 500 | step 50 | step 200 | step 700 | onColor(solid) |
danger | 27° | step 600 | step 50 | step 200 | step 700 | onColor(solid) |
info | 240° | step 600 | step 50 | step 200 | step 700 | onColor(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);
| Role | Light value | Dark value |
|---|---|---|
bg | #ffffff | neutral[950] |
bg-subtle / bg-muted | neutral[50] / neutral[100] | neutral[900] / oklchToHex(0.27, tint, hue) |
surface / elevated | #ffffff / #ffffff | neutral[900] / neutral[800] |
border / border-strong | neutral[200] / neutral[300] | rgba(255,255,255,.10) / rgba(255,255,255,.20) |
text / text-muted / text-subtle | Solved at target / 4.5 / 3, lightest passing step | Solved at target (against the lightest dark surface) / 4.5 / 3, darkest passing step |
primary | pickPrimary(primary, bg, max(target, 4.5)) | pickPrimary(primary, bg, 4.5) |
primary-hover | primary[hoverStep] | primary[max(primStep - 100, 200)] |
primary-active | primary[min(primStep + 100, 950)] | primary[min(primStep + 100, 900)] |
primary-subtle | primary[50] | withAlpha(prim, 0.16) |
on-primary | Color.onColor(prim) in both themes | |
ring | withAlpha(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:
| Role | Size | Weight | Line height | Tracking | Family |
|---|---|---|---|---|---|
display | 6xl | min(headWeight + 100, 900) | 1.05 | -0.03em | heading |
h1 | 5xl | headWeight | 1.1 | -0.02em | heading |
h2 | 4xl | headWeight | 1.15 | -0.02em | heading |
h3 | 3xl | headWeight | 1.2 | -0.01em | heading |
h4 | 2xl | max(headWeight - 100, 600) | 1.25 | -0.01em | heading |
h5 | xl | 600 | 1.3 | 0 | heading |
body-lg | lg | bodyWeight | 1.6 | 0 | body |
body | base | bodyWeight | 1.6 | 0 | body |
small | sm | bodyWeight | 1.5 | 0.005em | body |
caption | xs | 500 | 1.4 | 0.02em | body |
overline | 2xs | 700 | 1.3 | 0.12em | body |
code | sm | 400 | 1.5 | 0 | mono |
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:
| Preset | spread | alpha | Notes |
|---|---|---|---|
subtle | 0.5 | 0.05 | — |
soft | 0.8 | 0.07 | — |
balanced | 1.0 | 0.09 | Default fallback. |
deep | 1.35 | 0.14 | — |
hard | 1.0 | 0.9 | hard: true — offset-only, no blur, neo-brutalist. |
glow | 1.1 | 0.10 | glow: 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.
| Row | Foreground | Background | Requirement | Kind |
|---|---|---|---|---|
| Body text | text | bg | meta.target | normal text |
| Muted text | text-muted | bg | 4.5 | AA body |
| Subtle text | text-subtle | bg | 3 | large text |
| Text on surface | text | surface | meta.target | normal text |
| Text on muted bg | text | bg-muted | meta.target | normal text |
| On-primary label | on-primary | primary | 4.5 | button label |
| Primary vs bg | primary | bg | 3 | UI element |
| Strong border | border-strong | bg | 1.4 | decorative |
| Success / Warning / Danger / Info solid | fgOnSolid | solid | 3 | UI 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 string | Count | Contents |
|---|---|---|
Color · Primary / Secondary / Accent | 11 each | --color-<ramp>-<step> |
Color · Neutral | 13 | --color-neutral-0 … -1000 |
Color · Semantic | 16 | solid, -bg, -border, -text for four keys |
Color · Roles (light) | 14 | --color-bg through --color-ring |
Color · Roles (dark) | 14 | Same names with a -dark suffix. Omitted when meta.mode === "light". |
Color · Gradients | 5 | --gradient-brand and friends |
Typography | 3 | --font-heading, --font-body, --font-mono |
Typography · Size | 13 | --text-2xs … --text-7xl, in px |
Typography · Leading / Tracking | 6 each | — |
Spacing | 19 | --space-0 … --space-40 |
Radius | 8 | 9999 is emitted as 9999px |
Shadow | 8 | Full CSS shadow strings |
Motion | 5 | Three durations in ms, --ease-standard, --ease-spring |
Z-index | 9 | — |
Breakpoints | 5 | — |
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.
| Key | Label | File | Produced by | What it carries |
|---|---|---|---|---|
css | CSS | tokens.css | toCSS | Everything in :root, plus a [data-theme="dark"], .dark block with the -dark suffix stripped. |
css-modern | CSS (modern) | tokens.modern.css | toCssModern | Roles as light-dark() under color-scheme: light dark, plus a P3 oklch() refinement layer. |
scss | SCSS | _tokens.scss | toSCSS | Flat $variables plus a $tokens Sass map. Dark roles dropped. |
tailwind | Tailwind v3 | tailwind.config.js | toTailwind | theme.extend colours, fontFamily, fontSize, spacing, borderRadius, boxShadow. |
tailwind4 | Tailwind v4 | theme.css | toTailwindV4 | An @theme block with names remapped to Tailwind's namespaces. |
figma | Figma / W3C | design-tokens.json | toFigma | $type/$value Design Tokens for Figma Variables or Tokens Studio. |
json | JSON | design-system.json | toJSON | The full system including $meta, roles, gradients, motion, z-index and breakpoints. |
ts | TypeScript | tokens.ts | toTS | export const tokens = … as const over the JSON payload, plus a Tokens type. |
android | Android XML | colors.xml | toAndroidXml | colors.xml and dimens.xml resource blocks, sp for --text-* and dp otherwise. |
swiftui | SwiftUI | DesignTokens.swift | toSwiftUI | A Color extension plus a DesignTokens enum of CGFloats. |
flutter | Flutter | app_tokens.dart | toFlutter | An AppTokens class with Color(0xFFRRGGBB) constants and double dimensions. |
compose | Compose | Tokens.kt | toComposeKotlin | Kotlin 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.
| Prefix | Properties |
|---|---|
| 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
| Control | Values |
|---|---|
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 |
| Elevation | none 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 |
| Viewport | Pure 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-dep | Behaviour |
|---|---|
tab | Switches .dep-tab and the matching .dep-tabpanel inside [data-dep-tabs]. |
segbtn / segswap | Segmented control; segswap also rewrites the .dep-amount figure and its suffix. |
page | Pagination inside [data-dep-pager], understanding prev and next. |
dd / dd-pick | Opens a dropdown (closing any sibling) and picks an item into .dep-dd-lbl. |
step | Stepper; clamps at zero. |
rate | Star rating; writes [data-dep-rating].dataset.val. |
switch | Toggles data-on and aria-checked. Also reachable from the keyboard. |
like | Toggles .is-on. |
loadbtn / cart | Temporary loading and confirmation states, restored after 1300 ms and 1200 ms. |
toast | Stacks a toast into .dep-toast-stack; data-variant="danger" selects the error style. |
dismiss | Fades and removes the nearest [data-dismissable]. |
modal-open / modal-close | Adds or removes .open on #dep-<target>. A click on the .dep-modal backdrop also closes. |
acc | Toggles .open on the nearest .dep-acc-item. |
chip-x | Removes 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.
| Method | Behaviour |
|---|---|
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 }
| Method | Target |
|---|---|
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
| Variable | Meaning |
|---|---|
SYS | The current generated system, or null before the first generate. |
exportFormat | Current export key. Defaults to "css". |
PV | Preview-only overrides. See section 9. |
generating | Re-entry guard around doGenerate(). |
loadedFonts | The |-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
| Store | Key | Contents |
|---|---|---|
localStorage | de_last | The last readConfig() as JSON, written on every successful generate and applied on load when the URL carries no config params. |
sessionStorage | aa_handoff_palette | The 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
| Trigger | Timing |
|---|---|
change on any of the eleven config inputs; input on #de-token-prefix | 120 ms debounce, and only once SYS exists; 260 ms for the prefix. |
input on #de-seed | Immediate, on every colour-picker movement. |
change on #de-seed-hex | Immediate, after syncing the colour input if the text is a valid hex. |
The brief's hue strip and seed Auto button, surprise(), restoreSaved(), workspace restore | Immediate. |
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
roleMapinflattenTokenslists fourteen keys and omitsprimary-activeandprimary-subtle. Both are generated, and both reach the JSON and TypeScript exports throughclean(c.light), but neither appears in CSS, SCSS, Tailwind v4 or any native export. -
Native exporters drop non-hex colours.
--color-ringis always anrgba(), and in dark mode--color-borderand--color-border-strongare white at 10% and 20%. None of them surviveisColorValue, 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.
dimTokensaccepts only--space-,--radius-,--text-and--breakpoint-names. -
Tailwind v3 has no role tokens.
toTailwindexports 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.txtwith comment separators between the twelve files.
Engine and UI limits
-
Only the light roles are scored.
scoreSystemreadssys.color.lightand the light report, even when the colour mode is Dark. The dark roles are audited intometa.a11yReportDarkand 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.
pickPrimaryis called withMath.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
innerHTMLand 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 andpaintPreview()are a back-compat shim; the real theme control is the playground'sdata-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.
applySearchqueries[data-search]inside.anz-panel.activeonly, and hides non-matches with.de-hide. Elements without adata-searchattribute are invisible to it. -
Surprise leaves half the advanced overrides alone.
surprise()randomises industry, style, accessibility target and harmony — harmony on a coin flip betweenautoand a random scheme — and puts the seed back toauto, 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.comwithfonts.googleapis.comas fallback, and icons fromicons.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).