synthesisui 0.16.63 → 0.16.66

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.
@@ -1,13 +1,16 @@
1
1
  import { mkdir, readdir, readFile, writeFile } from "node:fs/promises";
2
2
  import { basename, dirname, join, relative } from "node:path";
3
3
  import { readCredentials, readToken, resolveRegistry, sameRegistry, } from "../config.js";
4
+ import { findBrokenRefs } from "../doctor/broken-refs.js";
4
5
  import { isNearDuplicate, isNeutral, lightness, } from "../doctor/color-distance.js";
5
6
  import { emptyTally, internalSpecifiers, scanComponentsInto, tallyToInventory, } from "../doctor/components-scan.js";
6
7
  import { crosswalk, observedRules } from "../doctor/crosswalk.js";
7
8
  import { reconcile, scanDefinitions, } from "../doctor/definitions-scan.js";
9
+ import { describeConvention, detectConventions, IDIOM_LABEL, } from "../doctor/idiom.js";
8
10
  import { diagnose, scanSource } from "../doctor/scan.js";
9
11
  import { parseSchemeBlocks } from "../doctor/scheme-blocks.js";
10
12
  import { buildTable } from "../doctor/tokens.js";
13
+ import { rootClasses, transcribe, } from "../doctor/transcribe.js";
11
14
  import { body, paint, section } from "../output.js";
12
15
  import { walk, walkAll } from "./doctor.js";
13
16
  /**
@@ -273,11 +276,23 @@ export async function takeCensus(root) {
273
276
  const tally = emptyTally();
274
277
  const internal = await internalSpecifiers(root);
275
278
  const defined = [];
279
+ // Kept for the reference check below, which needs every file at once: a name
280
+ // is only broken if NOTHING declares it, anywhere.
281
+ const sources = [];
282
+ // Name → value for every custom property their CSS declares, which is what
283
+ // turns `bg-ocean-500` into a token ref instead of a hex we looked up.
284
+ const declaredValues = new Map();
285
+ for (const m of css.matchAll(/(--[a-zA-Z0-9_-]+)\s*:\s*([^;}]+)/g)) {
286
+ if (!declaredValues.has(m[1]))
287
+ declaredValues.set(m[1], m[2].trim());
288
+ }
289
+ const looks = {};
276
290
  for await (const file of walk(root)) {
277
291
  const src = await readFile(file, "utf8").catch(() => "");
278
292
  if (!src)
279
293
  continue;
280
294
  const rel = relative(root, file);
295
+ sources.push({ file: rel, source: src });
281
296
  reports.push(scanSource(rel, src, table));
282
297
  // Stories and tests compose components to SHOW them; counting those as the
283
298
  // product's own composition is the same lie the colour census told before
@@ -286,7 +301,20 @@ export async function takeCensus(root) {
286
301
  scanComponentsInto(tally, rel, src, internal);
287
302
  // The other half: what this file EXPORTS, with the axes its types
288
303
  // declare. A library composes almost nothing and exports everything.
289
- defined.push(...scanDefinitions(rel, src));
304
+ const found = scanDefinitions(rel, src);
305
+ defined.push(...found);
306
+ // THE LOOK, from their own class names. One transcription per file, keyed
307
+ // by the component it defines - the root element's classes are the
308
+ // component's own look, and everything below it is a part this slice does
309
+ // not attempt.
310
+ if (found.length > 0) {
311
+ const t = transcribe(rootClasses(src), declaredValues);
312
+ const size = Object.keys(t.base).length +
313
+ Object.keys(t.dark).length +
314
+ Object.keys(t.states).length;
315
+ if (size > 0)
316
+ looks[found[0].name] = t;
317
+ }
290
318
  }
291
319
  }
292
320
  const d = diagnose(reports);
@@ -368,10 +396,22 @@ export async function takeCensus(root) {
368
396
  // an unreadable package.json costs the name, not the run
369
397
  }
370
398
  }
399
+ // Every name their CSS defines, from the same harvest the token table came
400
+ // from - so a reference is judged against what actually exists, not against
401
+ // the subset we managed to read as a ramp.
402
+ const declaredNames = new Set();
403
+ for (const m of css.matchAll(/(--[a-zA-Z0-9_-]+)\s*:/g)) {
404
+ declaredNames.add(m[1]);
405
+ }
406
+ const brokenRefs = findBrokenRefs(sources, declaredNames);
407
+ const conventions = detectConventions(sources);
371
408
  return {
372
409
  census: 1,
373
410
  project: { name, stack: await detectStack(root) },
374
411
  declared: Object.fromEntries(table.byName),
412
+ ...(brokenRefs.length > 0 ? { brokenRefs } : {}),
413
+ ...(conventions.length > 0 ? { conventions } : {}),
414
+ ...(Object.keys(looks).length > 0 ? { looks } : {}),
375
415
  ...(schemes.alt.size > 0
376
416
  ? { declaredAlt: Object.fromEntries(schemes.alt) }
377
417
  : {}),
@@ -435,6 +475,55 @@ function summarize(c) {
435
475
  console.log(body(`${paint.strong(v.value)} ${paint.faint(`${v.kind}, ${v.count}× in ${v.files} file${v.files === 1 ? "" : "s"}`)}`));
436
476
  }
437
477
  }
478
+ sayConventions(c);
479
+ sayBrokenRefs(c);
480
+ }
481
+ /**
482
+ * THEIR VOCABULARY, MEASURED. This section exists so every later finding can be
483
+ * phrased as "you already do X, and here you did not" instead of "adopt ours".
484
+ */
485
+ function sayConventions(c) {
486
+ const list = c.conventions ?? [];
487
+ if (list.length === 0)
488
+ return;
489
+ console.log("");
490
+ console.log(section("How you reference a value"));
491
+ for (const conv of list) {
492
+ console.log(body(describeConvention(conv)));
493
+ const off = conv.offIdiom.filter((o) => o.count > 0).slice(0, 3);
494
+ if (off.length > 0) {
495
+ console.log(body(paint.faint(` and ${off.map((o) => `${o.count} ${IDIOM_LABEL[o.idiom]}`).join(", ")}`)));
496
+ }
497
+ }
498
+ console.log("");
499
+ console.log(body(paint.faint("Nothing here is a rule we brought. It is what your own files do most, so the rest can be named against it.")));
500
+ }
501
+ /**
502
+ * NOT DRIFT - BROKEN. A `var()` no declaration answers renders nothing at all,
503
+ * so this goes above everything else the census has to say. It is also the one
504
+ * finding that needs no agreement with us: the convention being broken is
505
+ * theirs.
506
+ */
507
+ function sayBrokenRefs(c) {
508
+ const broken = c.brokenRefs ?? [];
509
+ if (broken.length === 0)
510
+ return;
511
+ const uses = broken.reduce((n, b) => n + b.count, 0);
512
+ console.log("");
513
+ console.log(section("These resolve to nothing"));
514
+ console.log(body(`${uses} reference${uses === 1 ? "" : "s"} to ${broken.length} token${broken.length === 1 ? "" : "s"} your CSS never declares. A ${paint.strong("var()")} with no declaration paints no colour - not a different colour.`));
515
+ console.log("");
516
+ for (const b of broken.slice(0, 8)) {
517
+ const where = `${b.count}× in ${b.files} file${b.files === 1 ? "" : "s"}`;
518
+ console.log(body(b.meant
519
+ ? `${paint.strong(`var(${b.name})`)} ${paint.faint(where)} - you declare ${paint.strong(b.meant)}`
520
+ : `${paint.strong(`var(${b.name})`)} ${paint.faint(`${where}, and nothing close is declared`)}`));
521
+ }
522
+ if (broken.length > 8) {
523
+ console.log(body(paint.faint(`(${broken.length - 8} more)`)));
524
+ }
525
+ console.log("");
526
+ console.log(body(paint.faint("Yours to fix in your own words - nothing here asks you to adopt ours.")));
438
527
  }
439
528
  /**
440
529
  * The contract an agent needs, printed where an agent will read it.
@@ -0,0 +1,75 @@
1
+ /**
2
+ * TOKENS REFERENCED AND NEVER DECLARED.
3
+ *
4
+ * Not drift, not taste - a variable that resolves to nothing. `var(--ocean-50)`
5
+ * in a project whose token is `--color-ocean-50` renders no colour at all, and
6
+ * the component ships with no background.
7
+ *
8
+ * Found by accident (dono, 01/08) while sizing something else: seven of nine
9
+ * references in one real `ImageUploader` were missing the `color-` prefix, and
10
+ * it is live. Nobody noticed because a missing background looks like a design
11
+ * choice.
12
+ *
13
+ * This is the highest-value thing a first read can say, and it costs nothing to
14
+ * find: it is arithmetic over two sets. It also needs no adoption of anything of
15
+ * ours - the convention being violated is THEIRS. A person can act on it in
16
+ * their own vocabulary, in the next commit, without agreeing to a single idea
17
+ * this product has (dono, 01/08: "a normalização será de acordo com cada
18
+ * projeto").
19
+ */
20
+ /** `var(--x)`, including inside a Tailwind arbitrary value: `bg-[var(--x)]`. */
21
+ const VAR_REF = /var\(\s*(--[a-zA-Z0-9_-]+)/g;
22
+ /**
23
+ * PREFIXES A DECLARATION MAY CARRY that a reference dropped.
24
+ *
25
+ * Tailwind v4's `@theme` is the reason this happens at all: declaring
26
+ * `--color-ocean-50` publishes the variable under that full name AND the
27
+ * utility `bg-ocean-50`, so a person who reads their own utilities and then
28
+ * reaches for `var()` writes the utility's name and gets nothing.
29
+ */
30
+ const NAMESPACES = [
31
+ "color",
32
+ "spacing",
33
+ "radius",
34
+ "font",
35
+ "text",
36
+ "shadow",
37
+ "ease",
38
+ "animate",
39
+ ];
40
+ /**
41
+ * Every reference no declaration answers, commonest first.
42
+ *
43
+ * `declared` is the set of names their own CSS defines, verbatim. Anything the
44
+ * scan could not attribute is left out rather than guessed at.
45
+ */
46
+ export function findBrokenRefs(sources, declared) {
47
+ const seen = new Map();
48
+ for (const { file, source } of sources) {
49
+ for (const m of source.matchAll(VAR_REF)) {
50
+ const name = m[1];
51
+ if (declared.has(name))
52
+ continue;
53
+ const hit = seen.get(name) ?? { count: 0, files: new Set() };
54
+ hit.count += 1;
55
+ hit.files.add(file);
56
+ seen.set(name, hit);
57
+ }
58
+ }
59
+ const out = [];
60
+ for (const [name, hit] of seen) {
61
+ const bare = name.slice(2);
62
+ const meant = NAMESPACES.map((ns) => `--${ns}-${bare}`).find((c) => declared.has(c));
63
+ out.push({
64
+ name,
65
+ count: hit.count,
66
+ files: hit.files.size,
67
+ ...(meant ? { meant } : {}),
68
+ });
69
+ }
70
+ // A broken reference somebody typed nine times is worse than one typed once,
71
+ // and one we can name a target for is actionable before one we cannot.
72
+ return out.sort((a, b) => Number(b.meant != null) - Number(a.meant != null) ||
73
+ b.count - a.count ||
74
+ a.name.localeCompare(b.name));
75
+ }
@@ -0,0 +1,132 @@
1
+ /**
2
+ * HOW THIS PROJECT REFERENCES A VALUE - and it is not one answer per project.
3
+ *
4
+ * Measured on a real monorepo (dono, 01/08), scoping by project looked like
5
+ * divergence and was not: `packages/ui` read 52% `var()` and 39% utility, which
6
+ * is one directory holding `.tsx` and `.scss` in the same bucket. Split by
7
+ * LANGUAGE the picture is unambiguous:
8
+ *
9
+ * .tsx 80% tailwind utility
10
+ * .scss 66% css var()
11
+ * .css 67% css var()
12
+ *
13
+ * So the convention belongs to the language, the project is already chosen by
14
+ * `--scope`, and neither needs a person to pick it - it is measured. What a
15
+ * person gets instead is the useful half: what does NOT follow the idiom their
16
+ * own files overwhelmingly use.
17
+ *
18
+ * WHY THIS MATTERS MORE THAN OUR TOKENS (dono, 01/08). A report that says "use
19
+ * `{color.ocean.500}`" asks somebody to restructure a repo to suit us - in a
20
+ * shadcn project that is a migration with real incompatibility risk and a commit
21
+ * nobody wants to review. A report that says "you write `bg-ocean-500` in 80% of
22
+ * your JSX and here you wrote a hex" asks for nothing. Same decision, their
23
+ * vocabulary, a commit the size of the problem.
24
+ *
25
+ * It also explained the worst bug this census has found. `bg-[var(--ocean-50)]`
26
+ * is not merely a dropped `color-` prefix: it is the CSS idiom used inside the
27
+ * JSX idiom - a change of language mid-sentence - and the `.scss` beside it
28
+ * spells the same token correctly, because there that language is the right one.
29
+ */
30
+ /** How each idiom reads, for the report. */
31
+ export const IDIOM_LABEL = {
32
+ "tailwind-utility": "Tailwind utility",
33
+ "css-var": "var(--token)",
34
+ "raw-hex": "raw hex",
35
+ "tailwind-arbitrary-var": "var() inside a utility",
36
+ "shadcn-hsl": "hsl(var(--token))",
37
+ };
38
+ const PATTERNS = [
39
+ // Order matters where two could match the same text: the shadcn wrapper and
40
+ // the arbitrary-value form are both `var()` and both more specific than it.
41
+ ["shadcn-hsl", /hsl\(\s*var\(\s*--/g],
42
+ [
43
+ "tailwind-arbitrary-var",
44
+ /\b(?:bg|text|border|ring|fill|stroke|shadow|from|to|via)-\[var\(\s*--/g,
45
+ ],
46
+ [
47
+ "tailwind-utility",
48
+ /\b(?:bg|text|border|ring|fill|stroke|from|to|via)-(?:[a-z]+-)+\d{2,3}\b/g,
49
+ ],
50
+ ["css-var", /var\(\s*--/g],
51
+ ["raw-hex", /#[0-9a-fA-F]{6}\b/g],
52
+ ];
53
+ /**
54
+ * Extensions worth reporting separately, because they are separate languages
55
+ * with separate correct answers. Anything else is counted and not split out.
56
+ */
57
+ const LANGUAGES = new Set([".tsx", ".jsx", ".scss", ".css", ".less", ".sass"]);
58
+ /**
59
+ * A custom-property declaration, whose right-hand side is a DEFINITION.
60
+ * Deliberately not anchored to a line start: these are written inside `:root`,
61
+ * `@theme` and nested blocks at every indentation.
62
+ */
63
+ const DECLARATION = /--[a-zA-Z0-9_-]+\s*:[^;}]*/g;
64
+ /**
65
+ * Count references per language and name the dominant idiom.
66
+ *
67
+ * A language with too few references to be conclusive is left out rather than
68
+ * given a convention on the strength of three sightings: claiming a project's
69
+ * convention from noise is worse than saying nothing, because every later
70
+ * finding inherits the claim.
71
+ */
72
+ export function detectConventions(sources, minReferences = 20) {
73
+ const byLanguage = new Map();
74
+ for (const { file, source } of sources) {
75
+ const dot = file.lastIndexOf(".");
76
+ const ext = dot === -1 ? "" : file.slice(dot).toLowerCase();
77
+ if (!LANGUAGES.has(ext))
78
+ continue;
79
+ const counts = byLanguage.get(ext) ?? new Map();
80
+ // A DECLARATION IS NOT A REFERENCE. `--color-ocean-500: #059aed` is where a
81
+ // hex belongs, and counting it made a design system's own token file read as
82
+ // 87% raw hex - the worst drift in the repo, when it is the opposite: the one
83
+ // place every other value should be pointing at (dono, 01/08).
84
+ let rest = source.replace(DECLARATION, "");
85
+ // A more specific pattern claims its matches first, so the same text is
86
+ // never counted twice - `bg-[var(--x)]` is one arbitrary-value reference,
87
+ // not also a `var()` one.
88
+ for (const [idiom, re] of PATTERNS) {
89
+ const found = rest.match(re);
90
+ if (!found)
91
+ continue;
92
+ counts.set(idiom, (counts.get(idiom) ?? 0) + found.length);
93
+ rest = rest.replace(re, "");
94
+ }
95
+ byLanguage.set(ext, counts);
96
+ }
97
+ const out = [];
98
+ for (const [language, counts] of byLanguage) {
99
+ const total = [...counts.values()].reduce((n, v) => n + v, 0);
100
+ if (total < minReferences)
101
+ continue;
102
+ const ranked = [...counts.entries()].sort((a, b) => b[1] - a[1]);
103
+ const [idiom, top] = ranked[0];
104
+ out.push({
105
+ language,
106
+ idiom,
107
+ share: top / total,
108
+ total,
109
+ offIdiom: ranked.slice(1).map(([i, count]) => ({ idiom: i, count })),
110
+ });
111
+ }
112
+ return out.sort((a, b) => b.total - a.total);
113
+ }
114
+ /**
115
+ * One line per language, in the report's voice and in THEIR vocabulary.
116
+ *
117
+ * A raw hex winning is NOT a convention. It is the absence of one, and saying
118
+ * "this language reaches for raw hex" dignifies it into a house style somebody
119
+ * might defend - when what the number means is that nothing there points at
120
+ * anything (dono, 01/08).
121
+ */
122
+ export function describeConvention(c) {
123
+ const pct = Math.round(c.share * 100);
124
+ if (c.idiom === "raw-hex") {
125
+ return `${c.language} has no convention - ${pct}% of its ${c.total} references are values typed in place`;
126
+ }
127
+ return `${c.language} reaches for ${IDIOM_LABEL[c.idiom]} - ${pct}% of the ${c.total} references in it`;
128
+ }
129
+ /** True when this language's dominant answer is "no answer". */
130
+ export function hasConvention(c) {
131
+ return c.idiom !== "raw-hex";
132
+ }
@@ -0,0 +1,323 @@
1
+ /**
2
+ * TRANSCRIBE A COMPONENT'S LOOK - deterministically, from their own class names.
3
+ *
4
+ * The import brings exclusive components in as contracts: axes declared, every
5
+ * style block empty. That was right when the alternative was inventing a look
6
+ * from a name. It is not right when the look is sitting in the file, spelled in
7
+ * a vocabulary we can read exactly.
8
+ *
9
+ * Measured before building any of this (dono, 01/08): across 23 exclusive
10
+ * components, 217 of 326 colour values resolve directly against their own
11
+ * declared tokens, and another 106 are Tailwind defaults - a fixed, published
12
+ * table. 99% is arithmetic. Nine of the 23 are entirely resolvable, and a single
13
+ * `TextEditor` holds 72% of all the ambiguity there is.
14
+ *
15
+ * So this is a transcriber, not a generator. No AI, no cost, no quota, and no
16
+ * chance of a value nobody wrote: every declaration it emits either points at a
17
+ * token they declared or carries a literal they typed.
18
+ *
19
+ * WHY NOT `refit`. That endpoint already turns arbitrary component code into a
20
+ * token-only recipe, and it is the right tool for its own job - bringing in
21
+ * code from OUTSIDE and dressing it in a system. But it maps every value to the
22
+ * NEAREST allowed token and never emits a raw one, which is normalisation, and
23
+ * v1 is a mirror. It also costs credits and has a daily quota, and a first
24
+ * import would spend 23 of them before anybody has seen value. It stays where
25
+ * it is; this runs first, for free, on what is already theirs.
26
+ *
27
+ * THE MODIFIER SAYS WHERE THE VALUE GOES, which is what makes this tractable:
28
+ *
29
+ * bg-white → base
30
+ * dark:bg-darkgray-500 → the dark side of base
31
+ * hover:shadow-md → states.hover
32
+ * data-[checked]:bg-ocean-50 → states.checked
33
+ */
34
+ /**
35
+ * Utility prefix → CSS property. Only the ones that carry DESIGN, which is the
36
+ * whole point: `flex`, `items-center` and `w-full` are structure, they are
37
+ * identical in every design system, and a recipe carrying them would be
38
+ * transcribing layout rather than a look.
39
+ */
40
+ const COLOR_PROPERTY = {
41
+ bg: "background-color",
42
+ text: "color",
43
+ border: "border-color",
44
+ ring: "outline-color",
45
+ fill: "fill",
46
+ stroke: "stroke",
47
+ decoration: "text-decoration-color",
48
+ };
49
+ /** Tailwind's own spacing scale, in rem - the fixed part of the fixed table. */
50
+ const SPACING = {
51
+ "0": "0",
52
+ px: "1px",
53
+ "0.5": "0.125rem",
54
+ "1": "0.25rem",
55
+ "1.5": "0.375rem",
56
+ "2": "0.5rem",
57
+ "2.5": "0.625rem",
58
+ "3": "0.75rem",
59
+ "3.5": "0.875rem",
60
+ "4": "1rem",
61
+ "5": "1.25rem",
62
+ "6": "1.5rem",
63
+ "7": "1.75rem",
64
+ "8": "2rem",
65
+ "9": "2.25rem",
66
+ "10": "2.5rem",
67
+ "11": "2.75rem",
68
+ "12": "3rem",
69
+ "14": "3.5rem",
70
+ "16": "4rem",
71
+ "20": "5rem",
72
+ "24": "6rem",
73
+ };
74
+ const SPACING_PROPERTY = {
75
+ p: "padding",
76
+ px: "padding-inline",
77
+ py: "padding-block",
78
+ pt: "padding-top",
79
+ pr: "padding-right",
80
+ pb: "padding-bottom",
81
+ pl: "padding-left",
82
+ m: "margin",
83
+ mx: "margin-inline",
84
+ my: "margin-block",
85
+ gap: "gap",
86
+ };
87
+ /** Tailwind's radius scale. */
88
+ const RADIUS = {
89
+ none: "0",
90
+ sm: "0.125rem",
91
+ "": "0.25rem",
92
+ md: "0.375rem",
93
+ lg: "0.5rem",
94
+ xl: "0.75rem",
95
+ "2xl": "1rem",
96
+ "3xl": "1.5rem",
97
+ full: "9999px",
98
+ };
99
+ /** Tailwind's own colour table, for the values a project uses without declaring.
100
+ * Only the ones that actually turn up: white, black, and the neutral ramp a
101
+ * real project reached for 106 times. */
102
+ const TAILWIND_COLOR = {
103
+ white: "#ffffff",
104
+ black: "#000000",
105
+ transparent: "transparent",
106
+ "neutral-50": "#fafafa",
107
+ "neutral-100": "#f5f5f5",
108
+ "neutral-200": "#e5e5e5",
109
+ "neutral-300": "#d4d4d4",
110
+ "neutral-400": "#a3a3a3",
111
+ "neutral-500": "#737373",
112
+ "neutral-600": "#525252",
113
+ "neutral-700": "#404040",
114
+ "neutral-800": "#262626",
115
+ "neutral-900": "#171717",
116
+ "neutral-950": "#0a0a0a",
117
+ };
118
+ /** States we recognise. Anything else is skipped rather than invented. */
119
+ const STATE = /^(hover|focus|focus-visible|active|disabled|checked)$/;
120
+ const DATA_STATE = /^data-\[([a-z-]+)\]$/;
121
+ /**
122
+ * Split a class into its modifiers and the utility itself.
123
+ *
124
+ * Arbitrary values can contain colons (`bg-[url(a:b)]`), so the split stops at
125
+ * the first bracket - a naive split on `:` would shred them.
126
+ */
127
+ export function parseClass(cls) {
128
+ // Bracket-aware, because BOTH sides use them: `data-[checked]:` is a modifier
129
+ // that contains one and `bg-[url(a:b)]` is a value that contains a colon. A
130
+ // first attempt stopped at the first `[`, which swallowed every `data-[…]:`
131
+ // modifier whole and dropped the state it named.
132
+ const modifiers = [];
133
+ let depth = 0;
134
+ let start = 0;
135
+ for (let i = 0; i < cls.length; i++) {
136
+ const ch = cls[i];
137
+ if (ch === "[")
138
+ depth += 1;
139
+ else if (ch === "]")
140
+ depth -= 1;
141
+ else if (ch === ":" && depth === 0) {
142
+ modifiers.push(cls.slice(start, i));
143
+ start = i + 1;
144
+ }
145
+ }
146
+ return { modifiers, utility: cls.slice(start) };
147
+ }
148
+ /**
149
+ * One utility → one declaration, or nothing.
150
+ *
151
+ * `declared` maps a token NAME to its value, so a resolved colour can be
152
+ * reported in their vocabulary rather than as a hex we looked up.
153
+ */
154
+ export function readUtility(utility, declared) {
155
+ // An opacity modifier changes the value, not the property, and honouring it
156
+ // would mean computing a colour they never wrote. The property still belongs
157
+ // in the recipe, so the base colour travels and the alpha does not.
158
+ const [core] = utility.split("/");
159
+ const dash = core.indexOf("-");
160
+ if (dash === -1)
161
+ return null;
162
+ const prefix = core.slice(0, dash);
163
+ const rest = core.slice(dash + 1);
164
+ const colorProp = COLOR_PROPERTY[prefix];
165
+ if (colorProp) {
166
+ // Their own token first: `--color-ocean-500` for `bg-ocean-500`, which is
167
+ // exactly how Tailwind v4's `@theme` publishes it.
168
+ const own = `--color-${rest}`;
169
+ if (declared.has(own)) {
170
+ return { property: colorProp, value: refFor(rest), token: own };
171
+ }
172
+ const builtin = TAILWIND_COLOR[rest];
173
+ if (builtin)
174
+ return { property: colorProp, value: builtin };
175
+ return null;
176
+ }
177
+ const spaceProp = SPACING_PROPERTY[prefix];
178
+ if (spaceProp && SPACING[rest]) {
179
+ return { property: spaceProp, value: SPACING[rest] };
180
+ }
181
+ if (prefix === "rounded") {
182
+ const own = `--radius-${rest}`;
183
+ if (declared.has(own)) {
184
+ return {
185
+ property: "border-radius",
186
+ value: `{radius.${rest}}`,
187
+ token: own,
188
+ };
189
+ }
190
+ if (RADIUS[rest])
191
+ return { property: "border-radius", value: RADIUS[rest] };
192
+ }
193
+ return null;
194
+ }
195
+ /**
196
+ * A colour token ref in the document's own spelling.
197
+ *
198
+ * `ocean-500` → `{color.ocean.500}`. The step is the trailing number; anything
199
+ * before it is the family, hyphens intact, because `royal-blue-500` is one
200
+ * family called `royal-blue`.
201
+ */
202
+ function refFor(name) {
203
+ const m = /^(.*)-(\d{2,4})$/.exec(name);
204
+ if (!m)
205
+ return `{color.${name}}`;
206
+ return `{color.${m[1]}.${m[2]}}`;
207
+ }
208
+ /**
209
+ * Read every class in a component's root element and route each declaration by
210
+ * its modifier.
211
+ *
212
+ * Unknown modifiers are skipped, not flattened into `base`: a `sm:` or
213
+ * `group-data-[checked]:` value written into the resting state would be a look
214
+ * the component never has.
215
+ */
216
+ export function transcribe(classes, declared) {
217
+ const out = {
218
+ base: {},
219
+ states: {},
220
+ dark: {},
221
+ skipped: [],
222
+ fromToken: 0,
223
+ fromLiteral: 0,
224
+ };
225
+ for (const cls of classes) {
226
+ const { modifiers, utility } = parseClass(cls);
227
+ const isDark = modifiers.includes("dark");
228
+ const rest = modifiers.filter((m) => m !== "dark");
229
+ // THE SLOT IS DECIDED BEFORE THE VALUE IS READ. Resolving first meant a
230
+ // `dark:hover:` whose colour happened to be undeclared vanished in silence
231
+ // rather than being reported as a look we have nowhere to put - two
232
+ // different problems, and only one of them is theirs to hear about.
233
+ let target = null;
234
+ let unslotted = false;
235
+ if (rest.length === 0) {
236
+ target = isDark ? out.dark : out.base;
237
+ }
238
+ else if (rest.length === 1 && !isDark) {
239
+ const state = STATE.test(rest[0])
240
+ ? rest[0]
241
+ : (DATA_STATE.exec(rest[0])?.[1] ?? null);
242
+ if (state)
243
+ target = out.states[state] ?? (out.states[state] = {});
244
+ else
245
+ unslotted = true;
246
+ }
247
+ else {
248
+ // A state under `dark:` is the dark scheme's version of that state, and a
249
+ // recipe has no slot for it.
250
+ unslotted = true;
251
+ }
252
+ const decl = readUtility(utility, declared);
253
+ if (unslotted) {
254
+ // Only worth saying when it IS a design value; an unslotted `sm:flex` is
255
+ // layout and nobody needs to hear about it.
256
+ if (decl)
257
+ out.skipped.push(cls);
258
+ continue;
259
+ }
260
+ if (!decl || !target)
261
+ continue;
262
+ // First write wins, matching how a class list resolves for the properties
263
+ // we read: later duplicates in the same list are almost always a merge
264
+ // artefact rather than an override.
265
+ if (target[decl.property] != null)
266
+ continue;
267
+ target[decl.property] = decl.value;
268
+ if (decl.token)
269
+ out.fromToken += 1;
270
+ else
271
+ out.fromLiteral += 1;
272
+ }
273
+ return out;
274
+ }
275
+ /**
276
+ * THE ROOT ELEMENT'S CLASSES, from a component file.
277
+ *
278
+ * A component's look lives on the outermost element it returns, and everything
279
+ * below it is a part - which a recipe has slots for and this slice does not
280
+ * attempt. Finding the root without an AST: the first JSX tag after `return (`
281
+ * is it, and its class list runs to the first `>` at depth zero.
282
+ *
283
+ * Deliberately conservative. A file this cannot read confidently returns
284
+ * nothing, and nothing is a contract with an empty base - which is exactly what
285
+ * the import already produces, so failing here costs no ground.
286
+ */
287
+ export function rootClasses(source) {
288
+ const ret = source.search(/return\s*\(/);
289
+ if (ret === -1)
290
+ return [];
291
+ const open = source.indexOf("<", ret);
292
+ if (open === -1)
293
+ return [];
294
+ // Walk to the end of the opening tag, tracking brackets so a `className={cn(
295
+ // "a", cond ? "b" : "c")}` block is read whole rather than cut at its first
296
+ // `>` inside a comparison.
297
+ let depth = 0;
298
+ let end = -1;
299
+ for (let i = open + 1; i < source.length; i++) {
300
+ const ch = source[i];
301
+ if (ch === "{" || ch === "(" || ch === "[")
302
+ depth += 1;
303
+ else if (ch === "}" || ch === ")" || ch === "]")
304
+ depth -= 1;
305
+ else if (ch === ">" && depth <= 0) {
306
+ end = i;
307
+ break;
308
+ }
309
+ }
310
+ if (end === -1)
311
+ return [];
312
+ const tag = source.slice(open, end);
313
+ const at = tag.search(/className\s*=/);
314
+ if (at === -1)
315
+ return [];
316
+ // Every string literal inside the className expression. Template literals and
317
+ // computed values are skipped on purpose: a class we cannot see in full is a
318
+ // class we would be guessing at.
319
+ const expr = tag.slice(at);
320
+ return [...expr.matchAll(/["']([^"']+)["']/g)]
321
+ .flatMap((m) => m[1].split(/\s+/))
322
+ .filter((c) => c.length > 0 && !c.includes("${"));
323
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "synthesisui",
3
- "version": "0.16.63",
3
+ "version": "0.16.66",
4
4
  "description": "Bring SynthesisUI design systems into any project - tokens, typed components, whole pages and an agent-ready CLAUDE.md manifest.",
5
5
  "type": "module",
6
6
  "bin": {