@goldencomm/baseline-tokens 0.0.0-stage → 0.1.0

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.
Files changed (55) hide show
  1. package/README.md +50 -2
  2. package/build.mjs +491 -0
  3. package/cli.mjs +323 -0
  4. package/icons/figma-preferred.json +293 -0
  5. package/icons/vocabulary.json +444 -0
  6. package/icons.mjs +280 -0
  7. package/import.mjs +132 -0
  8. package/lint.mjs +277 -0
  9. package/looks/looks.json +16 -0
  10. package/looks/shopify/accordion-contained.css +24 -0
  11. package/looks/shopify/accordion-default.css +2 -0
  12. package/looks/shopify/accordion-split.css +32 -0
  13. package/looks/shopify/pagination-bordered.css +37 -0
  14. package/looks/shopify/pagination-default.css +7 -0
  15. package/looks/shopify/pagination-grouped.css +30 -0
  16. package/looks/shopify/tabs-default.css +13 -0
  17. package/looks/shopify/tabs-line.css +35 -0
  18. package/looks/wordpress/accordion-contained.css +43 -0
  19. package/looks/wordpress/accordion-default.css +9 -0
  20. package/looks/wordpress/accordion-split.css +72 -0
  21. package/looks/wordpress/pagination-bordered.css +73 -0
  22. package/looks/wordpress/pagination-default.css +38 -0
  23. package/looks/wordpress/pagination-grouped.css +56 -0
  24. package/looks/wordpress/tabs-default.css +17 -0
  25. package/looks/wordpress/tabs-line.css +33 -0
  26. package/looks.mjs +126 -0
  27. package/package.json +66 -4
  28. package/src/figma/color-schemes.error.json +611 -0
  29. package/src/figma/color-schemes.scheme-1.json +611 -0
  30. package/src/figma/color-schemes.scheme-2.json +611 -0
  31. package/src/figma/color-schemes.success.json +611 -0
  32. package/src/figma/color-schemes.warning.json +611 -0
  33. package/src/figma/components.value.json +9229 -0
  34. package/src/figma/manifest.json +6437 -0
  35. package/src/figma/primitives.mode-1.json +6903 -0
  36. package/src/figma/responsive.2xl.json +562 -0
  37. package/src/figma/responsive.base.json +562 -0
  38. package/src/figma/responsive.lg.json +562 -0
  39. package/src/figma/responsive.md.json +562 -0
  40. package/src/figma/responsive.sm.json +562 -0
  41. package/src/figma/responsive.xl.json +562 -0
  42. package/src/figma/surface.accent.json +150 -0
  43. package/src/figma/surface.background.json +149 -0
  44. package/src/figma/surface.card.json +149 -0
  45. package/src/figma/surface.destructive.json +151 -0
  46. package/src/figma/surface.ghost.json +149 -0
  47. package/src/figma/surface.inverse.json +151 -0
  48. package/src/figma/surface.link.json +149 -0
  49. package/src/figma/surface.muted.json +150 -0
  50. package/src/figma/surface.outline.json +149 -0
  51. package/src/figma/surface.popover.json +149 -0
  52. package/src/figma/surface.primary.json +151 -0
  53. package/src/figma/surface.secondary.json +151 -0
  54. package/src/figma/text-styles.json +179 -0
  55. package/typeset.css +512 -0
package/README.md CHANGED
@@ -1,3 +1,51 @@
1
- # Temporary Holding Version
1
+ # @goldencomm/baseline-tokens
2
2
 
3
- This version is a temporary placeholder for this package. An operational version to replace this has been submitted for review and is awaiting a staged release.
3
+ Core Baseline tokens (the Figma variable export, imported as DTCG), the lints, the Style Dictionary build to every
4
+ platform output, and the system icon generator. Two ways to run it:
5
+
6
+ **In this monorepo** (core): `npm run tokens:import` after a plugin export; `npm run tokens`, `npm run icons:build`.
7
+ Outputs are committed for every platform and gated by `npm run check`.
8
+
9
+ **In a client project** (one platform), via the CLI this package ships:
10
+ ```bash
11
+ npx baseline-tokens build --platform wordpress --out wp-content/themes/<theme> --client client.tokens.json
12
+ npx baseline-tokens icons --platform wordpress --out wp-content/themes/<theme> --client client.tokens.json
13
+ npx baseline-tokens client from-defs --core-mode scheme-1=defs.json --core-mode scheme-2=defs2.json --out client.tokens.json
14
+ ```
15
+ `--out` is the platform root: the Next/HTML folder that imports `tokens.css`, or the WordPress / Shopify theme
16
+ folder. `client.tokens.json` holds value overrides keyed by core paths (`<collection-slug>.<mode-slug>` -> DTCG tree);
17
+ a name core does not have is rejected (ADR-0001: a client theme is core with different values), except additions in
18
+ Primitives folders that are never emitted (`colors/*` ramps, `spacing/*`), which a clone may add freely and the build
19
+ ignores with a note (ADR-0027 §5). The lints `spacing-derived`, `radius-derived` and `tailwind-frozen` (against the
20
+ installed `tailwindcss`) refuse hand-edited derived scales; regenerate them in the plugin's Scales panel. The Claude runbook
21
+ that produces it from a client's Figma theme is `.claude/skills/baseline-client-tokens/SKILL.md`.
22
+
23
+ **Code Connect for a client** (ADR-0021: the client's Figma library is a clone of the Core file, so node ids are
24
+ Core's and only the file key differs):
25
+ ```bash
26
+ npx baseline-tokens code-connect --platform wordpress --out wp-content/themes/<theme> --figma-file <clone key or URL> --parse
27
+ npx baseline-tokens code-connect --platform wordpress --out wp-content/themes/<theme> --figma-file <clone key or URL> --publish # CI only
28
+ ```
29
+ Writes `<root>/figma.config.json` (label per platform, default include globs `components/ui/**` for Next,
30
+ `blocks/**` + `code-connect/**` for WordPress, `blocks/**` for Shopify; override with `--include <glob>`), with
31
+ `documentUrlSubstitutions` mapping Core's key to the clone's. `--parse` checks the templates, `--dry-run` validates
32
+ against Figma (validation is on since ADR-0029), `--publish` publishes and refuses outside CI (`CI` unset) or without `FIGMA_ACCESS_TOKEN`. The Code
33
+ Connect CLI runs from the platform root (its include globs resolve against the working directory), using the
34
+ project's own `@figma/code-connect` when installed, otherwise `npx`. Re-run after every re-clone (new file key).
35
+
36
+ `icons` needs the client's active Iconify set installed in the project (`npm i -D @iconify-json/<prefix>`); the
37
+ command names it. The other sets are optional peers (this repo installs all five to keep the vocabulary complete).
38
+
39
+ ## Files
40
+ | | |
41
+ |---|---|
42
+ | `import.mjs` | plugin export -> `src/figma/<collection>.<mode>.json` + `manifest.json` |
43
+ | `lint.mjs` | shadcn set, naming/codeSyntax derivation (ADR-0012), component geometry rules (ADR-0017/0019) |
44
+ | `build.mjs` | Style Dictionary -> registry CSS/cssVars, WordPress/HTML `tokens.css`, Shopify snippets + settings; copies `typeset.css` per platform (ADR-0028) |
45
+ | `typeset.css` | Canonical Baseline fork of shadcn typeset (prose rhythm); platform copies are build outputs |
46
+ | `icons.mjs` | vocabulary x active set -> React icon map, WordPress icon collection, Shopify snippet + block options, HTML sprite, Code Connect reverse maps |
47
+ | `cli.mjs` | the per-platform orchestration above (scratch copy of core + client overrides -> generators -> copy out) |
48
+ | `test/run.mjs` | fixture -> import -> lint -> build assertions, determinism, and a client-override CLI smoke |
49
+
50
+ Env for tests/CLI: `TOKENS_SRC_DIR` (token source), `TOKENS_OUT_ROOT` (monorepo-shaped output root), `TOKENS_CLIENT=1`
51
+ (client mode: resolved literals may replace core aliases).
package/build.mjs ADDED
@@ -0,0 +1,491 @@
1
+ // Style Dictionary v5 build -> committed platform outputs. Runs from this folder only (chdir guard).
2
+ // Theme modes become CSS blocks: first mode (Scheme 1) -> :root and [data-scheme="scheme-1"], others -> [data-scheme="<mode-slug>"].
3
+ // Responsive modes -> @media (min-width) blocks. Primitives are inputs for alias resolution, never emitted.
4
+ import StyleDictionary from "style-dictionary";
5
+ import { readFileSync, writeFileSync, mkdirSync, existsSync } from "node:fs";
6
+ import { join, dirname } from "node:path";
7
+ import { fileURLToPath } from "node:url";
8
+
9
+ const here = dirname(fileURLToPath(import.meta.url));
10
+ process.chdir(here);
11
+ const root = process.env.TOKENS_OUT_ROOT ?? join(here, "..");
12
+ const src = process.env.TOKENS_SRC_DIR ?? join(here, "src", "figma");
13
+ if (!existsSync(join(src, "manifest.json"))) { console.error("build: run import first"); process.exit(1); }
14
+ const manifest = JSON.parse(readFileSync(join(src, "manifest.json"), "utf8"));
15
+ const col = (name) => manifest.collections.find((c) => c.name === name);
16
+ const modeFile = (c, m) => join(src, `${c.slug}.${m.slug}.json`);
17
+ const primitives = col("Primitives");
18
+ const theme = col("Color Schemes");
19
+ if (!theme) { console.error("build: Color Schemes collection missing"); process.exit(1); }
20
+
21
+ // Apply a Figma "color with opacity" to a resolved hex literal -> 8-digit hex. Correct for a PRIMITIVE, whose
22
+ // value is the same in every mode. NOT correct for a component tint that aliases a scheme role -- see roleTint.
23
+ const withOpacity = (hex, opacity) => {
24
+ if (!/^#[0-9a-f]{6}$/i.test(hex) || opacity === undefined) return hex;
25
+ return hex + Math.round(opacity * 255).toString(16).padStart(2, "0");
26
+ };
27
+
28
+ // A component tint is an alias to a Color Schemes role carrying an opacity (ADR-0017 §2). It must resolve at
29
+ // RUNTIME, not here: baking it would resolve the role against whichever mode Style Dictionary loaded and
30
+ // freeze the colour, which is exactly what the exception exists to avoid.
31
+ //
32
+ // color-mix() rather than relative colour syntax (`rgb(from var(--primary) r g b / 5%)`), and not for speed --
33
+ // both resolve once during style computation, neither is on a per-frame path, and the difference is not
34
+ // measurable. The reasons are support and consistency: color-mix() is Baseline widely available while relative
35
+ // colour syntax is only newly available, and Tailwind v4 already emits exactly this form for every `/alpha`
36
+ // utility in this codebase. Emitting the same string means swapping `bg-primary/5` for the variable is a
37
+ // byte-identical resolved value, which is trivial to verify.
38
+ const componentRefs = new Map(); // token path -> raw $value, before Style Dictionary resolves it
39
+ const roleVars = new Map(); // color role path -> its custom property
40
+ const indexRaw = (file, into, wantCodeSyntax) => {
41
+ const walk = (node, path) => {
42
+ for (const [k, v] of Object.entries(node)) {
43
+ if (!v || typeof v !== "object") continue;
44
+ if ("$value" in v) {
45
+ const cs = v.$extensions?.["com.goldencomm.figma"]?.codeSyntax;
46
+ if (!wantCodeSyntax) into.set([...path, k].join("."), v.$value);
47
+ else if (cs) into.set([...path, k].join("."), cs);
48
+ } else walk(v, [...path, k]);
49
+ }
50
+ };
51
+ walk(JSON.parse(readFileSync(file, "utf8")), []);
52
+ };
53
+ // A plain role alias (no opacity, or 100%; ADR-0017 §2 amended 2026-10-05) is emitted as var(--role), for the same
54
+ // reason: it must follow the scheme at runtime.
55
+ const roleTint = (t) => {
56
+ const ext = t.$extensions?.["com.goldencomm.figma"];
57
+ if (t.$type !== "color" || !ext || !/Components$/.test(ext.collection ?? "")) return null;
58
+ const raw = componentRefs.get(t.path.join("."));
59
+ const ref = typeof raw === "string" && raw.match(/^\{(.+)\}$/);
60
+ const cssVar = ref && roleVars.get(ref[1]);
61
+ const plain = typeof ext.opacity !== "number" || ext.opacity >= 1;
62
+ if (plain) return cssVar ? `var(${cssVar})` : null;
63
+ if (!cssVar) {
64
+ console.error(`build: ${t.path.join("/")} is a component tint but its alias does not resolve to an emitting colour role (ADR-0017 §2)`);
65
+ process.exit(1);
66
+ }
67
+ // 0.05 -> 5%, without floating-point noise
68
+ const pct = Math.round(ext.opacity * 10000) / 100;
69
+ return `color-mix(in oklab, var(${cssVar}) ${pct}%, transparent)`;
70
+ };
71
+ // fontFamily tokens hold one family name (a Figma FONT_FAMILY variable); the build quotes it when needed and
72
+ // appends the generic stack for its role (font/sans -> sans-serif ...). Loading the face is a project concern.
73
+ const GENERIC = { sans: "ui-sans-serif, system-ui, sans-serif", serif: "ui-serif, Georgia, serif", mono: "ui-monospace, monospace" };
74
+ const fmtFamily = (t) => {
75
+ const fam = String(t.$value);
76
+ const role = t.path.find((p) => GENERIC[p]) ?? (/mono/i.test(fam) ? "mono" : "sans");
77
+ return `${/[^\w-]/.test(fam) ? `"${fam}"` : fam}, ${GENERIC[role]}`;
78
+ };
79
+ const fmtValue = (t) => {
80
+ const v = t.$value;
81
+ const tint = roleTint(t);
82
+ if (tint) return tint;
83
+ if (t.$type === "fontFamily") return fmtFamily(t);
84
+ if (v && typeof v === "object" && "unit" in v) return `${v.value}${v.unit}`;
85
+ return withOpacity(String(v), t.$extensions?.["com.goldencomm.figma"]?.opacity);
86
+ };
87
+ const cssName = (t) => t.$extensions?.["com.goldencomm.figma"]?.codeSyntax;
88
+
89
+ // Format: emit tokens from `options.collections` that carry a WEB codeSyntax, inside `options.selector`.
90
+ StyleDictionary.registerFormat({
91
+ name: "baseline/css-block",
92
+ format: ({ dictionary, options }) => {
93
+ const tokens = dictionary.allTokens
94
+ .filter((t) => options.collections.includes(t.$extensions?.["com.goldencomm.figma"]?.collection))
95
+ .filter((t) => cssName(t) && !t.path[0].startsWith("_"))
96
+ .sort((a, b) => cssName(a).localeCompare(cssName(b)));
97
+ if (!tokens.length) return "";
98
+ const body = tokens.map((t) => ` ${cssName(t)}: ${fmtValue(t)};`).join("\n");
99
+ return `${options.selector} {\n${body}\n}\n`;
100
+ },
101
+ });
102
+
103
+ async function buildBlock({ sources, collections, selector }) {
104
+ const sd = new StyleDictionary({
105
+ source: sources,
106
+ usesDtcg: true,
107
+ // lint.mjs already proves every alias resolves; legacy STRING values that merely contain braces must not abort the build
108
+ log: { verbosity: process.env.TOKENS_VERBOSE ? "verbose" : "silent", warnings: "disabled", errors: { brokenReferences: "console" } },
109
+ platforms: {
110
+ css: {
111
+ transforms: ["name/kebab"],
112
+ files: [{ destination: "_.css", format: "baseline/css-block", options: { collections, selector } }],
113
+ },
114
+ },
115
+ });
116
+ await sd.hasInitialized;
117
+ const [file] = await sd.formatPlatform("css");
118
+ return file.output;
119
+ }
120
+
121
+ const primSources = primitives ? primitives.modes.map((m) => modeFile(primitives, m)) : [];
122
+ // Components (ADR-0017): geometry aliases with codeSyntax are emitted into :root next to the scales they alias
123
+ const components = manifest.collections.find((c) => /Components$/.test(c.name));
124
+ const compSources = components ? [modeFile(components, components.modes[0])] : [];
125
+ // Index the raw (unresolved) Components values and the emitting colour roles, so roleTint can turn a
126
+ // component tint back into the role it aliases. Style Dictionary has already resolved $value by then.
127
+ if (components) indexRaw(modeFile(components, components.modes[0]), componentRefs, false);
128
+ indexRaw(modeFile(theme, theme.modes[0]), roleVars, true);
129
+ const responsive = col("Responsive");
130
+ const respModes = responsive ? responsive.modes.map((m) => {
131
+ const tree = JSON.parse(readFileSync(modeFile(responsive, m), "utf8"));
132
+ const min = tree["breakpoint-min-width"]?.$value;
133
+ return { m, minPx: min && typeof min === "object" ? min.value : (min ?? 0) };
134
+ }).sort((a, b) => a.minPx - b.minPx) : [];
135
+ const baseResp = respModes.find((r) => r.minPx === 0);
136
+
137
+ let css = "/* Generated by tokens/build.mjs from the Figma export. Do not edit. */\n";
138
+ const [first, ...rest] = theme.modes;
139
+ // :root = Scheme 1 colors + emitted Primitives scales + the Responsive base (mobile) mode
140
+ // Collections that are never emitted but may be alias targets (legacy 2.0 typography, 3.0 sizes) are loaded for
141
+ // resolution only; the `collections` filter decides what is written.
142
+ const resolveOnly = manifest.collections
143
+ .filter((c) => !["Color Schemes", "Primitives", "Responsive", "Surface"].includes(c.name) && c !== components)
144
+ .map((c) => modeFile(c, c.modes[0]));
145
+ css += await buildBlock({
146
+ sources: [...primSources, ...compSources, ...resolveOnly, modeFile(theme, first), ...(baseResp ? [modeFile(responsive, baseResp.m)] : [])],
147
+ collections: ["Color Schemes", "Primitives", "Responsive", ...(components ? [components.name] : [])], selector: ":root",
148
+ });
149
+ // Scheme 1 is also written under its own data-scheme (B5, 2026-10-07), so a Scheme 1 Section inside a Scheme 2 page
150
+ // (or any other nesting) resets the roles; :root alone only covers the page default.
151
+ for (const m of [first, ...rest]) {
152
+ css += await buildBlock({ sources: [...primSources, modeFile(theme, m)], collections: ["Color Schemes"], selector: `[data-scheme="${m.slug}"]` });
153
+ }
154
+ // Component tints (ADR-0017 §2) are color-mix() over a scheme role. A custom property's var() is resolved where the
155
+ // property is declared, so a tint declared only on :root keeps Scheme 1's colour inside every other scheme (found
156
+ // 2026-10-01: Card's footer and outline stayed light inside scheme-2). Re-declaring the tints on every scheme root
157
+ // makes each one resolve against that scheme's roles. Shopify gets the same rule on its scheme classes below.
158
+ // A plain role alias (var(--role), amended 2026-10-05) needs the same; geometry aliases (var(--bl-…)) do not.
159
+ const TINT_DECL = /^\s+(--bl-[\w-]+): (color-mix\(in oklab, var\(--[\w-]+\) [\d.]+%, transparent\)|var\((--[\w-]+)\));$/gm;
160
+ const roleCssVars = new Set(roleVars.values());
161
+ const tintDecls = [...css.matchAll(TINT_DECL)].filter((x) => !x[3] || roleCssVars.has(x[3])).map((x) => [x[1], x[2]]);
162
+ if (tintDecls.length) css += `[data-scheme] {\n${tintDecls.map(([k, v]) => ` ${k}: ${v};`).join("\n")}\n}\n`;
163
+ // Responsive modes above the base become mobile-first @media blocks, smallest first, emitting only the
164
+ // declarations whose value differs from the previous (smaller) breakpoint.
165
+ const parseDecls = (block) => Object.fromEntries([...block.matchAll(/^\s+(--[\w-]+): (.+);$/gm)].map((x) => [x[1], x[2]]));
166
+ let previous = baseResp ? parseDecls(await buildBlock({ sources: [...primSources, modeFile(responsive, baseResp.m)], collections: ["Responsive"], selector: ":root" })) : {};
167
+ for (const { m, minPx } of respModes) {
168
+ if (minPx === 0) continue;
169
+ const block = await buildBlock({ sources: [...primSources, modeFile(responsive, m)], collections: ["Responsive"], selector: ":root" });
170
+ if (!block) continue;
171
+ const decls = parseDecls(block);
172
+ const changed = Object.entries(decls).filter(([k, v]) => previous[k] !== v);
173
+ previous = decls;
174
+ if (!changed.length) continue;
175
+ css += `@media (min-width: ${minPx}px) {\n :root {\n${changed.map(([k, v]) => ` ${k}: ${v};`).join("\n")}\n }\n}\n`;
176
+ }
177
+
178
+ // Tailwind v4 theme map (ADR-0012): colors --color-<x>: var(--<x>); scales --<path>: var(--bl-<path>)
179
+ const SHADCN = [
180
+ "background", "foreground", "card", "card-foreground", "popover", "popover-foreground",
181
+ "primary", "primary-foreground", "secondary", "secondary-foreground", "muted", "muted-foreground",
182
+ "accent", "accent-foreground", "destructive", "border", "input", "ring",
183
+ "chart-1", "chart-2", "chart-3", "chart-4", "chart-5",
184
+ "sidebar", "sidebar-foreground", "sidebar-primary", "sidebar-primary-foreground",
185
+ "sidebar-accent", "sidebar-accent-foreground", "sidebar-border", "sidebar-ring",
186
+ ];
187
+ function collectCodeNames(file) {
188
+ const out = [];
189
+ const walk = (node) => { for (const v of Object.values(node)) { if (v && typeof v === "object" && "$value" in v) { const cs = v.$extensions?.["com.goldencomm.figma"]?.codeSyntax; if (cs?.startsWith("--bl-")) out.push(cs); } else if (v && typeof v === "object") walk(v); } };
190
+ walk(JSON.parse(readFileSync(file, "utf8")));
191
+ return out;
192
+ }
193
+ const scaleNames = [...new Set([
194
+ ...primSources.flatMap(collectCodeNames),
195
+ ...(responsive ? [collectCodeNames(modeFile(responsive, responsive.modes[0]))].flat() : []),
196
+ ])].sort();
197
+ // Container sizes are written as their literal values, not var(--bl-container-*): Tailwind builds its @<size>
198
+ // container variants (@md/field-group:) from --container-*, and a container-query condition cannot read a custom
199
+ // property, so with var() Tailwind silently dropped every one of those rules (found 2026-10-02: Field's responsive
200
+ // orientation never applied). The value still comes from Figma; it is fixed at build time instead of runtime, so a
201
+ // container size must be one value at every width (a Responsive one fails here).
202
+ const LITERAL_IN_THEME = /^--bl-container-/;
203
+ const literalOf = (cs) => {
204
+ const hits = [...css.matchAll(new RegExp(`(?:^|[\\s;{])${cs}:\\s*([^;]+);`, "g"))].map((m) => m[1].trim());
205
+ if (hits.length !== 1) throw new Error(`build: ${cs} must be declared once (static) to be written as a literal in @theme; found ${hits.length}`);
206
+ if (hits[0].includes("var(")) throw new Error(`build: ${cs} resolves to ${hits[0]}, not a literal, so a container query cannot use it`);
207
+ return hits[0];
208
+ };
209
+ const themeMap =
210
+ "@theme inline {\n" +
211
+ [
212
+ ...SHADCN.map((n) => ` --color-${n}: var(--${n});`),
213
+ ...scaleNames.map((cs) => ` ${cs.replace(/^--bl-/, "--")}: ${LITERAL_IN_THEME.test(cs) ? literalOf(cs) : `var(${cs})`};`),
214
+ ].join("\n") +
215
+ "\n}\n";
216
+
217
+ // registry:base `css` field: the same CSS as a selector -> declarations object (nested for @media).
218
+ function cssToObject(text) {
219
+ const obj = {};
220
+ const re = /([^{}]+)\{([^{}]*)\}/g; // flat blocks
221
+ let rest = text;
222
+ // pull @media blocks first (they nest one level)
223
+ rest = rest.replace(/(@media[^{]+)\{\s*([\s\S]*?)\n\}\n/g, (_, q, inner) => { obj[q.trim()] = cssToObject(inner); return ""; });
224
+ for (const m of rest.matchAll(re)) {
225
+ const decls = Object.fromEntries(m[2].split(";").map((d) => d.trim()).filter(Boolean).map((d) => { const i = d.indexOf(":"); return [d.slice(0, i).trim(), d.slice(i + 1).trim()]; }));
226
+ if (Object.keys(decls).length) obj[m[1].trim()] = decls;
227
+ }
228
+ return obj;
229
+ }
230
+ // shadcn registry:base wants `cssVars.theme` (theme keys without --) and `cssVars.light` (root values without --);
231
+ // everything else (scheme blocks, @media) goes in the `css` field as selector -> declarations.
232
+ const cssObj = cssToObject((css + themeMap).replace(/^\/\*.*\*\/\n/, ""));
233
+ const strip = (o) => Object.fromEntries(Object.entries(o).map(([k, v]) => [k.replace(/^--/, ""), v]));
234
+ const baseCssVars = { theme: strip(cssObj["@theme inline"] ?? {}), light: strip(cssObj[":root"] ?? {}) };
235
+ const baseCssRest = Object.fromEntries(Object.entries(cssObj).filter(([k]) => k !== "@theme inline" && k !== ":root"));
236
+ // ---------------------------------------------------------------------------------------------------------------
237
+ // Shopify (ADR-0007): static scales -> snippets/baseline-tokens.liquid ({% stylesheet %}); colors -> a Baseline group
238
+ // in config/settings_schema.json (color_palette + color_scheme_group with the shadcn roles) seeded in
239
+ // config/settings_data.json; snippets/baseline-schemes.liquid maps each scheme to the same CSS variables.
240
+ // ---------------------------------------------------------------------------------------------------------------
241
+ const shopifyDir = join(root, "platforms", "shopify", "theme");
242
+ const shopifyOutputs = {};
243
+ if (existsSync(shopifyDir)) {
244
+ const roles = SHADCN; // 31 shadcn roles in fixed order
245
+ const sid = (r) => r.replace(/-/g, "_");
246
+ const label = (r) => r.replace(/-/g, " ").replace(/\b\w/g, (c) => c.toUpperCase());
247
+
248
+ // non-color scale CSS: :root --bl-* lines + the responsive @media blocks
249
+ const rootBl = (cssObj[":root"] ? Object.entries(cssObj[":root"]).filter(([k]) => k.startsWith("--bl-")) : []);
250
+ const mediaBlocks = Object.entries(cssObj).filter(([k]) => k.startsWith("@media"));
251
+ let tokensLiquid = "{% comment %} Generated by tokens/build.mjs from the Figma export. Do not edit. Static (non-color) Baseline scales. {% endcomment %}\n{% stylesheet %}\n:root {\n";
252
+ tokensLiquid += rootBl.map(([k, v]) => ` ${k}: ${v};`).join("\n") + "\n}\n";
253
+ // the component tints again on every scheme class (color-<id>, any id a merchant creates), as [data-scheme] above
254
+ if (tintDecls.length) tokensLiquid += `[class^="color-"],\n[class*=" color-"] {\n${tintDecls.map(([k, v]) => ` ${k}: ${v};`).join("\n")}\n}\n`;
255
+ for (const [q, inner] of mediaBlocks) {
256
+ const decls = Object.entries(inner[":root"] ?? {});
257
+ if (!decls.length) continue;
258
+ tokensLiquid += `${q} {\n :root {\n${decls.map(([k, v]) => ` ${k}: ${v};`).join("\n")}\n }\n}\n`;
259
+ }
260
+ tokensLiquid += "{% endstylesheet %}\n";
261
+ shopifyOutputs["platforms/shopify/theme/snippets/baseline-tokens.liquid"] = tokensLiquid;
262
+
263
+ // per-scheme resolved colors (from the generated CSS blocks)
264
+ const schemeColors = { [first.slug]: cssObj[":root"] ?? {} };
265
+ for (const m of rest) schemeColors[m.slug] = { ...(cssObj[":root"] ?? {}), ...(cssObj[`[data-scheme="${m.slug}"]`] ?? {}) };
266
+ const roleValue = (slug, r) => schemeColors[slug]?.[`--${r}`] ?? "#000000";
267
+ const hex6 = (v) => (typeof v === "string" && /^#[0-9a-f]{8}$/i.test(v) ? v.slice(0, 7) : v); // Shopify color settings take 6-digit hex
268
+ // Shopify color settings are opaque 6-digit hex, so a role Figma defines WITH an alpha loses it on the way
269
+ // through settings_data and has to have it re-applied in Liquid. The alpha is read back off the built value
270
+ // rather than hardcoded, so a designer moving color/ring off 25 % moves Shopify too. It has to be the same
271
+ // in every scheme, because the snippet emits one rule for all of them.
272
+ const roleAlpha = (r) => {
273
+ const alphas = new Set(theme.modes.map((m) => {
274
+ const v = roleValue(m.slug, r);
275
+ return /^#[0-9a-f]{8}$/i.test(v) ? parseInt(v.slice(7), 16) / 255 : 1;
276
+ }));
277
+ if (alphas.size > 1) {
278
+ console.error(`build: color/${r} has a different alpha per scheme (${[...alphas].join(", ")}); the Shopify snippet emits one rule for every scheme and cannot express that`);
279
+ process.exit(1);
280
+ }
281
+ return Math.round([...alphas][0] * 100) / 100;
282
+ };
283
+
284
+ // palette: the most-referenced primitive colors (max 20)
285
+ const primTokens = primitives ? primitives.modes.flatMap((m) => { const t = JSON.parse(readFileSync(modeFile(primitives, m), "utf8")); const out = []; const walk = (node, path) => { for (const [k, v] of Object.entries(node)) { if (v && typeof v === "object" && "$value" in v) out.push({ path: [...path, k].join("/"), value: v.$value }); else if (v && typeof v === "object") walk(v, [...path, k]); } }; walk(t, []); return out; }) : [];
286
+ const refCount = new Map();
287
+ for (const m of theme.modes) { const tree = JSON.parse(readFileSync(modeFile(theme, m), "utf8")); const walk = (node) => { for (const v of Object.values(node)) { if (v && typeof v === "object" && "$value" in v) { if (typeof v.$value === "string" && v.$value.startsWith("{")) refCount.set(v.$value.slice(1, -1), (refCount.get(v.$value.slice(1, -1)) ?? 0) + 1); } else if (v && typeof v === "object") walk(v); } }; walk(tree); }
288
+ const palette = {};
289
+ for (const [ref] of [...refCount.entries()].sort((a, b) => b[1] - a[1])) {
290
+ if (Object.keys(palette).length >= 20) break;
291
+ const tok = primTokens.find((p) => p.path.replace(/\//g, ".") === ref);
292
+ if (!tok || typeof tok.value !== "string" || !tok.value.startsWith("#")) continue;
293
+ const key = ref.replace(/^colors\./, "").replace(/[^a-zA-Z0-9]+/g, "_").replace(/^_+|_+$/g, "");
294
+ if (/^[a-zA-Z]/.test(key)) palette[key] = hex6(tok.value);
295
+ }
296
+
297
+ const baselineGroup = {
298
+ name: "GC Baseline",
299
+ settings: [
300
+ { type: "header", content: "Color schemes" },
301
+ { type: "paragraph", content: "Generated from the GC Baseline design tokens (Figma). Roles follow shadcn; scheme-1 is the page default." },
302
+ { type: "color_scheme", id: "default_color_scheme", label: "Default color scheme", default: first.slug },
303
+ {
304
+ type: "color_scheme_group",
305
+ id: "color_schemes",
306
+ definition: [
307
+ ...roles.map((r) => ({ type: "color", id: sid(r), label: label(r), default: hex6(roleValue(first.slug, r)) })),
308
+ // Shopify requires a gradient counterpart for the background role; Baseline leaves it empty by default
309
+ { type: "color_background", id: "background_gradient", label: "Background gradient" },
310
+ ],
311
+ role: {
312
+ background: { solid: "background", gradient: "background_gradient" },
313
+ text: "foreground",
314
+ primary_button: "primary",
315
+ on_primary_button: "primary_foreground",
316
+ primary_button_border: "primary",
317
+ secondary_button: "secondary",
318
+ on_secondary_button: "secondary_foreground",
319
+ secondary_button_border: "border",
320
+ links: "primary",
321
+ icons: "foreground",
322
+ },
323
+ },
324
+ { type: "header", content: "Palette" },
325
+ { type: "color_palette", id: "baseline_palette", default: palette },
326
+ ],
327
+ };
328
+ const schemaPath = join(shopifyDir, "config", "settings_schema.json");
329
+ const schema = existsSync(schemaPath) ? JSON.parse(readFileSync(schemaPath, "utf8")) : [];
330
+ // ADR-0034: the group is "GC Baseline"; a theme built before the rename still has "Baseline", replaced in place
331
+ const idx = schema.findIndex((g) => g.name === "GC Baseline" || g.name === "Baseline");
332
+ if (idx >= 0) schema[idx] = baselineGroup; else schema.push(baselineGroup);
333
+ shopifyOutputs["platforms/shopify/theme/config/settings_schema.json"] = JSON.stringify(schema, null, 2) + "\n";
334
+
335
+ // seed schemes (keep whatever else the merchant/theme has in settings_data)
336
+ const dataPath = join(shopifyDir, "config", "settings_data.json");
337
+ let data = { current: {} };
338
+ if (existsSync(dataPath)) { try { data = JSON.parse(readFileSync(dataPath, "utf8").replace(/^\s*\/\*[\s\S]*?\*\/\s*/, "")); } catch { data = { current: {} }; } }
339
+ if (typeof data.current !== "object" || data.current === null) data.current = {};
340
+ data.current.color_schemes = Object.fromEntries(theme.modes.map((m) => [m.slug, { settings: Object.fromEntries(roles.map((r) => [sid(r), hex6(roleValue(m.slug, r))])) }]));
341
+ if (!data.current.default_color_scheme) data.current.default_color_scheme = first.slug;
342
+ shopifyOutputs["platforms/shopify/theme/config/settings_data.json"] = JSON.stringify(data, null, 2) + "\n";
343
+
344
+ // schemes snippet: one class per scheme exposing the shadcn variables (Liquid is allowed in {% style %}, not {% stylesheet %})
345
+ shopifyOutputs["platforms/shopify/theme/snippets/baseline-schemes.liquid"] =
346
+ "{% comment %} Generated by tokens/build.mjs. Maps each color scheme to the Baseline CSS variables (ADR-0007). {% endcomment %}\n" +
347
+ "{% style %}\n{% for scheme in settings.color_schemes %}\n .color-{{ scheme.id }} {\n" +
348
+ // `ring` is a merchant setting like the rest, but Figma gives it an alpha the setting cannot carry, so
349
+ // it is re-applied here. Without this the Shopify focus ring was a solid block of primary.
350
+ roles
351
+ .map((r) =>
352
+ r === "ring"
353
+ ? ` --${r}: {{ scheme.settings.${sid(r)} | color_modify: 'alpha', ${roleAlpha("ring")} }};`
354
+ : ` --${r}: {{ scheme.settings.${sid(r)} }};`,
355
+ )
356
+ .join("\n") +
357
+ // Baseline extension roles (ADR-0026) are derived from the scheme's shadcn roles, not merchant settings:
358
+ // there is no ring-invalid setting, so it comes off destructive carrying color/ring-invalid's own alpha.
359
+ `\n --ring-invalid: {{ scheme.settings.destructive | color_modify: 'alpha', ${roleAlpha("ring-invalid")} }};` +
360
+ // color/overlay (ADR-0032) derives from no role and has no merchant setting: the Figma value, the same in every scheme.
361
+ (schemeColors[first.slug]?.["--overlay"] ? `\n --overlay: ${schemeColors[first.slug]["--overlay"]};` : "") +
362
+ "\n --color-background: var(--background);\n --color-foreground: var(--foreground);\n background-color: var(--background);\n color: var(--foreground);\n }\n{% endfor %}\n{% endstyle %}\n";
363
+ }
364
+
365
+ // ---------------------------------------------------------------------------------------------------------------
366
+ // typeset (ADR-0028 §2): prose rhythm is one stylesheet. tokens/typeset.css is the canonical Baseline fork of shadcn
367
+ // typeset (kept in this package so the client CLI can emit it). The React registry gets it verbatim. WordPress gets
368
+ // a derived copy: (a) unlayered, because core's block styles are unlayered and an unlayered declaration beats any
369
+ // @layer one regardless of specificity (core's `:where(body .is-layout-flow) > *` gap would otherwise win inside
370
+ // .typeset); (b) the block editor's canvas root counts as the wrapper, matched through :where() so specificity is
371
+ // unchanged, so the editor shows the same rhythm without a class.
372
+ // ---------------------------------------------------------------------------------------------------------------
373
+ const typesetSrc = readFileSync(join(here, "typeset.css"), "utf8");
374
+ // Platforms whose own CSS is unlayered (WordPress core, Shopify themes) get the rules unlayered too.
375
+ const typesetUnlayered = (src) => src.replace(/^@layer components \{\n([\s\S]*?)\n\}\n/gm, (_, body) => body.replace(/^ /gm, "") + "\n");
376
+ function typesetForWordPress(src) {
377
+ const out = typesetUnlayered(src).replace(/\.typeset(?![\w-])/g, ":is(.typeset, :where(.editor-styles-wrapper .is-root-container))");
378
+ return "/* Generated by tokens/build.mjs from tokens/typeset.css. Do not edit. WordPress copy: unlayered (core block\n styles are unlayered) and the block editor canvas root counts as the .typeset wrapper. */\n" + out;
379
+ }
380
+ // Shopify (ADR-0007 rules): one {% stylesheet %} per file, no Liquid inside it; Shopify concatenates every
381
+ // stylesheet tag into one unlayered theme CSS file, so the copy is unlayered as well.
382
+ const typesetForShopify = (src) =>
383
+ "{% comment %} Generated by tokens/build.mjs from tokens/typeset.css. Do not edit. Prose rhythm (ADR-0028): render once from layout/theme.liquid; class=\"typeset\" on every richtext output. {% endcomment %}\n{% stylesheet %}\n" +
384
+ typesetUnlayered(src).replace(/^\/\*[\s\S]*?\*\/\n\n/, "") + "{% endstylesheet %}\n";
385
+ const typesetOutputs = {
386
+ "registry/src/styles/typeset.css": typesetSrc,
387
+ "platforms/wordpress/theme/src/typeset.css": typesetForWordPress(typesetSrc),
388
+ ...(existsSync(shopifyDir) ? { "platforms/shopify/theme/snippets/typeset.liquid": typesetForShopify(typesetSrc) } : {}),
389
+ };
390
+
391
+ // ---------------------------------------------------------------------------------------------------------------
392
+ // Content ramp (ADR-0014, amended 2026-10-07): Figma's display/* and heading/* text styles (src/figma/text-styles.json,
393
+ // written by import.mjs from the export) become one class per style, named as the style with "/" -> "-"
394
+ // (display/lg -> display-lg). Each declaration reads the variable the style binds: size and line-height through their
395
+ // codeSyntax (so the Responsive steps keep changing per breakpoint), the family through font/*, the weight as the
396
+ // Figma-only font-weight value (ADR-0022 §1: never emitted, code writes the number). Not `text-*`: shadcn's cn
397
+ // (tailwind-merge) would read text-display-lg as a colour and drop it beside text-foreground.
398
+ // ---------------------------------------------------------------------------------------------------------------
399
+ const textStylesPath = join(src, "text-styles.json");
400
+ const rampStyles = existsSync(textStylesPath) ? JSON.parse(readFileSync(textStylesPath, "utf8")).textStyles.filter((s) => /^(display|heading)\//.test(s.name)) : [];
401
+ const tokenTree = {};
402
+ const tokenAt = (ref) => {
403
+ const [colName, name] = ref.split("|");
404
+ const c = manifest.collections.find((x) => x.name === colName);
405
+ if (!c) throw new Error(`text style binds ${ref}: no such collection`);
406
+ tokenTree[colName] ??= JSON.parse(readFileSync(modeFile(c, c.modes.find((m) => /base|mode 1|value/i.test(m.name)) ?? c.modes[0]), "utf8"));
407
+ const leaf = name.split("/").reduce((o, k) => o?.[k], tokenTree[colName]);
408
+ if (!leaf) throw new Error(`text style binds ${ref}: no such variable`);
409
+ return leaf;
410
+ };
411
+ const cssVarOf = (ref) => {
412
+ const cs = tokenAt(ref).$extensions?.["com.goldencomm.figma"]?.codeSyntax;
413
+ if (!cs) throw new Error(`text style binds ${ref}, which has no codeSyntax (not emitted)`);
414
+ return `var(${cs})`;
415
+ };
416
+ const rampDecls = rampStyles.map((s) => {
417
+ const b = s.bindings;
418
+ for (const f of ["fontFamily", "fontSize", "lineHeight", "fontWeight"]) if (!b[f]) throw new Error(`text style ${s.name} has no ${f} variable: bind it in Figma`);
419
+ return {
420
+ name: s.name, cls: s.name.replace(/\//g, "-"),
421
+ decls: [
422
+ ["font-family", cssVarOf(b.fontFamily)],
423
+ ["font-size", cssVarOf(b.fontSize)],
424
+ ["line-height", cssVarOf(b.lineHeight)],
425
+ ["font-weight", String(tokenAt(b.fontWeight).$value)],
426
+ ],
427
+ };
428
+ });
429
+ const block = (sel, decls, indent = "") => `${indent}${sel} {\n${decls.map(([p, v]) => `${indent} ${p}: ${v};`).join("\n")}\n${indent}}\n`;
430
+ const rampHeader = (how) => `/* Generated by tokens/build.mjs from the Figma text styles (src/figma/text-styles.json). Do not edit. Content ramp\n (ADR-0014): ${how} */\n`;
431
+ const rampOutputs = rampDecls.length ? {
432
+ // React and HTML: Tailwind utilities, so variants work (md:heading-xl) and they sit in the utilities layer above typeset
433
+ "registry/src/styles/text-styles.css": rampHeader("one Tailwind utility per style, e.g. className=\"display-lg\".") +
434
+ rampDecls.map((r) => `@utility ${r.cls} {\n${r.decls.map(([p, v]) => ` ${p}: ${v};`).join("\n")}\n}\n`).join(""),
435
+ // WordPress: a class per style plus core's font-size preset class (theme.json fontSizes, same slugs). Doubled class:
436
+ // typeset's unlayered heading rules are (0,1,1) and must not win the line-height or weight.
437
+ "platforms/wordpress/theme/src/text-styles.css": rampHeader("a class per style, and the matching core font-size preset (has-<style>-font-size).") +
438
+ rampDecls.map((r) => block(`:is(.${r.cls}, .has-${r.cls}-font-size):is(.${r.cls}, .has-${r.cls}-font-size)`, r.decls)).join(""),
439
+ ...(existsSync(shopifyDir) ? {
440
+ "platforms/shopify/theme/snippets/text-styles.liquid":
441
+ `{% comment %} Generated by tokens/build.mjs from the Figma text styles. Do not edit. Content ramp (ADR-0014): render once from layout/theme.liquid; the heading block applies the classes. {% endcomment %}\n{% stylesheet %}\n` +
442
+ rampDecls.map((r) => block(`.${r.cls}.${r.cls}`, r.decls)).join("") + "{% endstylesheet %}\n",
443
+ } : {}),
444
+ } : {};
445
+
446
+ const outputs = {
447
+ ...shopifyOutputs,
448
+ ...typesetOutputs,
449
+ ...rampOutputs,
450
+ "registry/src/styles/tokens.css": css + themeMap,
451
+ "registry/src/base.cssvars.json": JSON.stringify(baseCssVars, null, 2) + "\n",
452
+ "registry/src/base.css.json": JSON.stringify(baseCssRest, null, 2) + "\n",
453
+ // WordPress runs its own Tailwind build (ADR-0008), so it needs the @theme map too
454
+ "platforms/wordpress/theme/assets/tokens.css": css + themeMap,
455
+ // the HTML target runs its own Tailwind build too (scripts/gen-html.mjs)
456
+ "platforms/html/dist/tokens.css": css + themeMap,
457
+ };
458
+ for (const [rel, content] of Object.entries(outputs)) {
459
+ const p = join(root, rel);
460
+ mkdirSync(dirname(p), { recursive: true });
461
+ writeFileSync(p, content);
462
+ }
463
+ console.log(`build: wrote ${Object.keys(outputs).length} files (${css.split("\n").length} lines of CSS)`);
464
+
465
+ // ---------------------------------------------------------------------------------------------------------
466
+ // Cross-token invariants. These are relationships between variables that no single variable can express, so
467
+ // nothing else would catch them breaking -- the symptom is a component looking slightly wrong, which is
468
+ // exactly the kind of thing that survives review. Asserted against the EMITTED css, so it checks what ships
469
+ // rather than what the source says.
470
+ // ---------------------------------------------------------------------------------------------------------
471
+ {
472
+ const emitted = readFileSync(join(root, "registry", "src", "styles", "tokens.css"), "utf8");
473
+ const px = (name) => {
474
+ const m = new RegExp(`--${name}:\\s*(-?[\\d.]+)px`).exec(emitted);
475
+ return m ? Number(m[1]) : null;
476
+ };
477
+ // Base UI's Select positions the panel so the selected row's TEXT sits over the trigger's text
478
+ // (alignItemWithTrigger). The two insets have to match or the panel sits off its trigger.
479
+ const panel = px("bl-select-panel-padding");
480
+ const item = px("bl-select-item-padding-x");
481
+ const trigger = px("bl-select-md-padding-x");
482
+ if (panel !== null && item !== null && trigger !== null && panel + item !== trigger) {
483
+ console.error(
484
+ `build: select panel inset does not match the trigger's. ` +
485
+ `select/panel/padding (${panel}) + select/item/padding-x (${item}) = ${panel + item}, ` +
486
+ `but select/md/padding-x is ${trigger}. alignItemWithTrigger will sit the panel ` +
487
+ `${trigger - (panel + item)}px off its trigger.`,
488
+ );
489
+ process.exit(1);
490
+ }
491
+ }