@nurix/etna 0.19.2 → 0.19.4
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/data/harness-index.json +1 -1
- package/data/harnesses/code/CLAUDE.md +2 -2
- package/data/harnesses/code/harness.json +2 -2
- package/data/harnesses/code/rules/frontend.md +3 -3
- package/data/harnesses/code/skills/design/SKILL.md +20 -8
- package/data/harnesses/code/skills/design/data/catalog/rules.csv +10 -5
- package/data/harnesses/code/skills/design/data/combinations/Readme.md +8 -4
- package/data/harnesses/code/skills/design/data/combinations/accent-presets.md +113 -0
- package/data/harnesses/code/skills/design/data/combinations/fonts-known.json +11 -0
- package/data/harnesses/code/skills/design/data/combinations/manifest.json +57 -10
- package/data/harnesses/code/skills/design/data/combinations/motion.csv +10 -0
- package/data/harnesses/code/skills/design/data/combinations/palettes.csv +92 -49
- package/data/harnesses/code/skills/design/data/combinations/styles.csv +67 -54
- package/data/harnesses/code/skills/design/data/combinations/typography.csv +64 -61
- package/data/harnesses/code/skills/design/data/design-md/library/expo.md +4 -4
- package/data/harnesses/code/skills/design/data/design-md/library/lovable.md +5 -5
- package/data/harnesses/code/skills/design/data/design-md/library/minimax.md +6 -6
- package/data/harnesses/code/skills/design/data/design-md/library/notion.md +4 -4
- package/data/harnesses/code/skills/design/data/design-md/library/runwayml.md +4 -4
- package/data/harnesses/code/skills/design/data/design-md/library/sentry.md +2 -2
- package/data/harnesses/code/skills/design/data/design-md/library/slack.md +6 -6
- package/data/harnesses/code/skills/design/data/design-md/library/webflow.md +5 -5
- package/data/harnesses/code/skills/design/data/design-md/nurix.md +11 -7
- package/data/harnesses/code/skills/design/data/design-tokens/SCHEMA.md +256 -141
- package/data/harnesses/code/skills/design/data/design-tokens/airbnb/design.tokens.json +309 -1
- package/data/harnesses/code/skills/design/data/design-tokens/airtable/design.tokens.json +309 -1
- package/data/harnesses/code/skills/design/data/design-tokens/anthropic-claude/design.tokens.json +309 -1
- package/data/harnesses/code/skills/design/data/design-tokens/apple/design.tokens.json +309 -1
- package/data/harnesses/code/skills/design/data/design-tokens/binance/design.tokens.json +309 -1
- package/data/harnesses/code/skills/design/data/design-tokens/bmw/design.tokens.json +309 -1
- package/data/harnesses/code/skills/design/data/design-tokens/cal/design.tokens.json +313 -1
- package/data/harnesses/code/skills/design/data/design-tokens/clay/design.tokens.json +309 -1
- package/data/harnesses/code/skills/design/data/design-tokens/clickhouse/design.tokens.json +309 -1
- package/data/harnesses/code/skills/design/data/design-tokens/cohere/design.tokens.json +309 -1
- package/data/harnesses/code/skills/design/data/design-tokens/coinbase/design.tokens.json +309 -1
- package/data/harnesses/code/skills/design/data/design-tokens/composio/design.tokens.json +309 -1
- package/data/harnesses/code/skills/design/data/design-tokens/cursor/design.tokens.json +309 -1
- package/data/harnesses/code/skills/design/data/design-tokens/elevenlabs/design.tokens.json +313 -1
- package/data/harnesses/code/skills/design/data/design-tokens/expo/design.tokens.json +309 -1
- package/data/harnesses/code/skills/design/data/design-tokens/figma/design.tokens.json +313 -1
- package/data/harnesses/code/skills/design/data/design-tokens/framer/design.tokens.json +309 -1
- package/data/harnesses/code/skills/design/data/design-tokens/goldens.json +123 -123
- package/data/harnesses/code/skills/design/data/design-tokens/hashicorp/design.tokens.json +309 -1
- package/data/harnesses/code/skills/design/data/design-tokens/ibm/design.tokens.json +309 -1
- package/data/harnesses/code/skills/design/data/design-tokens/intercom/design.tokens.json +309 -1
- package/data/harnesses/code/skills/design/data/design-tokens/kraken/design.tokens.json +313 -1
- package/data/harnesses/code/skills/design/data/design-tokens/linear-app/design.resolver.json +1 -1
- package/data/harnesses/code/skills/design/data/design-tokens/linear-app/design.tokens.dark.json +10 -0
- package/data/harnesses/code/skills/design/data/design-tokens/linear-app/design.tokens.json +309 -1
- package/data/harnesses/code/skills/design/data/design-tokens/lovable/design.tokens.json +313 -1
- package/data/harnesses/code/skills/design/data/design-tokens/meta/design.tokens.json +313 -1
- package/data/harnesses/code/skills/design/data/design-tokens/minimax/design.tokens.json +309 -1
- package/data/harnesses/code/skills/design/data/design-tokens/mintlify/design.tokens.json +309 -1
- package/data/harnesses/code/skills/design/data/design-tokens/miro/design.tokens.json +313 -1
- package/data/harnesses/code/skills/design/data/design-tokens/mistral-ai/design.tokens.json +309 -1
- package/data/harnesses/code/skills/design/data/design-tokens/mongodb/design.tokens.json +313 -1
- package/data/harnesses/code/skills/design/data/design-tokens/notion/design.tokens.json +309 -1
- package/data/harnesses/code/skills/design/data/design-tokens/nurix/design.tokens.json +307 -2
- package/data/harnesses/code/skills/design/data/design-tokens/nvidia/design.tokens.json +309 -1
- package/data/harnesses/code/skills/design/data/design-tokens/ollama/design.tokens.json +309 -1
- package/data/harnesses/code/skills/design/data/design-tokens/opencode-ai/design.tokens.json +309 -1
- package/data/harnesses/code/skills/design/data/design-tokens/posthog/design.tokens.json +309 -1
- package/data/harnesses/code/skills/design/data/design-tokens/raycast/design.tokens.json +309 -1
- package/data/harnesses/code/skills/design/data/design-tokens/replicate/design.tokens.json +309 -1
- package/data/harnesses/code/skills/design/data/design-tokens/resend/design.resolver.json +1 -1
- package/data/harnesses/code/skills/design/data/design-tokens/resend/design.tokens.dark.json +10 -0
- package/data/harnesses/code/skills/design/data/design-tokens/resend/design.tokens.json +309 -1
- package/data/harnesses/code/skills/design/data/design-tokens/revolut/design.tokens.json +309 -1
- package/data/harnesses/code/skills/design/data/design-tokens/runwayml/design.tokens.json +310 -2
- package/data/harnesses/code/skills/design/data/design-tokens/sanity/design.tokens.json +313 -1
- package/data/harnesses/code/skills/design/data/design-tokens/sentry/design.tokens.json +309 -1
- package/data/harnesses/code/skills/design/data/design-tokens/shopify/design.tokens.json +309 -1
- package/data/harnesses/code/skills/design/data/design-tokens/slack/design.tokens.json +309 -1
- package/data/harnesses/code/skills/design/data/design-tokens/spacex/design.tokens.json +309 -1
- package/data/harnesses/code/skills/design/data/design-tokens/spotify/design.tokens.json +313 -1
- package/data/harnesses/code/skills/design/data/design-tokens/stripe/design.tokens.json +313 -1
- package/data/harnesses/code/skills/design/data/design-tokens/supabase/design.tokens.json +309 -1
- package/data/harnesses/code/skills/design/data/design-tokens/superhuman/design.tokens.json +313 -1
- package/data/harnesses/code/skills/design/data/design-tokens/tesla/design.tokens.json +309 -1
- package/data/harnesses/code/skills/design/data/design-tokens/theverge/design.tokens.json +309 -1
- package/data/harnesses/code/skills/design/data/design-tokens/together-ai/design.tokens.json +313 -1
- package/data/harnesses/code/skills/design/data/design-tokens/uber/design.tokens.json +309 -1
- package/data/harnesses/code/skills/design/data/design-tokens/vercel/design.tokens.json +309 -1
- package/data/harnesses/code/skills/design/data/design-tokens/voltagent/design.resolver.json +1 -1
- package/data/harnesses/code/skills/design/data/design-tokens/voltagent/design.tokens.dark.json +10 -0
- package/data/harnesses/code/skills/design/data/design-tokens/voltagent/design.tokens.json +309 -1
- package/data/harnesses/code/skills/design/data/design-tokens/warp/design.tokens.json +309 -1
- package/data/harnesses/code/skills/design/data/design-tokens/webflow/design.tokens.json +309 -1
- package/data/harnesses/code/skills/design/data/design-tokens/wired/design.tokens.json +309 -1
- package/data/harnesses/code/skills/design/data/design-tokens/wise/design.tokens.json +309 -1
- package/data/harnesses/code/skills/design/data/design-tokens/x-ai/design.tokens.json +309 -1
- package/data/harnesses/code/skills/design/data/design-tokens/zapier/design.tokens.json +309 -1
- package/data/harnesses/code/skills/design/data/operator-pack/exemplars/command-center.tsx +25 -25
- package/data/harnesses/code/skills/design/data/operator-pack/exemplars/create-edit.tsx +18 -18
- package/data/harnesses/code/skills/design/data/operator-pack/exemplars/dashboard.tsx +82 -105
- package/data/harnesses/code/skills/design/data/operator-pack/exemplars/entity-detail.tsx +97 -93
- package/data/harnesses/code/skills/design/data/operator-pack/exemplars/list-detail.tsx +16 -15
- package/data/harnesses/code/skills/design/data/operator-pack/exemplars/log-stream.tsx +34 -34
- package/data/harnesses/code/skills/design/data/operator-pack/surface-catalog.md +5 -5
- package/data/harnesses/code/skills/design/references/create.md +49 -7
- package/data/harnesses/code/skills/design/references/diagnosis.md +3 -3
- package/data/harnesses/code/skills/design/references/hard-rules.md +25 -3
- package/data/harnesses/code/skills/design/references/improve.md +32 -3
- package/data/harnesses/code/skills/design/references/interaction.md +3 -3
- package/data/harnesses/code/skills/design/references/introspect.md +1 -1
- package/data/harnesses/code/skills/design/references/lifecycle.md +2 -2
- package/data/harnesses/code/skills/design/references/personality.md +280 -133
- package/data/harnesses/code/skills/design/references/structure.md +22 -1
- package/data/harnesses/code/skills/design/scripts/convert-design-md.mjs +306 -26
- package/data/harnesses/code/skills/design/scripts/derive-personality.mjs +596 -104
- package/data/harnesses/code/skills/design/scripts/lib/checks.mjs +200 -11
- package/data/harnesses/code/skills/design/scripts/lib/vocabulary.mjs +65 -0
- package/data/harnesses/code/skills/design/scripts/phase-gate.mjs +95 -11
- package/data/harnesses/code/skills/design/scripts/preship-sweep.mjs +50 -5
- package/data/harnesses/code/skills/design/scripts/render-rung.mjs +214 -14
- package/data/harnesses/code/skills/design/scripts/scaffold.mjs +242 -25
- package/data/harnesses/code/skills/design/scripts/self-integrity.mjs +81 -6
- package/data/harnesses/code/skills/design/scripts/snapshot-goldens.mjs +101 -5
- package/data/harnesses/code/skills/design/scripts/validate-combinations.mjs +142 -17
- package/data/harnesses/etna/CLAUDE.md +1 -1
- package/data/harnesses/etna/harness.json +2 -2
- package/package.json +1 -1
|
@@ -1,19 +1,29 @@
|
|
|
1
1
|
#!/usr/bin/env node
|
|
2
|
-
// skills/design/scripts/derive-personality.mjs — design-tokens artifact resolver, coverage gate (--check; SCHEMA.md rules 1–
|
|
2
|
+
// skills/design/scripts/derive-personality.mjs — design-tokens artifact resolver, coverage gate (--check; SCHEMA.md rules 1–15), and CSS emitter (--emit) for the operator-UI personality system (D29, D6).
|
|
3
3
|
//
|
|
4
|
-
// The reference consumer of data/design-tokens/SCHEMA.md: resolves an
|
|
5
|
-
// (design.tokens.json + design.resolver.json [+ design.tokens.dark.json]) through
|
|
6
|
-
// deterministic order — concatenate the set files, overlay the selected theme context,
|
|
7
|
-
// aliases — then validates (rules 1–
|
|
4
|
+
// The reference consumer of data/design-tokens/SCHEMA.md (schema design-tokens/v2): resolves an
|
|
5
|
+
// artifact directory (design.tokens.json + design.resolver.json [+ design.tokens.dark.json]) through
|
|
6
|
+
// the resolver's deterministic order — concatenate the set files, overlay the selected theme context,
|
|
7
|
+
// resolve aliases — then validates (rules 1–15) or emits personality.css / globals.css / fonts.css /
|
|
8
|
+
// the <html> attributes. --emit refuses while any token carries a hint; --provisional (golden/
|
|
9
|
+
// regression flows only, never shipped output) emits hinted tokens at their provisional $values
|
|
10
|
+
// instead. --emit also enforces two emission contracts: font binding (contract 2, cod-16 — every
|
|
11
|
+
// emitted stack family binds to a generated @fontsource import / installed package or a system
|
|
12
|
+
// generic, fail-closed) and emitter completeness (contract 3 — an authored token group with no
|
|
13
|
+
// emitter refuses to emit, naming the group).
|
|
8
14
|
//
|
|
9
|
-
// Usage: derive-personality.mjs <artifact-dir> [--check | --emit <outdir>] [--theme light|dark]
|
|
15
|
+
// Usage: derive-personality.mjs <artifact-dir> [--check | --emit <outdir> [--provisional]] [--theme light|dark]
|
|
10
16
|
|
|
11
17
|
import { existsSync, mkdirSync, readFileSync, writeFileSync } from "node:fs";
|
|
12
18
|
import { dirname, join } from "node:path";
|
|
19
|
+
import { fileURLToPath } from "node:url";
|
|
20
|
+
import { SYSTEM_FONT_KEYWORDS, familySlug } from "./lib/checks.mjs";
|
|
13
21
|
|
|
14
22
|
const EXT = "com.nurix.design";
|
|
23
|
+
const SCRIPT_DIR = dirname(fileURLToPath(import.meta.url));
|
|
24
|
+
const COMBINATIONS_DIR = join(SCRIPT_DIR, "..", "data", "combinations");
|
|
15
25
|
|
|
16
|
-
// The
|
|
26
|
+
// The 27 visual primitives and their DTCG $type, in emission order (leaf names are law; no leading --).
|
|
17
27
|
const PRIMITIVE_TYPES = new Map([
|
|
18
28
|
["radius", "dimension"], ["shadow-intensity", "number"], ["border-weight", "dimension"],
|
|
19
29
|
["density-gap", "number"], ["density-inset", "number"],
|
|
@@ -21,7 +31,7 @@ const PRIMITIVE_TYPES = new Map([
|
|
|
21
31
|
["font-weight-normal", "fontWeight"], ["font-weight-strong", "fontWeight"],
|
|
22
32
|
["heading-tracking", "number"],
|
|
23
33
|
["duration-unit", "duration"], ["duration-fast", "duration"], ["duration-normal", "duration"],
|
|
24
|
-
["duration-slow", "duration"], ["ease-default", "cubicBezier"],
|
|
34
|
+
["duration-slow", "duration"], ["ease-default", "cubicBezier"], ["stagger-unit", "duration"],
|
|
25
35
|
["contrast-boost", "number"],
|
|
26
36
|
["icon-stroke", "number"], ["icon-size-base", "dimension"],
|
|
27
37
|
["sidebar-width-expanded", "dimension"], ["sidebar-width-collapsed", "dimension"],
|
|
@@ -31,11 +41,12 @@ const PRIMITIVE_TYPES = new Map([
|
|
|
31
41
|
]);
|
|
32
42
|
const PRIMITIVES = [...PRIMITIVE_TYPES.keys()];
|
|
33
43
|
|
|
34
|
-
// The
|
|
44
|
+
// The 19 semantic color tokens, in emission order.
|
|
35
45
|
const SEMANTIC_TOKENS = [
|
|
36
46
|
"background", "foreground", "card", "card-foreground", "muted", "muted-foreground",
|
|
37
47
|
"border", "input", "ring", "primary", "primary-foreground", "accent", "accent-foreground",
|
|
38
48
|
"destructive", "destructive-foreground", "success", "warning",
|
|
49
|
+
"surface-raised", "surface-overlay",
|
|
39
50
|
];
|
|
40
51
|
|
|
41
52
|
// The 17 substrate color tokens (the D6 set), in emission order.
|
|
@@ -48,17 +59,41 @@ const SUBSTRATE_TOKENS = [
|
|
|
48
59
|
|
|
49
60
|
const ALL_TOKENS = [...SEMANTIC_TOKENS, ...SUBSTRATE_TOKENS];
|
|
50
61
|
|
|
51
|
-
const RAMP_STEPS = ["h1", "h2", "h3", "body", "small", "caption"];
|
|
62
|
+
const RAMP_STEPS = ["h1", "h2", "h3", "body", "small", "caption", "display"];
|
|
63
|
+
const ELEVATION_TOKENS = ["sm", "md", "lg", "overlay"];
|
|
64
|
+
const WASH_TOKENS = ["surface", "emphasis"];
|
|
52
65
|
const LEGAL_VERBOSITY = ["full", "default", "compact", "expert"];
|
|
53
66
|
const LEGAL_FEEDBACK = ["subtle", "minimal"];
|
|
54
67
|
const LEGAL_DENSITY = ["spacious", "standard", "dense"];
|
|
55
68
|
const LEGAL_PROVENANCE = ["project", "extracted", "library", "default"];
|
|
56
|
-
const LEGAL_TYPES = ["color", "dimension", "duration", "cubicBezier", "fontFamily", "fontWeight", "number", "typography"];
|
|
69
|
+
const LEGAL_TYPES = ["color", "dimension", "duration", "cubicBezier", "fontFamily", "fontWeight", "number", "typography", "shadow", "gradient"];
|
|
57
70
|
const LEGAL_THEMES = ["light", "dark"];
|
|
58
71
|
const MANIFEST_KEYS = ["spacing-scale", "radius-scale", "shadow-levels", "motion", "icons", "accent-count"];
|
|
59
72
|
|
|
73
|
+
// The optional persisted intake look block's enums — mirrored from phase-gate.mjs validateLookBlock
|
|
74
|
+
// (the intake authority) so a persisted payload.look cannot rot unchecked.
|
|
75
|
+
const LOOK_THEMES = ["light", "dark", "both"];
|
|
76
|
+
const LOOK_DELIGHTS = ["baseline", "expressive"];
|
|
77
|
+
const LOOK_RESOLUTION_MODES = ["confirmed", "recorded"];
|
|
78
|
+
const LOOK_RESOLUTION_RE = /^(project|default|library:.+|seed:.+\+.+)$/i;
|
|
79
|
+
|
|
80
|
+
// Contract 3 — the emitter registry: every authored token group's named consumer in the emitted
|
|
81
|
+
// output. --emit refuses an authored group absent from this map (a group with no emitter vanishes).
|
|
82
|
+
const EMITTED_GROUPS = new Map([
|
|
83
|
+
["primitive", "the [data-style] primitives block"],
|
|
84
|
+
["color", "the armored theme blocks (:root, :root[data-theme=\"light\"] / :root[data-theme=\"dark\"])"],
|
|
85
|
+
["typography", "the @theme ramp text utilities (text-h1 … text-display)"],
|
|
86
|
+
["elevation", "the --shadow-sm/md/lg/overlay composites + @theme shadow utilities"],
|
|
87
|
+
["wash", "--wash-surface / --wash-emphasis (optional presence)"],
|
|
88
|
+
]);
|
|
89
|
+
const TYPOGRAPHY_SUBGROUPS = ["family", "ramp"];
|
|
90
|
+
|
|
91
|
+
// Legal $description opening tags (rule 15): the provenance-layer vocabulary (source/brief/deviation,
|
|
92
|
+
// 00-layering.md Composition A) plus the established mechanical-annotation prefixes.
|
|
93
|
+
const LEGAL_DESCRIPTION_TAGS = ["source", "brief", "deviation", "derived", "corpus", "rationale", "note", "synthesized", "brand", "dark"];
|
|
94
|
+
|
|
60
95
|
// Shadow-level names ranked by increasing intensity, for the ordering check.
|
|
61
|
-
const SHADOW_RANK = { none: 0, "2xs": 1, xs: 2, sm: 3, md: 4, lg: 5, xl: 6, "2xl": 7 };
|
|
96
|
+
const SHADOW_RANK = { none: 0, "2xs": 1, xs: 2, sm: 3, md: 4, lg: 5, xl: 6, "2xl": 7, overlay: 8 };
|
|
62
97
|
|
|
63
98
|
// ---------------------------------------------------------------------------
|
|
64
99
|
// CSS value parsers — the parse-back authority (rule 9); byte-compatible with convert-design-md.mjs
|
|
@@ -111,16 +146,38 @@ function parseColorCss(css) {
|
|
|
111
146
|
return value;
|
|
112
147
|
}
|
|
113
148
|
m = /^rgba?\(\s*(\d+)\s*,\s*(\d+)\s*,\s*(\d+)\s*(?:,\s*([\d.]+)\s*)?\)$/.exec(s);
|
|
149
|
+
if (!m) m = /^rgba?\(\s*(\d+)\s+(\d+)\s+(\d+)\s*(?:\/\s*([\d.]+%?)\s*)?\)$/.exec(s); // modern space syntax
|
|
114
150
|
if (m) {
|
|
115
151
|
const bytes = [m[1], m[2], m[3]].map(Number);
|
|
116
152
|
const hex = "#" + bytes.map((b) => b.toString(16).padStart(2, "0")).join("");
|
|
117
153
|
const value = { colorSpace: "srgb", components: bytes.map((b) => round5(b / 255)), hex };
|
|
118
|
-
if (m[4] !== undefined) value.alpha = parseFloat(m[4]);
|
|
154
|
+
if (m[4] !== undefined) value.alpha = m[4].endsWith("%") ? round5(parseFloat(m[4]) / 100) : parseFloat(m[4]);
|
|
119
155
|
return value;
|
|
120
156
|
}
|
|
121
157
|
throw new Error(`unparseable color: ${JSON.stringify(css)}`);
|
|
122
158
|
}
|
|
123
159
|
|
|
160
|
+
// Parse one shadow-list string (the elevation byte form) into the DTCG layer array. A layer color's
|
|
161
|
+
// alpha may ride the master multiplier as calc(A * var(--shadow-intensity)) — A is the layer alpha.
|
|
162
|
+
function parseShadowCss(css) {
|
|
163
|
+
return splitTopLevel(css, ",").map((layerRaw) => {
|
|
164
|
+
const s = layerRaw.trim();
|
|
165
|
+
const colorStart = s.search(/rgba?\(|oklch\(|hsla?\(|#[0-9a-fA-F]/);
|
|
166
|
+
if (colorStart < 0) throw new Error(`shadow layer names no color: ${JSON.stringify(s)}`);
|
|
167
|
+
const colorCss = s.slice(colorStart).trim().replace(/calc\(\s*([\d.]+)\s*\*\s*var\(--shadow-intensity\)\s*\)/, "$1");
|
|
168
|
+
const dims = s.slice(0, colorStart).trim().split(/\s+/);
|
|
169
|
+
if (dims.length < 3 || dims.length > 4) throw new Error(`shadow layer wants 3-4 lengths (offsetX offsetY blur [spread]): ${JSON.stringify(s)}`);
|
|
170
|
+
const dim = (d) => (d === "0" ? { value: 0, unit: "px" } : parseDimensionCss(d));
|
|
171
|
+
return {
|
|
172
|
+
color: parseColorCss(colorCss),
|
|
173
|
+
offsetX: dim(dims[0]),
|
|
174
|
+
offsetY: dim(dims[1]),
|
|
175
|
+
blur: dim(dims[2]),
|
|
176
|
+
spread: dims[3] !== undefined ? dim(dims[3]) : { value: 0, unit: "px" },
|
|
177
|
+
};
|
|
178
|
+
});
|
|
179
|
+
}
|
|
180
|
+
|
|
124
181
|
// Parse a tracking string ("0" | "0em" | "-0.015em") into its em-multiplier number.
|
|
125
182
|
function parseTrackingCss(css) {
|
|
126
183
|
const m = /^([+-]?\d*\.?\d+)(em)?$/.exec(css.trim());
|
|
@@ -182,6 +239,25 @@ function serializeValue(type, value) {
|
|
|
182
239
|
if (value.colorSpace === "srgb" && value.hex) return value.hex;
|
|
183
240
|
return `oklch(${value.components.join(" ")}${value.alpha !== undefined ? ` / ${value.alpha}` : ""})`;
|
|
184
241
|
}
|
|
242
|
+
if (type === "shadow") {
|
|
243
|
+
// Deterministic fallback (the migration always preserves .css): zero lengths serialize bare "0";
|
|
244
|
+
// the layer alpha rides the master multiplier so --shadow-intensity keeps flattening shadows.
|
|
245
|
+
const dim = (d) => (d.value === 0 ? "0" : `${d.value}${d.unit}`);
|
|
246
|
+
const layerColor = (c) => {
|
|
247
|
+
const alpha = c.alpha ?? 1;
|
|
248
|
+
if (c.colorSpace === "srgb") {
|
|
249
|
+
const bytes = c.components.map((n) => Math.round(n * 255));
|
|
250
|
+
return `rgb(${bytes.join(" ")} / calc(${alpha} * var(--shadow-intensity)))`;
|
|
251
|
+
}
|
|
252
|
+
return `oklch(${c.components.join(" ")} / calc(${alpha} * var(--shadow-intensity)))`;
|
|
253
|
+
};
|
|
254
|
+
return value.map((l) => `${dim(l.offsetX)} ${dim(l.offsetY)} ${dim(l.blur)} ${dim(l.spread ?? { value: 0, unit: "px" })} ${layerColor(l.color)}`).join(", ");
|
|
255
|
+
}
|
|
256
|
+
if (type === "gradient") {
|
|
257
|
+
// Deterministic fallback (authored washes preserve .css): a top-down tint ramp from the stop list.
|
|
258
|
+
const stops = value.map((s) => `${serializeValue("color", s.color)} ${Math.round(s.position * 100)}%`).join(", ");
|
|
259
|
+
return `linear-gradient(180deg, ${stops})`;
|
|
260
|
+
}
|
|
185
261
|
return String(value);
|
|
186
262
|
}
|
|
187
263
|
|
|
@@ -275,7 +351,7 @@ function loadArtifact(dir, errors) {
|
|
|
275
351
|
errors.push("rule 1 — design.resolver.json: missing the theme modifier");
|
|
276
352
|
return null;
|
|
277
353
|
}
|
|
278
|
-
if (themeModifier.default
|
|
354
|
+
if (!LEGAL_THEMES.includes(themeModifier.default)) errors.push(`rule 1 — design.resolver.json: theme default must be light|dark (found ${JSON.stringify(themeModifier.default)}); it mirrors the payload's theme-default`);
|
|
279
355
|
if (!Array.isArray(themeModifier.contexts?.light) || themeModifier.contexts.light.length !== 0) {
|
|
280
356
|
errors.push("rule 1 — design.resolver.json: the light context must be the empty overlay [] (the base set is the light rendering)");
|
|
281
357
|
}
|
|
@@ -406,24 +482,20 @@ function verifyScalarParseBack(name, token, css, errors) {
|
|
|
406
482
|
}
|
|
407
483
|
}
|
|
408
484
|
|
|
409
|
-
// Run the full coverage gate (rules 1–
|
|
410
|
-
|
|
485
|
+
// Run the full coverage gate (rules 1–15) over one artifact directory; returns everything emit needs.
|
|
486
|
+
// requestedTheme null = boot theme follows the payload's theme-default (light when absent).
|
|
487
|
+
function runCheck(dir, requestedTheme) {
|
|
411
488
|
const errors = [];
|
|
412
489
|
const artifact = loadArtifact(dir, errors);
|
|
413
490
|
if (!artifact) return { errors };
|
|
414
|
-
const { base, files, views, payload } = artifact;
|
|
415
|
-
|
|
416
|
-
if (!views.has(theme)) {
|
|
417
|
-
errors.push(`rule 1 — resolver: --theme ${theme} requested but the resolver carries no ${theme} context`);
|
|
418
|
-
return { errors };
|
|
419
|
-
}
|
|
491
|
+
const { base, resolver, files, views, payload } = artifact;
|
|
420
492
|
|
|
421
493
|
// Rule 2 — the file-level payload on the base root.
|
|
422
494
|
if (!payload || typeof payload !== "object") {
|
|
423
495
|
errors.push(`rule 2 — payload: missing $extensions["${EXT}"] on the base file's root group`);
|
|
424
496
|
return { errors };
|
|
425
497
|
}
|
|
426
|
-
if (payload.schema !== "design-tokens/
|
|
498
|
+
if (payload.schema !== "design-tokens/v2") errors.push(`rule 2 — payload.schema: must be "design-tokens/v2" (found ${JSON.stringify(payload.schema)})`);
|
|
427
499
|
if (payload.dtcg !== "2025.10") errors.push(`rule 2 — payload.dtcg: must be "2025.10" (found ${JSON.stringify(payload.dtcg)})`);
|
|
428
500
|
if (typeof payload.product !== "string" || payload.product.trim() === "") errors.push("rule 2 — payload.product: missing or empty");
|
|
429
501
|
if (!LEGAL_PROVENANCE.includes(payload.provenance)) errors.push(`rule 2 — payload.provenance: must be one of ${LEGAL_PROVENANCE.join("|")} (found ${JSON.stringify(payload.provenance)})`);
|
|
@@ -432,23 +504,63 @@ function runCheck(dir, theme) {
|
|
|
432
504
|
if (!LEGAL_FEEDBACK.includes(payload.feedback)) errors.push(`rule 2 — payload.feedback: must be one of ${LEGAL_FEEDBACK.join("|")} (found ${JSON.stringify(payload.feedback)})`);
|
|
433
505
|
if (!LEGAL_DENSITY.includes(payload["density-tier"])) errors.push(`rule 2 — payload.density-tier: must be one of ${LEGAL_DENSITY.join("|")} (found ${JSON.stringify(payload["density-tier"])})`);
|
|
434
506
|
if (!Array.isArray(payload["product-traits"]) || payload["product-traits"].length === 0) errors.push("rule 2 — payload.product-traits: must be a non-empty array");
|
|
507
|
+
if (payload["theme-default"] !== undefined && !LEGAL_THEMES.includes(payload["theme-default"])) {
|
|
508
|
+
errors.push(`rule 2 — payload.theme-default: must be light|dark when present (found ${JSON.stringify(payload["theme-default"])})`);
|
|
509
|
+
}
|
|
510
|
+
const themeDefault = LEGAL_THEMES.includes(payload["theme-default"]) ? payload["theme-default"] : "light";
|
|
511
|
+
const resolverDefault = (resolver.modifiers ?? []).find((m) => m?.name === "theme")?.default;
|
|
512
|
+
if (LEGAL_THEMES.includes(resolverDefault) && resolverDefault !== themeDefault) {
|
|
513
|
+
errors.push(`rule 2 — design.resolver.json: theme default ${JSON.stringify(resolverDefault)} does not mirror payload theme-default ${JSON.stringify(themeDefault)}`);
|
|
514
|
+
}
|
|
515
|
+
const anchors = payload.anchors;
|
|
516
|
+
const anchorsShapeOk = Array.isArray(anchors) && anchors.length >= 2 && anchors.length <= 3 && anchors.every((a) => typeof a === "string" && a.trim() !== "");
|
|
517
|
+
if (anchors !== undefined && !anchorsShapeOk) errors.push(`rule 2 — payload.anchors: must be an array of 2–3 non-empty strings when present (found ${JSON.stringify(anchors)})`);
|
|
518
|
+
if (payload.provenance === "project" && anchors === undefined) errors.push("rule 2 — payload.anchors: required on provenance: project artifacts (the named real-product anchors from intake)");
|
|
519
|
+
// Rule 2 (look) — the optional persisted intake look block (SCHEMA.md, the payload): enum-checked
|
|
520
|
+
// when present; theme-default and anchors stay the canonical top-level projections emission consumes.
|
|
521
|
+
const look = payload.look;
|
|
522
|
+
if (look !== undefined) {
|
|
523
|
+
if (look === null || typeof look !== "object" || Array.isArray(look)) {
|
|
524
|
+
errors.push("rule 2 — payload.look: must be an object when present (the persisted intake look block)");
|
|
525
|
+
} else {
|
|
526
|
+
if (!LOOK_THEMES.includes(look.theme)) errors.push(`rule 2 — payload.look.theme: must be one of ${LOOK_THEMES.join("|")} (found ${JSON.stringify(look.theme)})`);
|
|
527
|
+
if (!LOOK_DELIGHTS.includes(look.delight)) errors.push(`rule 2 — payload.look.delight: must be one of ${LOOK_DELIGHTS.join("|")} (found ${JSON.stringify(look.delight)})`);
|
|
528
|
+
if (!LOOK_RESOLUTION_MODES.includes(look["resolution-mode"])) errors.push(`rule 2 — payload.look.resolution-mode: must be one of ${LOOK_RESOLUTION_MODES.join("|")} (found ${JSON.stringify(look["resolution-mode"])})`);
|
|
529
|
+
const lookAnchors = look.anchors;
|
|
530
|
+
if (!Array.isArray(lookAnchors) || lookAnchors.length < 2 || lookAnchors.length > 3 || !lookAnchors.every((a) => typeof a === "string" && a.trim() !== "")) {
|
|
531
|
+
errors.push(`rule 2 — payload.look.anchors: must be an array of 2–3 non-empty named strings (found ${JSON.stringify(lookAnchors)})`);
|
|
532
|
+
}
|
|
533
|
+
if (typeof look.resolution !== "string" || !LOOK_RESOLUTION_RE.test(look.resolution.trim())) {
|
|
534
|
+
errors.push(`rule 2 — payload.look.resolution: must match project | library:<slug> | seed:<style>+<palette> | default (found ${JSON.stringify(look.resolution)})`);
|
|
535
|
+
}
|
|
536
|
+
}
|
|
537
|
+
}
|
|
435
538
|
const lm = payload["lever-manifest"];
|
|
436
539
|
for (const key of MANIFEST_KEYS) {
|
|
437
540
|
if (lm?.[key] === undefined) errors.push(`rule 2 — payload.lever-manifest.${key}: missing`);
|
|
438
541
|
}
|
|
439
542
|
|
|
543
|
+
const theme = requestedTheme ?? themeDefault;
|
|
544
|
+
if (!views.has(theme)) {
|
|
545
|
+
errors.push(`rule 1 — resolver: theme ${theme} selected but the resolver carries no ${theme} context`);
|
|
546
|
+
return { errors };
|
|
547
|
+
}
|
|
548
|
+
|
|
440
549
|
const lightView = views.get("light");
|
|
441
550
|
const lightTokens = collectTokens(lightView);
|
|
442
551
|
|
|
443
|
-
// Rule 3 — all
|
|
552
|
+
// Rule 3 — all 27 primitives present with the exact $type, each assigned or hinted.
|
|
444
553
|
for (const [key, type] of PRIMITIVE_TYPES) {
|
|
445
554
|
const t = lightView.primitive?.[key];
|
|
446
555
|
if (!isToken(t)) { errors.push(`rule 3 — primitive.${key}: missing`); continue; }
|
|
447
556
|
if (t.$type !== type) errors.push(`rule 3 — primitive.${key}: $type ${JSON.stringify(t.$type)} (want ${type})`);
|
|
448
557
|
if (isEmptyValue(t.$value)) errors.push(`rule 3 — primitive.${key}: empty $value`);
|
|
558
|
+
if (key === "stagger-unit" && typeof t.$value?.value === "number" && (t.$value.value < 0 || t.$value.value > 60)) {
|
|
559
|
+
errors.push(`rule 3 — primitive.stagger-unit: ${t.$value.value}ms outside the legal 0–60ms range`);
|
|
560
|
+
}
|
|
449
561
|
}
|
|
450
562
|
|
|
451
|
-
// Rule 4 — all
|
|
563
|
+
// Rule 4 — all 36 color roles present, typed color, legal colorSpace, .css present (via alias when aliased).
|
|
452
564
|
for (const role of ALL_TOKENS) {
|
|
453
565
|
const t = lightView.color?.[role];
|
|
454
566
|
if (!isToken(t)) { errors.push(`rule 4 — color.${role}: missing`); continue; }
|
|
@@ -459,11 +571,15 @@ function runCheck(dir, theme) {
|
|
|
459
571
|
if (typeof target.$extensions?.[EXT]?.css !== "string") errors.push(`rule 4 — color.${role}: missing preserved .css`);
|
|
460
572
|
}
|
|
461
573
|
|
|
462
|
-
// Rule 5 — typography coverage: the three families and the
|
|
574
|
+
// Rule 5 — typography coverage: the three families (display optional) and the seven-step ramp with all five sub-values.
|
|
463
575
|
for (const fam of ["heading", "body", "mono"]) {
|
|
464
576
|
const t = lightView.typography?.family?.[fam];
|
|
465
577
|
if (!isToken(t) || t.$type !== "fontFamily") errors.push(`rule 5 — typography.family.${fam}: missing or not $type fontFamily`);
|
|
466
578
|
}
|
|
579
|
+
const famDisplay = lightView.typography?.family?.display;
|
|
580
|
+
if (famDisplay !== undefined && (!isToken(famDisplay) || famDisplay.$type !== "fontFamily")) {
|
|
581
|
+
errors.push("rule 5 — typography.family.display: not $type fontFamily (the slot is optional; its shape is not)");
|
|
582
|
+
}
|
|
467
583
|
for (const step of RAMP_STEPS) {
|
|
468
584
|
const t = lightView.typography?.ramp?.[step];
|
|
469
585
|
if (!isToken(t) || t.$type !== "typography") { errors.push(`rule 5 — typography.ramp.${step}: missing or not $type typography`); continue; }
|
|
@@ -504,21 +620,25 @@ function runCheck(dir, theme) {
|
|
|
504
620
|
if (typeof lm.icons?.family !== "string" || lm.icons.family === "") errors.push("rule 7 — lever-manifest.icons.family: missing or empty");
|
|
505
621
|
if (!(lm["accent-count"] >= 1)) errors.push(`rule 7 — lever-manifest.accent-count: must be >= 1 (found ${JSON.stringify(lm?.["accent-count"])})`);
|
|
506
622
|
|
|
507
|
-
// Rule 8 — the look's own base values sit on its own allowlist.
|
|
508
|
-
|
|
509
|
-
|
|
510
|
-
|
|
623
|
+
// Rule 8 — the look's own base values sit on its own allowlist. An off-manifest value is legal
|
|
624
|
+
// only as a recorded deviation: its $description must open with the deviation: layer tag (rule 15).
|
|
625
|
+
const deviationTagged = (t) => typeof t?.$description === "string" && t.$description.startsWith("deviation:");
|
|
626
|
+
const radiusToken = lightView.primitive?.radius;
|
|
627
|
+
const radius = radiusToken?.$value?.value;
|
|
628
|
+
if (Array.isArray(lm["radius-scale"]) && radius !== undefined && !lm["radius-scale"].includes(radius) && !deviationTagged(radiusToken)) {
|
|
629
|
+
errors.push(`rule 8 — primitive.radius: ${radius} not on lever-manifest.radius-scale (off-manifest values need a deviation: $description)`);
|
|
511
630
|
}
|
|
512
631
|
for (const key of ["duration-fast", "duration-normal", "duration-slow"]) {
|
|
513
|
-
const
|
|
514
|
-
|
|
515
|
-
|
|
632
|
+
const t = lightView.primitive?.[key];
|
|
633
|
+
const ms = t?.$value?.value;
|
|
634
|
+
if (Array.isArray(lm.motion?.durations) && ms !== undefined && !lm.motion.durations.includes(ms) && !deviationTagged(t)) {
|
|
635
|
+
errors.push(`rule 8 — primitive.${key}: ${ms} not in lever-manifest.motion.durations (off-manifest values need a deviation: $description)`);
|
|
516
636
|
}
|
|
517
637
|
}
|
|
518
638
|
const ease = lightView.primitive?.["ease-default"];
|
|
519
639
|
if (isToken(ease) && Array.isArray(lm.motion?.easings)) {
|
|
520
640
|
const easeCss = typeof ease.$extensions?.[EXT]?.css === "string" ? ease.$extensions[EXT].css : serializeValue("cubicBezier", ease.$value);
|
|
521
|
-
if (!lm.motion.easings.includes(easeCss)) errors.push(`rule 8 — primitive.ease-default: ${JSON.stringify(easeCss)} not in lever-manifest.motion.easings`);
|
|
641
|
+
if (!lm.motion.easings.includes(easeCss) && !deviationTagged(ease)) errors.push(`rule 8 — primitive.ease-default: ${JSON.stringify(easeCss)} not in lever-manifest.motion.easings (off-manifest values need a deviation: $description)`);
|
|
522
642
|
}
|
|
523
643
|
}
|
|
524
644
|
|
|
@@ -531,6 +651,20 @@ function runCheck(dir, theme) {
|
|
|
531
651
|
const name = `${prefix}${path}`;
|
|
532
652
|
if (token.$type === "color") {
|
|
533
653
|
verifyColorParseBack(name, token, errors);
|
|
654
|
+
} else if (token.$type === "shadow" && typeof css === "string") {
|
|
655
|
+
try {
|
|
656
|
+
const reparsed = parseShadowCss(css);
|
|
657
|
+
if (JSON.stringify(reparsed) !== JSON.stringify(token.$value)) errors.push(`rule 9 — ${name}: shadow css ${JSON.stringify(css)} does not parse back to $value`);
|
|
658
|
+
} catch (e) {
|
|
659
|
+
errors.push(`rule 9 — ${name}: ${e.message}`);
|
|
660
|
+
}
|
|
661
|
+
} else if (token.$type === "gradient" && typeof css === "string") {
|
|
662
|
+
// A gradient's .css carries geometry the stop list does not; verify each stop color's byte form appears in it.
|
|
663
|
+
for (const [i, stop] of (Array.isArray(token.$value) ? token.$value : []).entries()) {
|
|
664
|
+
if (stop?.color && typeof stop.color === "object" && !css.includes(serializeValue("color", stop.color))) {
|
|
665
|
+
errors.push(`rule 9 — ${name}: stop ${i} color ${serializeValue("color", stop.color)} not present in the preserved .css`);
|
|
666
|
+
}
|
|
667
|
+
}
|
|
534
668
|
} else if (typeof css === "string") {
|
|
535
669
|
verifyScalarParseBack(name, token, css, errors);
|
|
536
670
|
} else if (token.$type === "typography" && typeof css === "object" && css !== null) {
|
|
@@ -546,7 +680,7 @@ function runCheck(dir, theme) {
|
|
|
546
680
|
}
|
|
547
681
|
}
|
|
548
682
|
|
|
549
|
-
// Rule 10 — the dark file, when present, carries exactly the
|
|
683
|
+
// Rule 10 — the dark file, when present, carries exactly the 36-role color group and nothing else.
|
|
550
684
|
const darkDoc = files.get("design.tokens.dark.json");
|
|
551
685
|
if (darkDoc !== undefined) {
|
|
552
686
|
const rootKeys = Object.keys(darkDoc).filter((k) => !k.startsWith("$"));
|
|
@@ -565,7 +699,7 @@ function runCheck(dir, theme) {
|
|
|
565
699
|
}
|
|
566
700
|
}
|
|
567
701
|
|
|
568
|
-
// Rule 11 — only the
|
|
702
|
+
// Rule 11 — only the ten legal $type values appear, in any file.
|
|
569
703
|
for (const [fileName, doc] of files) {
|
|
570
704
|
const prefix = fileName === "design.tokens.json" ? "" : `${fileName} `;
|
|
571
705
|
for (const [path, token] of collectTokens(doc)) {
|
|
@@ -573,6 +707,70 @@ function runCheck(dir, theme) {
|
|
|
573
707
|
}
|
|
574
708
|
}
|
|
575
709
|
|
|
710
|
+
// True when a shadow/wash layer color is a legal DTCG color value; records rule errors under `where`.
|
|
711
|
+
const checkLayerColor = (color, where, rule) => {
|
|
712
|
+
if (!color || typeof color !== "object" || !["oklch", "srgb"].includes(color.colorSpace) || !Array.isArray(color.components)) {
|
|
713
|
+
errors.push(`${rule} — ${where}: color must be a typed oklch|srgb value (found ${JSON.stringify(color)})`);
|
|
714
|
+
return;
|
|
715
|
+
}
|
|
716
|
+
const isPureBlack = color.colorSpace === "srgb"
|
|
717
|
+
? color.components.every((c) => c === 0)
|
|
718
|
+
: color.components[0] === 0 && color.components[1] === 0;
|
|
719
|
+
if (rule === "rule 13" && isPureBlack && (color.alpha ?? 1) >= 0.15) {
|
|
720
|
+
errors.push(`${rule} — ${where}: pure black at alpha ${color.alpha ?? 1} (≥ 0.15) — shadow colors are brand-tintable, never a hard black drop`);
|
|
721
|
+
}
|
|
722
|
+
};
|
|
723
|
+
|
|
724
|
+
// Rule 13 — elevation coverage: all four elevation.* present, $type shadow, ≥ 1 layer each,
|
|
725
|
+
// every layer shape-legal and rule-conformant, the byte form preserved; each assigned or hinted.
|
|
726
|
+
for (const level of ELEVATION_TOKENS) {
|
|
727
|
+
const t = lightView.elevation?.[level];
|
|
728
|
+
if (!isToken(t)) { errors.push(`rule 13 — elevation.${level}: missing`); continue; }
|
|
729
|
+
if (t.$type !== "shadow") { errors.push(`rule 13 — elevation.${level}: $type ${JSON.stringify(t.$type)} (want shadow)`); continue; }
|
|
730
|
+
if (!Array.isArray(t.$value) || t.$value.length === 0) { errors.push(`rule 13 — elevation.${level}: $value must be a non-empty array of layers`); continue; }
|
|
731
|
+
for (const [i, layer] of t.$value.entries()) {
|
|
732
|
+
checkLayerColor(layer?.color, `elevation.${level} layer ${i}`, "rule 13");
|
|
733
|
+
for (const dim of ["offsetX", "offsetY", "blur"]) {
|
|
734
|
+
if (typeof layer?.[dim]?.value !== "number") errors.push(`rule 13 — elevation.${level} layer ${i}: ${dim} must be a dimension`);
|
|
735
|
+
}
|
|
736
|
+
}
|
|
737
|
+
if (typeof t.$extensions?.[EXT]?.css !== "string") errors.push(`rule 13 — elevation.${level}: missing preserved .css (the serialized shadow list)`);
|
|
738
|
+
}
|
|
739
|
+
for (const level of Object.keys(lightView.elevation ?? {})) {
|
|
740
|
+
if (!level.startsWith("$") && !ELEVATION_TOKENS.includes(level)) errors.push(`rule 13 — elevation.${level}: not a legal level (sm|md|lg|overlay)`);
|
|
741
|
+
}
|
|
742
|
+
|
|
743
|
+
// Rule 14 — wash shape: the group is optional; when present, ≤ 2 gradient tokens named
|
|
744
|
+
// surface/emphasis with rule-conformant stops. Presence is never required.
|
|
745
|
+
if (lightView.wash !== undefined) {
|
|
746
|
+
const washKeys = Object.keys(lightView.wash).filter((k) => !k.startsWith("$"));
|
|
747
|
+
if (washKeys.length > 2) errors.push(`rule 14 — wash: at most 2 tokens (found ${washKeys.length})`);
|
|
748
|
+
for (const key of washKeys) {
|
|
749
|
+
if (!WASH_TOKENS.includes(key)) { errors.push(`rule 14 — wash.${key}: not a legal wash slot (surface|emphasis)`); continue; }
|
|
750
|
+
const t = lightView.wash[key];
|
|
751
|
+
if (!isToken(t) || t.$type !== "gradient") { errors.push(`rule 14 — wash.${key}: must be $type gradient`); continue; }
|
|
752
|
+
if (!Array.isArray(t.$value) || t.$value.length < 2) { errors.push(`rule 14 — wash.${key}: $value must be an array of ≥ 2 stops`); continue; }
|
|
753
|
+
for (const [i, stop] of t.$value.entries()) {
|
|
754
|
+
checkLayerColor(stop?.color, `wash.${key} stop ${i}`, "rule 14");
|
|
755
|
+
if (typeof stop?.position !== "number" || stop.position < 0 || stop.position > 1) errors.push(`rule 14 — wash.${key} stop ${i}: position must be a number in 0–1`);
|
|
756
|
+
}
|
|
757
|
+
if (typeof t.$extensions?.[EXT]?.css !== "string") errors.push(`rule 14 — wash.${key}: missing preserved .css (the authored gradient)`);
|
|
758
|
+
}
|
|
759
|
+
}
|
|
760
|
+
|
|
761
|
+
// Rule 15 — provenance vocabulary: a per-value $description opening with a tag must use a legal
|
|
762
|
+
// one (source:/brief:/deviation: are the layer tags; untagged = inherited). Rule 8 enforces the
|
|
763
|
+
// off-manifest side (every off-manifest value carries deviation:).
|
|
764
|
+
for (const [fileName, doc] of files) {
|
|
765
|
+
const prefix = fileName === "design.tokens.json" ? "" : `${fileName} `;
|
|
766
|
+
for (const [path, token] of collectTokens(doc)) {
|
|
767
|
+
const m = typeof token.$description === "string" && /^([a-z][a-z-]*):/.exec(token.$description);
|
|
768
|
+
if (m && !LEGAL_DESCRIPTION_TAGS.includes(m[1])) {
|
|
769
|
+
errors.push(`rule 15 — ${prefix}${path}: $description opens with unknown tag "${m[1]}:" (layer tags: source:|brief:|deviation:; untagged = inherited)`);
|
|
770
|
+
}
|
|
771
|
+
}
|
|
772
|
+
}
|
|
773
|
+
|
|
576
774
|
// Rule 12 inventory — every token still carrying a hint (passes --check; --emit refuses).
|
|
577
775
|
const hints = [];
|
|
578
776
|
for (const [fileName, doc] of files) {
|
|
@@ -583,17 +781,19 @@ function runCheck(dir, theme) {
|
|
|
583
781
|
}
|
|
584
782
|
}
|
|
585
783
|
|
|
586
|
-
return { errors, payload, views, hints, hasDark: files.has("design.tokens.dark.json") };
|
|
784
|
+
return { errors, payload, views, hints, theme, hasDark: files.has("design.tokens.dark.json"), hasWash: lightView.wash !== undefined };
|
|
587
785
|
}
|
|
588
786
|
|
|
589
787
|
// ---------------------------------------------------------------------------
|
|
590
788
|
// Emitter feed — flatten the resolved views into the value map the emitters consume
|
|
591
789
|
// ---------------------------------------------------------------------------
|
|
592
790
|
|
|
593
|
-
// Assemble the emitters' document:
|
|
594
|
-
//
|
|
791
|
+
// Assemble the emitters' document: 27 primitive CSS strings + the payload dials, the 36-role CSS
|
|
792
|
+
// string map per theme, the elevation/wash value strings, and the ramp steps as emitter-ready data
|
|
793
|
+
// (preferring each token's preserved .css throughout).
|
|
595
794
|
function buildEmitterDoc(views, payload, errors) {
|
|
596
|
-
const
|
|
795
|
+
const lightView = views.get("light");
|
|
796
|
+
const lightTokens = collectTokens(lightView);
|
|
597
797
|
const primitives = {};
|
|
598
798
|
for (const key of PRIMITIVES) primitives[key] = cssFor(`primitive.${key}`, lightTokens, errors);
|
|
599
799
|
primitives.verbosity = payload.verbosity;
|
|
@@ -609,7 +809,222 @@ function buildEmitterDoc(views, payload, errors) {
|
|
|
609
809
|
for (const role of ALL_TOKENS) colorTokens.dark[role] = cssFor(`color.${role}`, darkTokens, errors);
|
|
610
810
|
}
|
|
611
811
|
|
|
612
|
-
|
|
812
|
+
const elevation = {};
|
|
813
|
+
for (const level of ELEVATION_TOKENS) elevation[level] = cssFor(`elevation.${level}`, lightTokens, errors);
|
|
814
|
+
|
|
815
|
+
let wash = null;
|
|
816
|
+
if (lightView.wash !== undefined) {
|
|
817
|
+
wash = {};
|
|
818
|
+
for (const key of WASH_TOKENS) {
|
|
819
|
+
if (isToken(lightView.wash[key])) wash[key] = cssFor(`wash.${key}`, lightTokens, errors);
|
|
820
|
+
}
|
|
821
|
+
}
|
|
822
|
+
|
|
823
|
+
// Ramp steps as @theme text-utility data: size/line-height as literals; weight and tracking as
|
|
824
|
+
// var() references when they alias a personality primitive, so the utilities track the cascade.
|
|
825
|
+
const ramp = {};
|
|
826
|
+
for (const step of RAMP_STEPS) {
|
|
827
|
+
const t = lightView.typography?.ramp?.[step];
|
|
828
|
+
if (!isToken(t) || !t.$value || typeof t.$value !== "object") { errors.push(`emit — typography.ramp.${step}: missing composite`); continue; }
|
|
829
|
+
const v = t.$value;
|
|
830
|
+
const weightVar = { "{primitive.font-weight-strong}": "var(--font-weight-strong)", "{primitive.font-weight-normal}": "var(--font-weight-normal)" };
|
|
831
|
+
let fontWeight;
|
|
832
|
+
if (typeof v.fontWeight === "number") fontWeight = String(v.fontWeight);
|
|
833
|
+
else if (weightVar[v.fontWeight]) fontWeight = weightVar[v.fontWeight];
|
|
834
|
+
else {
|
|
835
|
+
const target = resolveAlias(v.fontWeight, lightTokens, "fontWeight", `typography.ramp.${step}.fontWeight`, errors);
|
|
836
|
+
fontWeight = target ? String(target.$value) : "400";
|
|
837
|
+
}
|
|
838
|
+
const preservedTracking = t.$extensions?.[EXT]?.css?.letterSpacing;
|
|
839
|
+
let letterSpacing;
|
|
840
|
+
if (v.letterSpacing === "{primitive.heading-tracking}") letterSpacing = "var(--heading-tracking)";
|
|
841
|
+
else if (typeof preservedTracking === "string") letterSpacing = preservedTracking;
|
|
842
|
+
else if (typeof v.letterSpacing === "number") letterSpacing = `${v.letterSpacing}em`;
|
|
843
|
+
else {
|
|
844
|
+
const target = resolveAlias(v.letterSpacing, lightTokens, "number", `typography.ramp.${step}.letterSpacing`, errors);
|
|
845
|
+
letterSpacing = target ? `${target.$value}em` : "0em";
|
|
846
|
+
}
|
|
847
|
+
ramp[step] = {
|
|
848
|
+
fontSize: `${v.fontSize?.value}${v.fontSize?.unit ?? "px"}`,
|
|
849
|
+
lineHeight: String(v.lineHeight),
|
|
850
|
+
fontWeight,
|
|
851
|
+
letterSpacing,
|
|
852
|
+
};
|
|
853
|
+
}
|
|
854
|
+
|
|
855
|
+
return { primitives, elevation, wash, ramp, manifest: { "color-tokens": colorTokens } };
|
|
856
|
+
}
|
|
857
|
+
|
|
858
|
+
// ---------------------------------------------------------------------------
|
|
859
|
+
// Contract 3 — emitter completeness: every authored group reaches consumable output
|
|
860
|
+
// ---------------------------------------------------------------------------
|
|
861
|
+
|
|
862
|
+
// Refuse emission when any resolved view carries an authored token group (or typography subgroup)
|
|
863
|
+
// absent from the emitter registry — machinery that accepts data it never emits is a silent breach.
|
|
864
|
+
function checkEmitterCompleteness(views, emitErrors) {
|
|
865
|
+
for (const [context, view] of views) {
|
|
866
|
+
const label = context === "light" ? "" : ` [${context}]`;
|
|
867
|
+
for (const group of Object.keys(view)) {
|
|
868
|
+
if (group.startsWith("$")) continue;
|
|
869
|
+
if (!EMITTED_GROUPS.has(group)) {
|
|
870
|
+
emitErrors.push(`emitter completeness — authored token group "${group}"${label} has no emitter (contract 3): an authored group with no emitter simply vanishes; add its emitter to this script or remove the group. Emitters exist for: ${[...EMITTED_GROUPS.keys()].join(", ")}.`);
|
|
871
|
+
}
|
|
872
|
+
}
|
|
873
|
+
for (const sub of Object.keys(view.typography ?? {})) {
|
|
874
|
+
if (!sub.startsWith("$") && !TYPOGRAPHY_SUBGROUPS.includes(sub)) {
|
|
875
|
+
emitErrors.push(`emitter completeness — authored token group "typography.${sub}"${label} has no emitter (contract 3): only typography.family and typography.ramp reach the emitted output.`);
|
|
876
|
+
}
|
|
877
|
+
}
|
|
878
|
+
}
|
|
879
|
+
}
|
|
880
|
+
|
|
881
|
+
// ---------------------------------------------------------------------------
|
|
882
|
+
// Contract 2 — font binding: fonts.css generation + the fail-closed cod-16 gate
|
|
883
|
+
// ---------------------------------------------------------------------------
|
|
884
|
+
|
|
885
|
+
// Parse a CSV text into row objects keyed by its header (RFC quoting: "" escapes a quote inside a quoted field).
|
|
886
|
+
function parseCsv(text) {
|
|
887
|
+
const records = [];
|
|
888
|
+
let field = "", record = [], inQuotes = false;
|
|
889
|
+
const endField = () => { record.push(field); field = ""; };
|
|
890
|
+
const endRecord = () => { endField(); if (record.some((f) => f !== "")) records.push(record); record = []; };
|
|
891
|
+
for (let i = 0; i < text.length; i++) {
|
|
892
|
+
const c = text[i];
|
|
893
|
+
if (inQuotes) {
|
|
894
|
+
if (c === '"' && text[i + 1] === '"') { field += '"'; i++; }
|
|
895
|
+
else if (c === '"') inQuotes = false;
|
|
896
|
+
else field += c;
|
|
897
|
+
} else if (c === '"') inQuotes = true;
|
|
898
|
+
else if (c === ",") endField();
|
|
899
|
+
else if (c === "\n") endRecord();
|
|
900
|
+
else if (c !== "\r") field += c;
|
|
901
|
+
}
|
|
902
|
+
endRecord();
|
|
903
|
+
const header = records.shift() ?? [];
|
|
904
|
+
return records.map((cells) => Object.fromEntries(header.map((h, i) => [h, cells[i] ?? ""])));
|
|
905
|
+
}
|
|
906
|
+
|
|
907
|
+
// Load the family → registering-import resolver: typography.csv css-import rows first (paste-ready
|
|
908
|
+
// static @fontsource imports, registering the plain family names), fonts-known.json as the fallback
|
|
909
|
+
// oracle (family → static weights). Returns resolve(family) → { family, pkg, imports } | null.
|
|
910
|
+
function loadFontResolver() {
|
|
911
|
+
const byFamily = new Map();
|
|
912
|
+
try {
|
|
913
|
+
for (const row of parseCsv(readFileSync(join(COMBINATIONS_DIR, "typography.csv"), "utf8"))) {
|
|
914
|
+
for (const col of ["heading_family", "body_family", "mono_family"]) {
|
|
915
|
+
const family = (row[col] ?? "").trim();
|
|
916
|
+
if (family === "" || byFamily.has(family.toLowerCase())) continue;
|
|
917
|
+
const slug = familySlug(family);
|
|
918
|
+
const imports = [...String(row["css-import"] ?? "").matchAll(/@import\s+'(@fontsource\/([a-z0-9-]+)\/[^']+)'/g)]
|
|
919
|
+
.filter((m) => m[2] === slug)
|
|
920
|
+
.map((m) => `@import '${m[1]}';`);
|
|
921
|
+
if (imports.length > 0) byFamily.set(family.toLowerCase(), { family, pkg: `@fontsource/${slug}`, imports });
|
|
922
|
+
}
|
|
923
|
+
}
|
|
924
|
+
} catch { /* typography.csv unreadable — fonts-known.json stays the oracle */ }
|
|
925
|
+
let knownFamilies = {};
|
|
926
|
+
try { knownFamilies = JSON.parse(readFileSync(join(COMBINATIONS_DIR, "fonts-known.json"), "utf8")).families ?? {}; } catch { /* absent oracle degrades to csv-only resolution */ }
|
|
927
|
+
const knownLower = new Map(Object.entries(knownFamilies).map(([name, weights]) => [name.toLowerCase(), weights]));
|
|
928
|
+
return (family) => {
|
|
929
|
+
const hit = byFamily.get(family.toLowerCase());
|
|
930
|
+
if (hit) return hit;
|
|
931
|
+
const weights = knownLower.get(family.toLowerCase());
|
|
932
|
+
if (!Array.isArray(weights) || weights.length === 0) return null;
|
|
933
|
+
const slug = familySlug(family);
|
|
934
|
+
return { family, pkg: `@fontsource/${slug}`, imports: weights.map((w) => `@import '@fontsource/${slug}/${w}.css';`) };
|
|
935
|
+
};
|
|
936
|
+
}
|
|
937
|
+
|
|
938
|
+
// Collect the artifact's font surfaces: the two emitted stacks (the cod-16 gate's subjects) and the
|
|
939
|
+
// deduped named families across every fontFamily token (import generation covers all of them).
|
|
940
|
+
function collectFontSurfaces(views, doc, errors) {
|
|
941
|
+
const lightTokens = collectTokens(views.get("light"));
|
|
942
|
+
const stacks = [
|
|
943
|
+
{ name: "--font-sans", families: parseFontStackCss(String(doc.primitives["font-sans"])) },
|
|
944
|
+
{ name: "--font-mono", families: parseFontStackCss(String(doc.primitives["font-mono"])) },
|
|
945
|
+
];
|
|
946
|
+
const importFamilies = [];
|
|
947
|
+
const seen = new Set();
|
|
948
|
+
const push = (family) => {
|
|
949
|
+
const key = family.toLowerCase();
|
|
950
|
+
if (!seen.has(key) && !SYSTEM_FONT_KEYWORDS.has(key)) { seen.add(key); importFamilies.push(family); }
|
|
951
|
+
};
|
|
952
|
+
for (const stack of stacks) for (const family of stack.families) push(family);
|
|
953
|
+
for (const slot of ["heading", "body", "mono", "display"]) {
|
|
954
|
+
if (!lightTokens.has(`typography.family.${slot}`)) continue;
|
|
955
|
+
for (const family of parseFontStackCss(cssFor(`typography.family.${slot}`, lightTokens, errors))) push(family);
|
|
956
|
+
}
|
|
957
|
+
return { stacks, importFamilies };
|
|
958
|
+
}
|
|
959
|
+
|
|
960
|
+
// Read the emit target's dependency map, or null when the target is standalone (no package.json) —
|
|
961
|
+
// the standalone case degrades the cod-16 gate to import-list validation.
|
|
962
|
+
function readTargetDeps(outdir) {
|
|
963
|
+
const pkgPath = join(outdir, "package.json");
|
|
964
|
+
if (!existsSync(pkgPath)) return null;
|
|
965
|
+
try {
|
|
966
|
+
const pkg = JSON.parse(readFileSync(pkgPath, "utf8"));
|
|
967
|
+
return { ...(pkg.dependencies ?? {}), ...(pkg.devDependencies ?? {}) };
|
|
968
|
+
} catch {
|
|
969
|
+
return null;
|
|
970
|
+
}
|
|
971
|
+
}
|
|
972
|
+
|
|
973
|
+
// The emitted src/styles/fonts.css content: the generated registering-import list, one derivation.
|
|
974
|
+
function emitFontsCss(entries, unresolved) {
|
|
975
|
+
const lines = [
|
|
976
|
+
"/* src/styles/fonts.css — @fontsource font registrations (emitted; do not hand-edit).",
|
|
977
|
+
" *",
|
|
978
|
+
" * One derivation, not two authored artifacts: this import list is GENERATED from the",
|
|
979
|
+
" * artifact's fontFamily tokens (typography.csv css-import rows; fonts-known.json fallback),",
|
|
980
|
+
" * and the emitted stacks use the same registered family names — so the flagship font cannot",
|
|
981
|
+
" * silently fail to bind (cod-16 gates the pair at --emit). Loaded FIRST by globals.css",
|
|
982
|
+
" * (step 1 of the five-step import order): faces register before any stack consumes them.",
|
|
983
|
+
" * To change a family, edit design.tokens.json and re-run derive-personality.mjs --emit. */",
|
|
984
|
+
];
|
|
985
|
+
for (const entry of entries) lines.push("", `/* ${entry.family} — ${entry.pkg} */`, ...entry.imports);
|
|
986
|
+
for (const family of unresolved) lines.push("", `/* UNRESOLVED: "${family}" — no known @fontsource package registers it; register it via @font-face or swap the family (cod-16). */`);
|
|
987
|
+
if (entries.length === 0 && unresolved.length === 0) lines.push("", "/* every stack family is a system/generic face — nothing to register. */");
|
|
988
|
+
return lines.join("\n") + "\n";
|
|
989
|
+
}
|
|
990
|
+
|
|
991
|
+
// Contract 2 in full: generate fonts.css from the artifact's fontFamily tokens and run the cod-16
|
|
992
|
+
// binding gate over the emitted stacks — (a) each named family rides a generated import whose
|
|
993
|
+
// package is installed (project targets; standalone targets degrade to import-list validation), or
|
|
994
|
+
// (b) is a recognized system/generic family. Returns { fontsCss, failures }.
|
|
995
|
+
function buildFontBinding(views, doc, outdir, errors) {
|
|
996
|
+
const resolveFamily = loadFontResolver();
|
|
997
|
+
const { stacks, importFamilies } = collectFontSurfaces(views, doc, errors);
|
|
998
|
+
const deps = readTargetDeps(outdir);
|
|
999
|
+
const entries = [];
|
|
1000
|
+
const unresolved = [];
|
|
1001
|
+
for (const family of importFamilies) {
|
|
1002
|
+
const hit = resolveFamily(family);
|
|
1003
|
+
if (hit) { entries.push(hit); continue; }
|
|
1004
|
+
const variablePkg = `@fontsource-variable/${familySlug(family)}`;
|
|
1005
|
+
if (deps !== null && variablePkg in deps) {
|
|
1006
|
+
// The consumer already installed the variable package (registers the "<Family> Variable" name).
|
|
1007
|
+
entries.push({ family, pkg: variablePkg, imports: [`@import '${variablePkg}';`] });
|
|
1008
|
+
continue;
|
|
1009
|
+
}
|
|
1010
|
+
unresolved.push(family);
|
|
1011
|
+
}
|
|
1012
|
+
const entryByFamily = new Map(entries.map((entry) => [entry.family.toLowerCase(), entry]));
|
|
1013
|
+
const failures = [];
|
|
1014
|
+
for (const stack of stacks) {
|
|
1015
|
+
for (const family of stack.families) {
|
|
1016
|
+
const key = family.toLowerCase();
|
|
1017
|
+
if (SYSTEM_FONT_KEYWORDS.has(key)) continue;
|
|
1018
|
+
const entry = entryByFamily.get(key);
|
|
1019
|
+
const slug = familySlug(family);
|
|
1020
|
+
if (!entry) {
|
|
1021
|
+
failures.push(`"${family}" (emitted ${stack.name} stack): no known @fontsource package registers it (typography.csv, fonts-known.json${deps !== null ? ", package.json" : ""}) — install and register one (npm install @fontsource/${slug}, then re-run --emit) or swap to a bindable family / system generic.`);
|
|
1022
|
+
} else if (deps !== null && !(entry.pkg in deps) && !(`@fontsource/${slug}` in deps) && !(`@fontsource-variable/${slug}` in deps)) {
|
|
1023
|
+
failures.push(`"${family}" (emitted ${stack.name} stack): its registering import needs ${entry.pkg}, absent from package.json — npm install ${entry.pkg}`);
|
|
1024
|
+
}
|
|
1025
|
+
}
|
|
1026
|
+
}
|
|
1027
|
+
return { fontsCss: emitFontsCss(entries, unresolved), failures };
|
|
613
1028
|
}
|
|
614
1029
|
|
|
615
1030
|
// ---------------------------------------------------------------------------
|
|
@@ -639,17 +1054,11 @@ function deriveEaseEnter(easeDefault) {
|
|
|
639
1054
|
return "cubic-bezier(0.16, 1, 0.3, 1)";
|
|
640
1055
|
}
|
|
641
1056
|
|
|
642
|
-
//
|
|
643
|
-
|
|
644
|
-
|
|
645
|
-
const
|
|
646
|
-
return Math.max(0, Math.min(40, Math.round(raw)));
|
|
647
|
-
}
|
|
648
|
-
|
|
649
|
-
// Build the [data-style] primitive lines (governed density refs + derived temporals inserted after ease-default).
|
|
650
|
-
function buildStyleBlockLines(primitives) {
|
|
1057
|
+
// Build the [data-style] primitive lines (governed density refs + derived temporals inserted after
|
|
1058
|
+
// the temporal family), then the elevation composites and the optional wash pair.
|
|
1059
|
+
function buildStyleBlockLines(doc) {
|
|
1060
|
+
const primitives = doc.primitives;
|
|
651
1061
|
const easeEnter = deriveEaseEnter(primitives["ease-default"]);
|
|
652
|
-
const staggerMs = deriveStaggerMs(primitives["duration-unit"]);
|
|
653
1062
|
const lines = [];
|
|
654
1063
|
for (const key of PRIMITIVES) {
|
|
655
1064
|
if (key === "density-gap") lines.push(" --density-gap: var(--density-base-gap);");
|
|
@@ -657,10 +1066,18 @@ function buildStyleBlockLines(primitives) {
|
|
|
657
1066
|
else if (key === "font-sans") lines.push(` --font-sans: ${quoteFontStack(primitives["font-sans"])};`);
|
|
658
1067
|
else if (key === "font-mono") lines.push(` --font-mono: ${quoteFontStack(primitives["font-mono"])};`);
|
|
659
1068
|
else lines.push(` --${key}: ${primitives[key]};`);
|
|
660
|
-
if (key === "
|
|
1069
|
+
if (key === "stagger-unit") {
|
|
661
1070
|
lines.push(` --ease-enter: ${easeEnter}; /* derived from ease-default */`);
|
|
662
1071
|
lines.push(" --ease-exit: var(--ease-default); /* derived: departure mirrors the neutral curve */");
|
|
663
|
-
lines.push(
|
|
1072
|
+
lines.push(" --stagger-delay: var(--stagger-unit); /* derived: the motion-stagger cascade base */");
|
|
1073
|
+
}
|
|
1074
|
+
}
|
|
1075
|
+
lines.push(" /* elevation (4) — designed shadow composites; --shadow-intensity stays the master multiplier */");
|
|
1076
|
+
for (const level of ELEVATION_TOKENS) lines.push(` --shadow-${level}: ${doc.elevation[level]};`);
|
|
1077
|
+
if (doc.wash) {
|
|
1078
|
+
lines.push(" /* wash (optional, <= 2) — low-chroma tint ramps, consumed only on budget-granted surfaces */");
|
|
1079
|
+
for (const key of WASH_TOKENS) {
|
|
1080
|
+
if (doc.wash[key] !== undefined) lines.push(` --wash-${key}: ${doc.wash[key]};`);
|
|
664
1081
|
}
|
|
665
1082
|
}
|
|
666
1083
|
return lines.join("\n");
|
|
@@ -678,23 +1095,26 @@ function emitPersonalityCss(doc, slug, meta) {
|
|
|
678
1095
|
|
|
679
1096
|
const semanticLines = SEMANTIC_TOKENS.map((t) => ` --${t}: ${light[t]};`).join("\n");
|
|
680
1097
|
const substrateLines = SUBSTRATE_TOKENS.map((t) => ` --${t}: ${light[t]};`).join("\n");
|
|
681
|
-
const styleLines = buildStyleBlockLines(
|
|
1098
|
+
const styleLines = buildStyleBlockLines(doc);
|
|
682
1099
|
|
|
683
1100
|
const darkSection = dark
|
|
684
1101
|
? `/* ── Dark theme — OPT-IN ONLY (this look ships a dark overlay). ──
|
|
685
|
-
*
|
|
686
|
-
*
|
|
687
|
-
|
|
688
|
-
|
|
1102
|
+
* CASCADE CONTRACT 1b: :root[data-theme="dark"] (0,2,0) — a lone [data-theme="dark"]
|
|
1103
|
+
* attribute selector (0,1,0) loses to the library's later bare :root block by source
|
|
1104
|
+
* order. design.tokens.dark.json remaps all 36 color tokens; <html data-theme="dark">
|
|
1105
|
+
* opts in (theme-default decides the boot attribute). */
|
|
1106
|
+
:root[data-theme="dark"] {
|
|
1107
|
+
/* semantic (19) */
|
|
689
1108
|
${SEMANTIC_TOKENS.map((t) => ` --${t}: ${dark[t]};`).join("\n")}
|
|
690
1109
|
/* substrate (17, the D6 set) — popover/secondary/chart/sidebar the library consumes */
|
|
691
1110
|
${SUBSTRATE_TOKENS.map((t) => ` --${t}: ${dark[t]};`).join("\n")}
|
|
692
1111
|
}`
|
|
693
1112
|
: `/* ── Dark theme — OPT-IN ONLY. ──
|
|
694
|
-
* Emitted only when the artifact ships a design.tokens.dark.json dark overlay
|
|
695
|
-
*
|
|
696
|
-
|
|
697
|
-
|
|
1113
|
+
* Emitted only when the artifact ships a design.tokens.dark.json dark overlay,
|
|
1114
|
+
* as :root[data-theme="dark"] (0,2,0 — armored against the library's later :root).
|
|
1115
|
+
* This block never appears for a light-only project.
|
|
1116
|
+
:root[data-theme="dark"] {
|
|
1117
|
+
--background: ...; --foreground: ...; ... all 36 tokens remapped ...
|
|
698
1118
|
} */`;
|
|
699
1119
|
|
|
700
1120
|
return `/* src/styles/personality.css — operator-UI personality tokens (emitted; do not hand-edit).
|
|
@@ -706,14 +1126,14 @@ ${SUBSTRATE_TOKENS.map((t) => ` --${t}: ${dark[t]};`).join("\n")}
|
|
|
706
1126
|
* density: ${density} (<html data-density="${density}">)
|
|
707
1127
|
* verbosity: ${verbosity} (<html data-verbosity="${verbosity}">)
|
|
708
1128
|
* feedback: ${feedback} (<html data-feedback="${feedback}">)
|
|
709
|
-
* theme: ${meta.theme} (<html data-theme="${meta.theme}">) —
|
|
1129
|
+
* theme: ${meta.theme} (<html data-theme="${meta.theme}">) — the payload's theme-default decides the boot theme
|
|
710
1130
|
* artifact: design.tokens.json (resolved via design.resolver.json) — provenance: ${meta.provenance}, sourced ${meta.sourced}
|
|
711
1131
|
*
|
|
712
1132
|
* Semantic-token doctrine: components name purpose (bg-card, text-foreground, border-border);
|
|
713
1133
|
* this file resolves those names to values. Components never use dark: prefixes or literal colors.
|
|
714
1134
|
*/
|
|
715
1135
|
|
|
716
|
-
/* ── System baseline: the
|
|
1136
|
+
/* ── System baseline: the 27 primitives at neutral operator defaults. ──
|
|
717
1137
|
* The safety net if <html> ever carries no data-style. The project's real
|
|
718
1138
|
* values live in the single [data-style="${slug}"] block below, which
|
|
719
1139
|
* overrides these. Improve/extract rewrites that block, never this baseline. */
|
|
@@ -726,9 +1146,10 @@ ${SUBSTRATE_TOKENS.map((t) => ` --${t}: ${dark[t]};`).join("\n")}
|
|
|
726
1146
|
--density-base-inset: 1;
|
|
727
1147
|
--density-gap: var(--density-base-gap);
|
|
728
1148
|
--density-inset: var(--density-base-inset);
|
|
729
|
-
/* typographic
|
|
730
|
-
|
|
731
|
-
--font-
|
|
1149
|
+
/* typographic — generics only: the baseline never names a webfont it cannot
|
|
1150
|
+
* guarantee is registered (cod-16); the [data-style] block carries the real stacks. */
|
|
1151
|
+
--font-sans: system-ui, sans-serif;
|
|
1152
|
+
--font-mono: ui-monospace, SFMono-Regular, monospace;
|
|
732
1153
|
--font-weight-normal: 400;
|
|
733
1154
|
--font-weight-strong: 600;
|
|
734
1155
|
--heading-tracking: -0.01em;
|
|
@@ -738,11 +1159,17 @@ ${SUBSTRATE_TOKENS.map((t) => ` --${t}: ${dark[t]};`).join("\n")}
|
|
|
738
1159
|
--duration-normal: var(--duration-unit);
|
|
739
1160
|
--duration-slow: calc(var(--duration-unit) * 2);
|
|
740
1161
|
--ease-default: cubic-bezier(0.4, 0, 0.2, 1);
|
|
1162
|
+
--stagger-unit: 30ms;
|
|
741
1163
|
--ease-enter: cubic-bezier(0.16, 1, 0.3, 1);
|
|
742
1164
|
--ease-exit: var(--ease-default);
|
|
743
|
-
--stagger-delay:
|
|
1165
|
+
--stagger-delay: var(--stagger-unit);
|
|
744
1166
|
/* surface */
|
|
745
1167
|
--contrast-boost: 1;
|
|
1168
|
+
/* elevation (neutral slate; the [data-style] block below carries the designed composites) */
|
|
1169
|
+
--shadow-sm: 0 1px 3px 0 rgb(15 23 42 / calc(0.10 * var(--shadow-intensity))), 0 1px 2px -1px rgb(15 23 42 / calc(0.10 * var(--shadow-intensity)));
|
|
1170
|
+
--shadow-md: 0 4px 6px -1px rgb(15 23 42 / calc(0.10 * var(--shadow-intensity))), 0 2px 4px -2px rgb(15 23 42 / calc(0.10 * var(--shadow-intensity)));
|
|
1171
|
+
--shadow-lg: 0 10px 15px -3px rgb(15 23 42 / calc(0.10 * var(--shadow-intensity))), 0 4px 6px -4px rgb(15 23 42 / calc(0.10 * var(--shadow-intensity)));
|
|
1172
|
+
--shadow-overlay: 0 20px 25px -5px rgb(15 23 42 / calc(0.10 * var(--shadow-intensity))), 0 8px 10px -6px rgb(15 23 42 / calc(0.10 * var(--shadow-intensity)));
|
|
746
1173
|
/* iconography */
|
|
747
1174
|
--icon-stroke: 1.75;
|
|
748
1175
|
--icon-size-base: 16px;
|
|
@@ -765,13 +1192,14 @@ ${SUBSTRATE_TOKENS.map((t) => ` --${t}: ${dark[t]};`).join("\n")}
|
|
|
765
1192
|
}
|
|
766
1193
|
|
|
767
1194
|
/* ── Semantic + substrate color tokens — light theme. ──
|
|
768
|
-
* CASCADE CONTRACT 1:
|
|
769
|
-
* :root
|
|
770
|
-
*
|
|
771
|
-
*
|
|
1195
|
+
* CASCADE CONTRACT 1: both theme blocks carry the same 0,2,0 armor — light as
|
|
1196
|
+
* :root, :root[data-theme="light"] and dark as :root[data-theme="dark"] — because
|
|
1197
|
+
* a bare :root or a lone attribute selector loses to the library's later :root
|
|
1198
|
+
* block in @nurix/components/styles.css by source order. All 36 tokens are set
|
|
1199
|
+
* (D6): no consumed token falls back to a shadcn-gray default. */
|
|
772
1200
|
:root,
|
|
773
1201
|
:root[data-theme="light"] {
|
|
774
|
-
/* semantic (
|
|
1202
|
+
/* semantic (19) */
|
|
775
1203
|
${semanticLines}
|
|
776
1204
|
/* substrate (17, the D6 set) — popover/secondary/chart/sidebar the library consumes */
|
|
777
1205
|
${substrateLines}
|
|
@@ -779,9 +1207,10 @@ ${substrateLines}
|
|
|
779
1207
|
|
|
780
1208
|
/* ── The project personality: the single [data-style] block (D3, rosterless). ──
|
|
781
1209
|
* Named after the project — not a preset. This materializes the artifact's
|
|
782
|
-
* primitive token group
|
|
783
|
-
*
|
|
784
|
-
*
|
|
1210
|
+
* primitive token group (all 27 primitives + the derived temporals), the four
|
|
1211
|
+
* elevation composites, and the optional wash pair. It is the one block
|
|
1212
|
+
* improve/extract rewrites. density-gap/inset are governed refs, so the global
|
|
1213
|
+
* tier and per-surface step still compose. */
|
|
785
1214
|
[data-style="${slug}"] {
|
|
786
1215
|
${styleLines}
|
|
787
1216
|
}
|
|
@@ -812,10 +1241,34 @@ ${styleLines}
|
|
|
812
1241
|
|
|
813
1242
|
${darkSection}
|
|
814
1243
|
|
|
1244
|
+
/* ── Motion moments — named classes consuming the temporal tokens. ──
|
|
1245
|
+
* motion-lift: hover lift (-1px + elevation.sm) at duration-fast/ease-default.
|
|
1246
|
+
* motion-stagger: children arrival cascade at --stagger-delay, capped at 8
|
|
1247
|
+
* children (mot-08); consumption is budget-gated by the surface stamp. Both are
|
|
1248
|
+
* inert under prefers-reduced-motion (mot-03). */
|
|
1249
|
+
@media (prefers-reduced-motion: no-preference) {
|
|
1250
|
+
.motion-lift {
|
|
1251
|
+
transition: transform var(--duration-fast) var(--ease-default), box-shadow var(--duration-fast) var(--ease-default);
|
|
1252
|
+
}
|
|
1253
|
+
.motion-lift:hover {
|
|
1254
|
+
transform: translateY(-1px);
|
|
1255
|
+
box-shadow: var(--shadow-sm);
|
|
1256
|
+
}
|
|
1257
|
+
@keyframes motion-stagger-arrive {
|
|
1258
|
+
from { opacity: 0; transform: translateY(4px); }
|
|
1259
|
+
to { opacity: 1; transform: translateY(0); }
|
|
1260
|
+
}
|
|
1261
|
+
.motion-stagger > * {
|
|
1262
|
+
animation: motion-stagger-arrive var(--duration-normal) var(--ease-enter) backwards;
|
|
1263
|
+
animation-delay: calc(var(--stagger-delay) * 7); /* children past the 8-cap arrive with the 8th */
|
|
1264
|
+
}
|
|
1265
|
+
${Array.from({ length: 8 }, (_, i) => ` .motion-stagger > :nth-child(${i + 1}) { animation-delay: calc(var(--stagger-delay) * ${i}); }`).join("\n")}
|
|
1266
|
+
}
|
|
1267
|
+
|
|
815
1268
|
/* ── Accessibility ── */
|
|
816
1269
|
@media (prefers-reduced-motion: reduce) {
|
|
817
1270
|
:root, [data-style] {
|
|
818
|
-
--duration-unit: 0ms; --stagger-delay: 0ms;
|
|
1271
|
+
--duration-unit: 0ms; --stagger-unit: 0ms; --stagger-delay: 0ms;
|
|
819
1272
|
--ease-default: linear; --ease-enter: linear; --ease-exit: linear;
|
|
820
1273
|
--feedback-press-scale: 1;
|
|
821
1274
|
}
|
|
@@ -838,15 +1291,17 @@ function emitGlobalsCss(doc) {
|
|
|
838
1291
|
|
|
839
1292
|
return `/* src/styles/globals.css — Tailwind v4 glue + @theme inline mappings (emitted). */
|
|
840
1293
|
|
|
841
|
-
/* CASCADE CONTRACT 2: this
|
|
842
|
-
*
|
|
843
|
-
*
|
|
844
|
-
*
|
|
845
|
-
|
|
846
|
-
@import "
|
|
847
|
-
@import "
|
|
848
|
-
|
|
849
|
-
@
|
|
1294
|
+
/* CASCADE CONTRACT 2: this five-step order is LOAD-BEARING. fonts.css registers the
|
|
1295
|
+
* @fontsource faces before any stack consumes them; the library's styles.css ships
|
|
1296
|
+
* its own :root token defaults and its own @theme radius scale, so our @theme MUST
|
|
1297
|
+
* come AFTER the import to win the radius scale and own every mapping.
|
|
1298
|
+
* rules/frontend.md states the same order — co-authority. */
|
|
1299
|
+
@import "./fonts.css"; /* 1. font registration (faces before stacks) */
|
|
1300
|
+
@import "./personality.css"; /* 2. token source */
|
|
1301
|
+
@import "tailwindcss"; /* 3. framework */
|
|
1302
|
+
@import "@nurix/components/styles.css"; /* 4. library (before our @theme) */
|
|
1303
|
+
|
|
1304
|
+
@theme inline { /* 5. consumer theme — AFTER the library, so it wins */
|
|
850
1305
|
/* radius scale — shadcn offsets on --radius, re-asserted so the CONSUMER owns it.
|
|
851
1306
|
* A flat personality sets --radius: 0 → every step collapses to 0. */
|
|
852
1307
|
--radius-sm: calc(var(--radius) - 4px);
|
|
@@ -859,12 +1314,14 @@ function emitGlobalsCss(doc) {
|
|
|
859
1314
|
* tier AND the per-surface density step into the library's internals. */
|
|
860
1315
|
--spacing: calc(0.25rem * var(--density-gap));
|
|
861
1316
|
|
|
862
|
-
/* shadow —
|
|
1317
|
+
/* shadow — the elevation.* composites become the shadow-* utilities; every alpha rides
|
|
1318
|
+
* --shadow-intensity (2xs/xs stay the neutral slate hairlines the library leans on) */
|
|
863
1319
|
--shadow-2xs: 0 1px 2px 0 rgb(15 23 42 / calc(0.04 * var(--shadow-intensity)));
|
|
864
1320
|
--shadow-xs: 0 1px 2px 0 rgb(15 23 42 / calc(0.06 * var(--shadow-intensity)));
|
|
865
|
-
--shadow-sm:
|
|
866
|
-
--shadow-md:
|
|
867
|
-
--shadow-lg:
|
|
1321
|
+
--shadow-sm: ${doc.elevation.sm};
|
|
1322
|
+
--shadow-md: ${doc.elevation.md};
|
|
1323
|
+
--shadow-lg: ${doc.elevation.lg};
|
|
1324
|
+
--shadow-overlay: ${doc.elevation.overlay};
|
|
868
1325
|
|
|
869
1326
|
/* font weight — two-weight discipline: medium/semibold/bold all resolve to the
|
|
870
1327
|
* single emphasis weight; operator UI carries no third weight. normal is emitted
|
|
@@ -878,6 +1335,16 @@ function emitGlobalsCss(doc) {
|
|
|
878
1335
|
--tracking-tighter: var(--heading-tracking);
|
|
879
1336
|
--tracking-tight: var(--heading-tracking);
|
|
880
1337
|
|
|
1338
|
+
/* type ramp — the seven authored steps as text-* utilities (typ-09: the type scale IS the
|
|
1339
|
+
* declared ramp; text-display is the scale-drama step, brand-forward surfaces only, typ-20) */
|
|
1340
|
+
${RAMP_STEPS.map((step) => {
|
|
1341
|
+
const r = doc.ramp[step];
|
|
1342
|
+
return ` --text-${step}: ${r.fontSize};
|
|
1343
|
+
--text-${step}--line-height: ${r.lineHeight};
|
|
1344
|
+
--text-${step}--font-weight: ${r.fontWeight};
|
|
1345
|
+
--text-${step}--letter-spacing: ${r.letterSpacing};`;
|
|
1346
|
+
}).join("\n")}
|
|
1347
|
+
|
|
881
1348
|
/* fonts — emitted as the literal stack: a font is not a runtime-toggled token, and
|
|
882
1349
|
* the theme key shares a name with the personality primitive (no self-reference).
|
|
883
1350
|
* personality.css carries the same --font-sans/--font-mono for direct var() use. */
|
|
@@ -891,15 +1358,17 @@ function emitGlobalsCss(doc) {
|
|
|
891
1358
|
--transition-enter: var(--duration-normal) var(--ease-enter);
|
|
892
1359
|
--transition-exit: var(--duration-fast) var(--ease-exit);
|
|
893
1360
|
|
|
894
|
-
/* semantic + substrate color maps — all
|
|
1361
|
+
/* semantic + substrate color maps — all 36, so bg-popover / bg-chart-1 / bg-sidebar and the
|
|
1362
|
+
* dark ladder's bg-surface-raised / bg-surface-overlay resolve.
|
|
895
1363
|
* --color-destructive-foreground is mapped explicitly (D6): the library leaves it unmapped. */
|
|
896
1364
|
${colorMaps}
|
|
897
1365
|
}
|
|
898
1366
|
|
|
899
|
-
/* dark variant —
|
|
900
|
-
*
|
|
901
|
-
*
|
|
902
|
-
|
|
1367
|
+
/* dark variant — ALWAYS ACTIVE: redefines the library's class-based dark: variant to
|
|
1368
|
+
* follow the data-theme attribute, so library dark: utilities (legal inside the library,
|
|
1369
|
+
* banned in project JSX by the no-literals rule) track the same attribute the theme
|
|
1370
|
+
* blocks armor. Inert until <html data-theme="dark">; harmless for a light-only project. */
|
|
1371
|
+
@custom-variant dark (&:where([data-theme="dark"], [data-theme="dark"] *));
|
|
903
1372
|
`;
|
|
904
1373
|
}
|
|
905
1374
|
|
|
@@ -932,7 +1401,7 @@ function writeFileEnsured(path, content) {
|
|
|
932
1401
|
|
|
933
1402
|
// Print usage and exit non-zero.
|
|
934
1403
|
function usage() {
|
|
935
|
-
process.stderr.write("usage: derive-personality.mjs <artifact-dir> [--check | --emit <outdir>] [--theme light|dark]\n");
|
|
1404
|
+
process.stderr.write("usage: derive-personality.mjs <artifact-dir> [--check | --emit <outdir> [--provisional]] [--theme light|dark]\n");
|
|
936
1405
|
process.exit(2);
|
|
937
1406
|
}
|
|
938
1407
|
|
|
@@ -942,20 +1411,23 @@ function main() {
|
|
|
942
1411
|
let artifactDir = null;
|
|
943
1412
|
let mode = "check";
|
|
944
1413
|
let outdir = null;
|
|
945
|
-
let theme =
|
|
1414
|
+
let theme = null; // null = follow the payload's theme-default
|
|
1415
|
+
let provisional = false;
|
|
946
1416
|
for (let i = 0; i < argv.length; i++) {
|
|
947
1417
|
const a = argv[i];
|
|
948
1418
|
if (a === "--check") mode = "check";
|
|
949
1419
|
else if (a === "--emit") { mode = "emit"; outdir = argv[++i] ?? null; }
|
|
950
1420
|
else if (a === "--theme") { theme = argv[++i] ?? null; }
|
|
1421
|
+
else if (a === "--provisional") provisional = true;
|
|
951
1422
|
else if (!a.startsWith("-") && artifactDir === null) artifactDir = a;
|
|
952
1423
|
else usage();
|
|
953
1424
|
}
|
|
954
1425
|
if (!artifactDir) usage();
|
|
955
1426
|
if (mode === "emit" && !outdir) usage();
|
|
956
|
-
if (
|
|
1427
|
+
if (provisional && mode !== "emit") usage();
|
|
1428
|
+
if (theme !== null && !LEGAL_THEMES.includes(theme)) usage();
|
|
957
1429
|
|
|
958
|
-
const { errors, payload, views, hints, hasDark } = runCheck(artifactDir, theme);
|
|
1430
|
+
const { errors, payload, views, hints, theme: bootTheme, hasDark, hasWash } = runCheck(artifactDir, theme);
|
|
959
1431
|
|
|
960
1432
|
if (errors.length > 0) {
|
|
961
1433
|
process.stderr.write(`check FAILED (${artifactDir}): ${errors.length} issue(s)\n`);
|
|
@@ -967,39 +1439,59 @@ function main() {
|
|
|
967
1439
|
|
|
968
1440
|
if (mode === "check") {
|
|
969
1441
|
const hintNote = hints.length > 0 ? `, ${hints.length} hint(s) outstanding (emit will refuse)` : "";
|
|
970
|
-
process.stdout.write(`check PASSED (${artifactDir}): rules 1–
|
|
1442
|
+
process.stdout.write(`check PASSED (${artifactDir}): rules 1–15 OK — 27 primitives, 36 color roles, typography 3+7, elevation 4${hasWash ? ", wash present" : ""}, payload/manifest/parse-back OK — provenance=${payload.provenance}, style=${slug}, theme=${bootTheme}${hasDark ? ", dark overlay present" : ""}${hintNote}\n`);
|
|
971
1443
|
process.exit(0);
|
|
972
1444
|
}
|
|
973
1445
|
|
|
974
|
-
// Rule 12 — emission precondition: refuse while any token carries a hint.
|
|
975
|
-
|
|
1446
|
+
// Rule 12 — emission precondition: refuse while any token carries a hint. --provisional (the
|
|
1447
|
+
// golden/regression flow) emits hinted tokens at their provisional $values instead — loudly.
|
|
1448
|
+
if (hints.length > 0 && !provisional) {
|
|
976
1449
|
process.stderr.write(`emit REFUSED (${artifactDir}): ${hints.length} unresolved hint(s) — rule 12: every gated token must be concrete before emission.\n`);
|
|
977
1450
|
for (const h of hints) process.stderr.write(` - ${h.key}: hint: ${h.text}\n`);
|
|
978
1451
|
process.stderr.write(" Resolve each with judgment: set the real $value, delete the hint key, record the rationale in $description — then re-run --emit.\n");
|
|
979
1452
|
process.exit(1);
|
|
980
1453
|
}
|
|
1454
|
+
if (hints.length > 0) {
|
|
1455
|
+
process.stdout.write(`emit PROVISIONAL (${artifactDir}): ${hints.length} hint(s) emitted at their provisional $values — regression/golden flow only, never shipped output.\n`);
|
|
1456
|
+
}
|
|
981
1457
|
|
|
982
1458
|
const emitErrors = [];
|
|
983
1459
|
const doc = buildEmitterDoc(views, payload, emitErrors);
|
|
1460
|
+
checkEmitterCompleteness(views, emitErrors);
|
|
1461
|
+
const fontBinding = buildFontBinding(views, doc, outdir, emitErrors);
|
|
984
1462
|
if (emitErrors.length > 0) {
|
|
985
1463
|
process.stderr.write(`emit FAILED (${artifactDir}): ${emitErrors.length} issue(s)\n`);
|
|
986
1464
|
for (const e of emitErrors) process.stderr.write(` - ${e}\n`);
|
|
987
1465
|
process.exit(1);
|
|
988
1466
|
}
|
|
989
1467
|
|
|
990
|
-
|
|
1468
|
+
// Contract 2 — the cod-16 font-binding gate, fail-closed at --emit. --provisional (the golden/
|
|
1469
|
+
// regression flow over hint-carrying or brand-font library twins) demotes it to a loud warning.
|
|
1470
|
+
if (fontBinding.failures.length > 0) {
|
|
1471
|
+
if (!provisional) {
|
|
1472
|
+
process.stderr.write(`emit FAILED (${artifactDir}): font binding (cod-16) — ${fontBinding.failures.length} unbound famil${fontBinding.failures.length === 1 ? "y" : "ies"}\n`);
|
|
1473
|
+
for (const f of fontBinding.failures) process.stderr.write(` - ${f}\n`);
|
|
1474
|
+
process.exit(1);
|
|
1475
|
+
}
|
|
1476
|
+
process.stdout.write(`emit PROVISIONAL font-binding warning (${artifactDir}): ${fontBinding.failures.length} unbound famil${fontBinding.failures.length === 1 ? "y" : "ies"} — cod-16 fails closed at a real --emit\n`);
|
|
1477
|
+
for (const f of fontBinding.failures) process.stdout.write(` - ${f}\n`);
|
|
1478
|
+
}
|
|
1479
|
+
|
|
1480
|
+
const meta = { provenance: payload.provenance, sourced: payload.sourced, theme: bootTheme };
|
|
991
1481
|
const personalityCss = emitPersonalityCss(doc, slug, meta);
|
|
992
1482
|
const globalsCss = emitGlobalsCss(doc);
|
|
993
1483
|
const htmlAttrs = emitIndexHtmlAttributes(doc, slug, meta);
|
|
994
1484
|
const personalityPath = join(outdir, "src/styles/personality.css");
|
|
995
1485
|
const globalsPath = join(outdir, "src/styles/globals.css");
|
|
1486
|
+
const fontsPath = join(outdir, "src/styles/fonts.css");
|
|
996
1487
|
const attrsPath = join(outdir, "index-html-attributes.txt");
|
|
997
1488
|
writeFileEnsured(personalityPath, personalityCss);
|
|
998
1489
|
writeFileEnsured(globalsPath, globalsCss);
|
|
1490
|
+
writeFileEnsured(fontsPath, fontBinding.fontsCss);
|
|
999
1491
|
writeFileEnsured(attrsPath, htmlAttrs);
|
|
1000
1492
|
|
|
1001
1493
|
process.stdout.write(`emitted personality system for style=${slug} → ${outdir}\n`);
|
|
1002
|
-
process.stdout.write(` ${personalityPath}\n ${globalsPath}\n ${attrsPath}\n`);
|
|
1494
|
+
process.stdout.write(` ${personalityPath}\n ${globalsPath}\n ${fontsPath}\n ${attrsPath}\n`);
|
|
1003
1495
|
process.exit(0);
|
|
1004
1496
|
}
|
|
1005
1497
|
|