synthesisui 0.16.81 → 0.16.83

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.
@@ -2,20 +2,23 @@ import { mkdir, readdir, readFile, stat, writeFile } from "node:fs/promises";
2
2
  import { basename, dirname, join, relative } from "node:path";
3
3
  import { resolveAnatomy, resolveFlatParts, safePartName, } from "../anatomy-read.js";
4
4
  import { readCredentials, readToken, resolveRegistry, sameRegistry, } from "../config.js";
5
- import { describeArchitecture, describeChoice, detectArchitectures, } from "../doctor/architecture.js";
5
+ import { architectureRule, describeArchitecture, describeChoice, detectArchitectures, } from "../doctor/architecture.js";
6
6
  import { findBrokenRefs } from "../doctor/broken-refs.js";
7
7
  import { nestingRules, propRules, readDefinitionProps, readNesting, } from "../doctor/call-sites.js";
8
8
  import { describeClassStyle, detectClassStyle, } from "../doctor/class-style.js";
9
9
  import { isNearDuplicate, isNeutral, lightness, } from "../doctor/color-distance.js";
10
10
  import { describeGate, gateComponent, groupSkips, } from "../doctor/component-gate.js";
11
11
  import { emptyTally, internalSpecifiers, scanComponentsInto, tallyToInventory, } from "../doctor/components-scan.js";
12
+ import { countShape, describeCoverage, summarizeCoverage, } from "../doctor/coverage.js";
12
13
  import { crosswalk, isLibrary, observedRules } from "../doctor/crosswalk.js";
14
+ import { moduleImports, readModuleCss, readModuleUsage, transcribeModule, } from "../doctor/css-modules.js";
13
15
  import { reconcile, scanDefinitions, } from "../doctor/definitions-scan.js";
14
16
  import { describeConvention, describeRemainder, detectConventions, } from "../doctor/idiom.js";
15
17
  import { diagnose, scanSource } from "../doctor/scan.js";
16
18
  import { parseSchemeBlocks } from "../doctor/scheme-blocks.js";
17
19
  import { buildTable } from "../doctor/tokens.js";
18
- import { rootClasses, rootTag, transcribe, } from "../doctor/transcribe.js";
20
+ import { readInlineStyle, rootClasses, rootTag, transcribe, } from "../doctor/transcribe.js";
21
+ import { transcribeVariants } from "../doctor/variant-read.js";
19
22
  import { body, paint, section } from "../output.js";
20
23
  import { startProgress } from "../progress.js";
21
24
  import { detectStack, resolveDeps } from "../stack.js";
@@ -242,6 +245,18 @@ export async function takeCensus(root, opts) {
242
245
  const nesting = new Map();
243
246
  /** Component → what its own definition names and whether it forwards the rest. */
244
247
  const propsOf = new Map();
248
+ /** Component → the axes its own `cva` declares. See `variant-read.ts`. */
249
+ const variantAxes = new Map();
250
+ /** How much came out of CSS Modules, for the coverage report. */
251
+ let moduleFiles = 0;
252
+ let moduleDeclarations = 0;
253
+ const moduleKeyframes = {};
254
+ /** How each component expresses style, so the census can state its own coverage. */
255
+ const shapes = new Map();
256
+ /** Component FILES the gate accepted - the honest denominator for coverage. A first
257
+ * version used the whole inventory, which includes everything they compose from a
258
+ * package, and reported 128 for a library of 36 (probe, 01/08). */
259
+ let componentFiles = 0;
245
260
  /**
246
261
  * A census over a real monorepo reads 2797 files, and the terminal said nothing for
247
262
  * most of it. Twenty silent minutes is indistinguishable from twenty broken ones
@@ -306,6 +321,10 @@ export async function takeCensus(root, opts) {
306
321
  for (const d of found) {
307
322
  propsOf.set(d.name, readDefinitionProps(d.name, src));
308
323
  }
324
+ if (found.length > 0) {
325
+ countShape(src, found[0].name, shapes);
326
+ componentFiles += 1;
327
+ }
309
328
  defined.push(...found);
310
329
  // THE LOOK, from their own class names. One transcription per file, keyed
311
330
  // by the component it defines - the root element's classes are the
@@ -313,12 +332,102 @@ export async function takeCensus(root, opts) {
313
332
  // not attempt.
314
333
  if (found.length > 0) {
315
334
  const t = transcribe(rootClasses(src), declaredValues);
316
- const size = Object.keys(t.base).length +
317
- Object.keys(t.dark).length +
318
- Object.keys(t.states).length;
335
+ /**
336
+ * THE VARIANT STYLES, FOLDED IN.
337
+ *
338
+ * `rootClasses` reads the class string ON the element, which for a component
339
+ * built with `cva` is a call - `className={cn(buttonVariants({ variant }))}` -
340
+ * and carries none of the nine variants' colours. The definition does, in an
341
+ * object literal, and reading it is deterministic (see `variant-read.ts`).
342
+ *
343
+ * Folded here rather than reported separately because this is the one funnel:
344
+ * the reader was computing 23 layers for a real Button and nobody was asking
345
+ * for the result, which is the "valid and unread" failure this pipeline has
346
+ * paid for twice already.
347
+ */
348
+ const v = transcribeVariants(src, declaredValues);
349
+ /**
350
+ * THE CSS MODULE, if this component styles that way.
351
+ *
352
+ * 239 of 317 components in a real app do, and they arrived with ZERO
353
+ * declarations - 5,777 real CSS declarations sat in 530 stylesheets nobody
354
+ * read (dono, 01/08). Read here rather than in a pass of its own so a
355
+ * component that uses BOTH a module and utilities comes out as one look.
356
+ */
357
+ const modules = moduleImports(src);
358
+ let fromModule = null;
359
+ for (const mod of modules) {
360
+ const cssPath = join(dirname(file), mod.specifier);
361
+ const moduleCss = await readFile(cssPath, "utf8").catch(() => "");
362
+ if (!moduleCss)
363
+ continue;
364
+ const usage = readModuleUsage(src, mod.local);
365
+ if (usage.length === 0)
366
+ continue;
367
+ const readModule = transcribeModule(readModuleCss(moduleCss), usage, declaredValues);
368
+ if (readModule.read === 0)
369
+ continue;
370
+ fromModule = readModule;
371
+ moduleFiles += 1;
372
+ moduleDeclarations += readModule.read;
373
+ for (const [name, frames] of Object.entries(readModule.keyframes)) {
374
+ if (!moduleKeyframes[name])
375
+ moduleKeyframes[name] = frames;
376
+ }
377
+ // One module per component is the shape every one of the 530 follows.
378
+ break;
379
+ }
380
+ // The MODULE first and the utilities on top: a component that has both is
381
+ // adding a utility to a module-styled element, not the other way round.
382
+ /**
383
+ * `style={{ }}` UNDER everything: a literal is the weakest source, so a class or
384
+ * a module rule that names the same property wins. It travels rather than being
385
+ * dropped because the alternative is the component arriving empty, and empty is
386
+ * not more honest than literal - v1 mirrors, v2 normalises.
387
+ */
388
+ const inlineStyle = readInlineStyle(src);
389
+ const base = {
390
+ ...inlineStyle,
391
+ ...fromModule?.base,
392
+ ...v.base.base,
393
+ ...t.base,
394
+ };
395
+ const states = {
396
+ ...fromModule?.states,
397
+ ...v.base.states,
398
+ ...t.states,
399
+ };
400
+ const dark = { ...fromModule?.dark, ...t.dark };
401
+ const layers = [...(fromModule?.layers ?? []), ...v.layers];
402
+ const parts = { ...fromModule?.parts, ...t.parts };
403
+ const tree = fromModule?.tree ?? [];
404
+ const size = Object.keys(base).length +
405
+ Object.keys(dark).length +
406
+ Object.keys(states).length +
407
+ Object.keys(parts).length +
408
+ layers.length;
319
409
  const tag = rootTag(src);
320
410
  if (size > 0 || tag) {
321
- looks[found[0].name] = tag ? { ...t, rootTag: tag } : t;
411
+ looks[found[0].name] = {
412
+ ...t,
413
+ base,
414
+ states,
415
+ dark,
416
+ ...(Object.keys(parts).length > 0 ? { parts } : {}),
417
+ ...(tree.length > 0 ? { tree } : {}),
418
+ ...(tag ? { rootTag: tag } : {}),
419
+ ...(layers.length > 0 ? { layers } : {}),
420
+ ...(Object.keys(v.axes).length > 0 ? { declaredAxes: v.axes } : {}),
421
+ ...(Object.keys(v.defaults).length > 0
422
+ ? { defaults: v.defaults }
423
+ : {}),
424
+ };
425
+ }
426
+ // Their variant DEFINITION beats what usage happened to pick, and it is the
427
+ // better source: usage shows what somebody chose, the declaration shows what
428
+ // the author designed.
429
+ if (Object.keys(v.axes).length > 0) {
430
+ variantAxes.set(found[0].name, v.axes);
322
431
  }
323
432
  }
324
433
  }
@@ -408,12 +517,25 @@ export async function takeCensus(root, opts) {
408
517
  files: 0,
409
518
  props: {
410
519
  ...Object.fromEntries(Object.entries(d.axes)),
520
+ ...Object.fromEntries(Object.entries(variantAxes.get(d.name) ?? {})),
411
521
  ...Object.fromEntries(d.flags.map((f) => [f, ["true"]])),
412
522
  },
413
523
  propFiles: {},
414
524
  // Their own rung, off the folder. It travels from the DEFINITION because
415
525
  // that is the only reading that knows which file the component lives in.
416
526
  ...(d.tier ? { tier: d.tier } : {}),
527
+ /**
528
+ * The axes, kept BESIDE `props` as well as in it.
529
+ *
530
+ * A library composes nothing, so its components arrive through this branch and
531
+ * `declaredAxes` is where the contract writer looks. Leaving it out meant a
532
+ * Button whose `cva` declares nine variants reported none of them (probe,
533
+ * 01/08) - the styles reached the recipe and the axis names did not.
534
+ */
535
+ ...(Object.keys({ ...d.axes, ...(variantAxes.get(d.name) ?? {}) })
536
+ .length > 0
537
+ ? { declaredAxes: { ...d.axes, ...(variantAxes.get(d.name) ?? {}) } }
538
+ : {}),
417
539
  }));
418
540
  /**
419
541
  * A composed component that is ALSO defined keeps its usage and gains the
@@ -435,13 +557,22 @@ export async function takeCensus(root, opts) {
435
557
  const declaredBy = new Map(defined.map((d) => [d.name, d]));
436
558
  const enriched = inventory.map((c) => {
437
559
  const d = declaredBy.get(c.name);
438
- if (!d)
560
+ const fromVariants = variantAxes.get(c.name);
561
+ if (!d && !fromVariants)
439
562
  return c;
440
- const tier = d.tier ? { tier: d.tier } : {};
441
- if (Object.keys(d.axes).length === 0) {
563
+ const tier = d?.tier ? { tier: d.tier } : {};
564
+ /**
565
+ * THREE SOURCES FOR AN AXIS, and this is their order.
566
+ *
567
+ * The `cva` definition is the strongest: it declares the option AND what the
568
+ * option does. The prop type is next - it declares the closed set. Usage is last,
569
+ * because it shows what somebody happened to pick.
570
+ */
571
+ const axes = { ...(d?.axes ?? {}), ...(fromVariants ?? {}) };
572
+ if (Object.keys(axes).length === 0) {
442
573
  return Object.keys(tier).length > 0 ? { ...c, ...tier } : c;
443
574
  }
444
- return { ...c, declaredAxes: d.axes, ...tier };
575
+ return { ...c, declaredAxes: axes, ...tier };
445
576
  });
446
577
  const scoped = [...enriched, ...fromTypes];
447
578
  /**
@@ -519,6 +650,33 @@ export async function takeCensus(root, opts) {
519
650
  console.log(body(paint.faint(`(${theirsOnly.length} component${theirsOnly.length === 1 ? "" : "s"} live there and not in the system - ${shown.join(", ")}${theirsOnly.length > shown.length ? ", …" : ""}. Left out on purpose: the system is one place, and a union of two codebases is not a system. Point --scope at them if they belong in it.)`)));
520
651
  }
521
652
  }
653
+ /**
654
+ * WHAT THIS VERSION CAN AND CANNOT READ, on their files.
655
+ *
656
+ * Before this, importing a CSS-Modules app produced 283 components with no
657
+ * declarations and said nothing about why - and a person seeing that concludes the
658
+ * product is broken, reasoning correctly from what they were shown. The gap was never
659
+ * the defect; the silence was (dono, 01/08).
660
+ */
661
+ const coverage = summarizeCoverage(shapes, componentFiles, Object.keys(looks).length, Object.values(looks).reduce((n, look) => n +
662
+ Object.keys(look.base).length +
663
+ Object.keys(look.dark).length +
664
+ Object.values(look.states).reduce((m, block) => m + Object.keys(block).length, 0) +
665
+ // The LAYERS count: the record and ternary readers produce layers rather than a
666
+ // base, so leaving them out reported a Tag with eight colours as three
667
+ // declarations (probe, 01/08).
668
+ (look.layers ?? []).reduce((m, layer) => m + Object.keys(layer.style).length, 0) +
669
+ Object.values(look.parts ?? {}).reduce((m, part) => m +
670
+ Object.keys(part.base).length +
671
+ Object.keys(part.dark).length +
672
+ Object.values(part.states).reduce((k, block) => k + Object.keys(block).length, 0), 0), 0));
673
+ const coverageLines = describeCoverage(coverage);
674
+ if (coverageLines.length > 0) {
675
+ console.log("");
676
+ console.log(section("What this version reads, on your files"));
677
+ for (const line of coverageLines)
678
+ console.log(body(line));
679
+ }
522
680
  /**
523
681
  * WHAT THE GATE TURNED AWAY - said out loud, grouped, with the reason.
524
682
  *
@@ -625,8 +783,40 @@ export async function takeCensus(root, opts) {
625
783
  observed: distinctValues(d),
626
784
  ...(components.length > 0 ? { components } : {}),
627
785
  ...(defined.length > 0 ? { defined } : {}),
786
+ /**
787
+ * THE SHAPE, AND THE RULE IT BECOMES.
788
+ *
789
+ * `rule` travels with each one so the platform writes it without owning a second
790
+ * copy of the sentence. The reader picks which architecture has PRIORITY; absent a
791
+ * choice the first is the one, which is the shape holding the most code.
792
+ *
793
+ * Carried rather than only printed: the whole point of the feature is that the
794
+ * chosen shape reaches CLAUDE.md, and a version of this that detected the shape,
795
+ * printed it, and never turned it into a rule is a feature that does nothing (dono,
796
+ * 01/08).
797
+ */
798
+ ...(coverage.components > 0
799
+ ? {
800
+ coverage: {
801
+ components: coverage.components,
802
+ read: coverage.read,
803
+ declarations: coverage.declarations,
804
+ shapes: coverage.counts.map((c) => ({
805
+ key: c.shape.key,
806
+ label: c.shape.label,
807
+ reads: c.shape.reads,
808
+ files: c.files,
809
+ })),
810
+ },
811
+ }
812
+ : {}),
628
813
  ...(architectures.length > 0
629
- ? { architectures: architectures.slice(0, 6) }
814
+ ? {
815
+ architectures: architectures.slice(0, 6).map((a) => ({
816
+ ...a,
817
+ rule: architectureRule(a),
818
+ })),
819
+ }
630
820
  : {}),
631
821
  ...(skips.length > 0
632
822
  ? {
@@ -0,0 +1,129 @@
1
+ /**
2
+ * WHAT THIS VERSION CAN AND CANNOT READ, MEASURED ON THEIR OWN REPO.
3
+ *
4
+ * The most commercially important thing in the pipeline, and it took a person saying
5
+ * "nothing works" to see it (dono, 01/08).
6
+ *
7
+ * Before this, importing a CSS-Modules app produced 283 components with zero
8
+ * declarations and said nothing about why. A person seeing that concludes the product is
9
+ * broken, and they are reasoning correctly from what they were shown. The defect was
10
+ * never the gap - every reader has a boundary. The defect was the SILENCE about it.
11
+ *
12
+ * So the census measures its own coverage and says the number before anything is sent:
13
+ * how each component expresses style, how many of those this version reads, and which
14
+ * ones it does not - by name, with the count from their repo rather than a disclaimer.
15
+ *
16
+ * A governance product that cannot state what it does not govern is not trustworthy.
17
+ */
18
+ /**
19
+ * Every shape, in the order the enumeration found them. Each entry survived being
20
+ * counted on a real repo: a shape with no evidence is a shape I assumed.
21
+ */
22
+ export const STYLE_SHAPES = [
23
+ {
24
+ key: "static",
25
+ label: "a class list written out",
26
+ reads: "full",
27
+ test: /className=["'][^"']+["']/,
28
+ },
29
+ {
30
+ key: "module",
31
+ label: "CSS Modules",
32
+ reads: "full",
33
+ test: /from\s+["'][^"']*\.module\.(?:css|scss|sass|less)["']/,
34
+ },
35
+ {
36
+ key: "cva",
37
+ label: "cva / tailwind-variants",
38
+ reads: "full",
39
+ test: /\b(?:cva|tv)\s*\(/,
40
+ },
41
+ {
42
+ key: "record",
43
+ label: "a lookup record of option to classes",
44
+ reads: "full",
45
+ test: /(?:const|let)\s+\w*(?:[Ss]tyles?|[Cc]lasses|[Vv]ariants?|[Mm]ap|[Cc]olors?|[Ss]izes?|[Tt]ones?)\w*\s*(?::\s*[^=]+)?=\s*\{/,
46
+ },
47
+ {
48
+ key: "ternary",
49
+ label: "a ternary between class literals",
50
+ reads: "full",
51
+ test: /\?\s*["'][^"']*(?:bg-|text-|border-|p-|px-|rounded-)/,
52
+ },
53
+ {
54
+ key: "inline",
55
+ label: "style={{ }}",
56
+ reads: "partial",
57
+ because: "a literal travels as a literal, so it re-themes nowhere until you promote it - and a value computed at render time is not in the source at all",
58
+ test: /style=\{\{/,
59
+ },
60
+ {
61
+ key: "interpolated",
62
+ label: "a template with a runtime value in it",
63
+ reads: "partial",
64
+ because: "the literal halves are read and a `${cond ? a : b}` between two known classes is read as a variant; a class assembled from a variable does not exist in the source, so there is nothing to read",
65
+ test: /className=\{`[^`]*\$\{/,
66
+ },
67
+ {
68
+ key: "styled",
69
+ label: "styled-components / emotion",
70
+ reads: "none",
71
+ because: "not read by this version. It was left out deliberately rather than guessed at: zero files in the repo this was measured against, and building for a shape with no evidence is the same mistake as building for one file",
72
+ test: /styled[.(]|@emotion/,
73
+ },
74
+ {
75
+ key: "sx",
76
+ label: "a style-prop system (Chakra, Panda, MUI sx)",
77
+ reads: "none",
78
+ because: "the styling lives in props this version does not read; a component that only forwards to that library is reported as an adapter instead",
79
+ test: /\bsx=\{|@chakra-ui|@pandacss/,
80
+ },
81
+ ];
82
+ export function countShape(source, name, into) {
83
+ for (const shape of STYLE_SHAPES) {
84
+ if (!shape.test.test(source))
85
+ continue;
86
+ const at = into.get(shape.key) ?? { shape, files: 0, examples: [] };
87
+ at.files += 1;
88
+ if (at.examples.length < 4)
89
+ at.examples.push(name);
90
+ into.set(shape.key, at);
91
+ }
92
+ }
93
+ export function summarizeCoverage(counts, components, read, declarations) {
94
+ return {
95
+ components,
96
+ read,
97
+ declarations,
98
+ counts: [...counts.values()].sort((a, b) => b.files - a.files),
99
+ };
100
+ }
101
+ /**
102
+ * The report, in the words a person needs before they decide whether to import.
103
+ *
104
+ * Leads with the number that matters - how many components came out with a look - and
105
+ * then names every shape that is not fully read, with the reason. No percentages without
106
+ * the count behind them: "75%" of an unknown total is a marketing number.
107
+ */
108
+ export function describeCoverage(c) {
109
+ if (c.components === 0)
110
+ return [];
111
+ const lines = [];
112
+ const pct = Math.round((c.read / c.components) * 100);
113
+ lines.push(`${c.read} of ${c.components} components came out with a look - ${pct}%, ${c.declarations} declarations in total. This is measured on your files, not an estimate.`);
114
+ for (const { shape, files, examples } of c.counts) {
115
+ const share = Math.round((files / c.components) * 100);
116
+ const head = ` ${files} (${share}%) ${shape.label}`;
117
+ if (shape.reads === "full") {
118
+ lines.push(`${head} - read`);
119
+ continue;
120
+ }
121
+ lines.push(`${head} - ${shape.reads === "none" ? "NOT read" : "partly read"}. ${shape.because ?? ""}`);
122
+ lines.push(` ${examples.join(", ")}`);
123
+ }
124
+ const missing = c.components - c.read;
125
+ if (missing > 0) {
126
+ lines.push(`${missing} came out with no look at all. They still arrive as contracts - the axes their types declare, their name, their place in your tree - and the look stays yours to write. That is the design, but you should know the number before you decide.`);
127
+ }
128
+ return lines;
129
+ }
@@ -0,0 +1,535 @@
1
+ /**
2
+ * CSS MODULES - the largest source of style in a real repo, and the one nothing read.
3
+ *
4
+ * Enumerated before a line of this was written, which is the rule that produced it
5
+ * (dono, 01/08). In `apps/web-dashboard`:
6
+ *
7
+ * 530 module stylesheets, 32,947 lines
8
+ * 3,978 simple class selectors each one a part, under the author's own name
9
+ * 5,777 real CSS declarations against 0.2 per component read before this
10
+ * 1,623 (28%) point at var(--their-token)
11
+ * 51 dark rules via [data-theme], all in one stylesheet
12
+ * 154 keyframes
13
+ * 47 descendant selectors
14
+ * 45 media queries
15
+ * 29 states
16
+ * 0 `composes:` - no inheritance to resolve
17
+ *
18
+ * 239 of 317 components (75%) style this way, and they arrived with ZERO declarations.
19
+ * A person importing that app would get 283 empty components and conclude the product
20
+ * is broken, which is a reasonable conclusion to reach.
21
+ *
22
+ * IT IS MORE RELIABLE THAN READING TAILWIND, not less. There is no utility table to
23
+ * maintain and nothing is inferred from a name: the declaration is already CSS, and the
24
+ * part name is the word the author typed. The mapping is arithmetic:
25
+ *
26
+ * Loader.tsx import styles from './Chat.module.css'
27
+ * <div className={styles.loader}> → the base
28
+ * <div className={styles.spinner}> → part "spinner"
29
+ * <p className={styles.loadingMessage}> → part "loading-message"
30
+ *
31
+ * Chat.module.css .loader { display:flex; gap:8px }
32
+ * .spinner { width:20px; animation:spinPulse 4s }
33
+ * .loadingMessage { … }
34
+ *
35
+ * THE INTERPOLATED TEMPLATE IS THE SAME FAMILY, which the enumeration also settled. Of
36
+ * 200 real `className={`…`}` occurrences, 165 are a literal plus `${styles.x}` and 85
37
+ * are `${cond ? styles.a : styles.b}` - a variant, with the condition naming the prop
38
+ * and both branches naming a class in this file. Exactly one is a ternary between two
39
+ * string literals. So this reader covers a family I had written off as impossible.
40
+ */
41
+ /** A CSS pseudo-class or attribute, mapped to the state name the contract uses. */
42
+ const STATE_OF = [
43
+ [/:hover\b/, "hover"],
44
+ [/:focus-visible\b/, "focusVisible"],
45
+ [/:focus-within\b/, "focus"],
46
+ [/:focus\b/, "focus"],
47
+ [/:active\b/, "active"],
48
+ [/:disabled\b|\[disabled\]/, "disabled"],
49
+ [/:checked\b|\[data-checked\]|\[aria-checked="?true"?\]/, "checked"],
50
+ [/\[aria-selected="?true"?\]|\[data-selected\]/, "selected"],
51
+ [/\[open\]|\[aria-expanded="?true"?\]|\[data-state="?open"?\]/, "open"],
52
+ [/:invalid\b|\[aria-invalid="?true"?\]/, "invalid"],
53
+ [/:read-only\b|\[readonly\]/, "readOnly"],
54
+ [/::placeholder\b/, "placeholder"],
55
+ [/:nth-child\(even\)/, "even"],
56
+ [/:nth-child\(odd\)/, "odd"],
57
+ [/:first-child\b/, "first"],
58
+ [/:last-child\b/, "last"],
59
+ [/:empty\b/, "empty"],
60
+ ];
61
+ /** `[data-theme="dark"]`, `.dark`, `[data-scheme=dark]` - how a project says dark. */
62
+ const DARK = /\[data-theme=["']?dark["']?\]|\[data-scheme=["']?dark["']?\]|(^|\s)\.dark(\s|$)/;
63
+ /** `kebab-case` → `camelCase`, which is the only spelling the contract accepts. */
64
+ export function camel(prop) {
65
+ return prop.trim().replace(/-([a-z])/g, (_, c) => c.toUpperCase());
66
+ }
67
+ /** `.loadingMessage` → `loading-message`. THEIR word, only re-spelled for a class. */
68
+ export function partName(authored) {
69
+ return authored
70
+ .replace(/([a-z0-9])([A-Z])/g, "$1-$2")
71
+ .toLowerCase()
72
+ .replace(/[^a-z0-9-]/g, "-")
73
+ .replace(/-+/g, "-")
74
+ .replace(/^-|-$/g, "");
75
+ }
76
+ /** Comments out, so a `{` inside one cannot end a block early. */
77
+ function stripCssComments(css) {
78
+ return css.replace(/\/\*[\s\S]*?\*\//g, "");
79
+ }
80
+ function rules(css, media = null, out = []) {
81
+ let i = 0;
82
+ while (i < css.length) {
83
+ const open = css.indexOf("{", i);
84
+ if (open === -1)
85
+ break;
86
+ const head = css.slice(i, open).trim();
87
+ // Balance to the matching close, because an at-rule contains whole rules.
88
+ let depth = 0;
89
+ let close = -1;
90
+ for (let j = open; j < css.length; j++) {
91
+ if (css[j] === "{")
92
+ depth += 1;
93
+ else if (css[j] === "}") {
94
+ depth -= 1;
95
+ if (depth === 0) {
96
+ close = j;
97
+ break;
98
+ }
99
+ }
100
+ }
101
+ if (close === -1)
102
+ break;
103
+ const body = css.slice(open + 1, close);
104
+ if (head.startsWith("@")) {
105
+ const at = /^@media\b/.test(head) ? head : null;
106
+ if (/^@(?:media|supports|layer)\b/.test(head)) {
107
+ rules(body, at ?? media, out);
108
+ }
109
+ else {
110
+ // `@keyframes` and friends: the caller reads them whole.
111
+ out.push({ selector: head, body, media });
112
+ }
113
+ }
114
+ else if (head) {
115
+ out.push({ selector: head, body, media });
116
+ }
117
+ i = close + 1;
118
+ }
119
+ return out;
120
+ }
121
+ /** `prop: value;` pairs, in the camelCase the contract uses. */
122
+ function declarations(body) {
123
+ const out = {};
124
+ // Only the top level: a nested rule's declarations belong to that rule.
125
+ const flat = body.replace(/\{[^{}]*\}/g, "").replace(/[^;]*\{/g, "");
126
+ for (const piece of flat.split(";")) {
127
+ const colon = piece.indexOf(":");
128
+ if (colon === -1)
129
+ continue;
130
+ const prop = piece.slice(0, colon).trim();
131
+ const value = piece.slice(colon + 1).trim();
132
+ if (!/^-?[a-z][a-z-]*$/.test(prop) || !value)
133
+ continue;
134
+ // A value that could break out of the stylesheet is not a value we carry.
135
+ if (/[<>{}@]/.test(value))
136
+ continue;
137
+ out[camel(prop)] = value;
138
+ }
139
+ return out;
140
+ }
141
+ /** `min-width: 768px` out of a media query, or null for anything else. */
142
+ function minWidthOf(media) {
143
+ if (!media)
144
+ return null;
145
+ const m = /min-width\s*:\s*([\d.]+(?:px|rem|em))/i.exec(media);
146
+ return m ? m[1] : null;
147
+ }
148
+ const CLASS_TOKEN = /\.(-?[_a-zA-Z][\w-]*)/g;
149
+ /** Every class the selector names, in order - the last one is the target. */
150
+ function classesIn(selector) {
151
+ CLASS_TOKEN.lastIndex = 0;
152
+ return [...selector.matchAll(CLASS_TOKEN)].map((m) => m[1]);
153
+ }
154
+ function emptyClass() {
155
+ return {
156
+ base: {},
157
+ states: {},
158
+ dark: {},
159
+ at: {},
160
+ darkStates: {},
161
+ children: [],
162
+ };
163
+ }
164
+ /**
165
+ * One module stylesheet, read.
166
+ *
167
+ * `declared` resolves a `var(--their-token)` to the ref for the token they named, the
168
+ * same way the utility reader does - so the value travels as `{radius.md}` rather than
169
+ * as a literal, and the recipe re-themes.
170
+ */
171
+ export function readModuleCss(css) {
172
+ const out = { classes: {}, keyframes: {}, unslotted: [] };
173
+ const clean = stripCssComments(css);
174
+ for (const rule of rules(clean)) {
175
+ if (rule.selector.startsWith("@keyframes")) {
176
+ const name = rule.selector.replace(/^@keyframes\s+/, "").trim();
177
+ if (name)
178
+ out.keyframes[name] = rule.body.trim();
179
+ continue;
180
+ }
181
+ if (rule.selector.startsWith("@")) {
182
+ out.unslotted.push(rule.selector);
183
+ continue;
184
+ }
185
+ // A comma-separated selector is several rules with one body.
186
+ for (const one of rule.selector.split(",")) {
187
+ const selector = one.trim();
188
+ if (!selector)
189
+ continue;
190
+ const names = classesIn(selector);
191
+ if (names.length === 0) {
192
+ // An element or `:root` selector styles something this reader cannot attach to
193
+ // a component. Said out loud rather than dropped.
194
+ out.unslotted.push(selector);
195
+ continue;
196
+ }
197
+ const target = names[names.length - 1];
198
+ const entry = out.classes[target] ?? emptyClass();
199
+ out.classes[target] = entry;
200
+ // A descendant selector records the RELATION as well as the styles: `.panel
201
+ // .title` says title lives inside panel, which is the anatomy for free.
202
+ if (names.length > 1) {
203
+ const parent = names[names.length - 2];
204
+ const above = out.classes[parent] ?? emptyClass();
205
+ if (!above.children.includes(target))
206
+ above.children.push(target);
207
+ out.classes[parent] = above;
208
+ }
209
+ const block = declarations(rule.body);
210
+ if (Object.keys(block).length === 0)
211
+ continue;
212
+ const isDark = DARK.test(selector);
213
+ const state = STATE_OF.find(([re]) => re.test(selector))?.[1] ?? null;
214
+ const width = minWidthOf(rule.media);
215
+ if (width) {
216
+ // A breakpoint layer wins over the state split: `@media … { .x:hover }` is rare
217
+ // enough that carrying it as the breakpoint's block is the honest simplification,
218
+ // and the selector is reported when it is neither.
219
+ entry.at[width] = { ...entry.at[width], ...block };
220
+ continue;
221
+ }
222
+ if (isDark && state) {
223
+ entry.darkStates[state] = { ...entry.darkStates[state], ...block };
224
+ continue;
225
+ }
226
+ if (isDark) {
227
+ entry.dark = { ...entry.dark, ...block };
228
+ continue;
229
+ }
230
+ if (state) {
231
+ entry.states[state] = { ...entry.states[state], ...block };
232
+ continue;
233
+ }
234
+ entry.base = { ...entry.base, ...block };
235
+ }
236
+ }
237
+ return out;
238
+ }
239
+ // ---------------------------------------------------------------------------
240
+ // The other half: which element wears which class
241
+ // ---------------------------------------------------------------------------
242
+ /** `import styles from "./X.module.css"` - the local name and the file. */
243
+ const MODULE_IMPORT = /import\s+([A-Za-z_$][\w$]*)\s+from\s+["']([^"']*\.module\.(?:css|scss|sass|less))["']/g;
244
+ export function moduleImports(source) {
245
+ MODULE_IMPORT.lastIndex = 0;
246
+ return [...source.matchAll(MODULE_IMPORT)].map((m) => ({
247
+ local: m[1],
248
+ specifier: m[2],
249
+ }));
250
+ }
251
+ /**
252
+ * A generic in type position, not an element.
253
+ *
254
+ * The same discriminator the utility reader uses, for the same reason: JSX follows `(`,
255
+ * `{`, `>`, a newline, a keyword or nothing, while a type argument follows an
256
+ * identifier. `return <Foo />` is the keyword case, so the keyword list is here too.
257
+ */
258
+ const JSX_BEFORE = /\b(return|case|default|await|yield|typeof|in|of|do|else|new)$/;
259
+ function looksLikeTypeArg(source, at) {
260
+ for (let i = at - 1; i >= 0; i--) {
261
+ const c = source[i];
262
+ if (c === " " || c === "\t")
263
+ continue;
264
+ if (!/[A-Za-z0-9_)\]]/.test(c))
265
+ return false;
266
+ return !JSX_BEFORE.test(source.slice(Math.max(0, i - 11), i + 1));
267
+ }
268
+ return false;
269
+ }
270
+ function scanTags(source) {
271
+ const out = [];
272
+ for (let i = 0; i < source.length; i++) {
273
+ if (source[i] !== "<")
274
+ continue;
275
+ const closing = source[i + 1] === "/";
276
+ const nameAt = i + (closing ? 2 : 1);
277
+ const name = /^[A-Za-z][\w.]*/.exec(source.slice(nameAt, nameAt + 64))?.[0];
278
+ if (!name)
279
+ continue;
280
+ if (!closing && looksLikeTypeArg(source, i))
281
+ continue;
282
+ let depth = 0;
283
+ let quote = null;
284
+ let end = -1;
285
+ for (let j = nameAt + name.length; j < source.length; j++) {
286
+ const ch = source[j];
287
+ if (quote) {
288
+ if (ch === "\\")
289
+ j += 1;
290
+ else if (ch === quote)
291
+ quote = null;
292
+ continue;
293
+ }
294
+ if (ch === '"' || ch === "'" || ch === "`") {
295
+ quote = ch;
296
+ continue;
297
+ }
298
+ if (ch === "{")
299
+ depth += 1;
300
+ else if (ch === "}")
301
+ depth -= 1;
302
+ else if (ch === ">" && depth === 0) {
303
+ end = j;
304
+ break;
305
+ }
306
+ }
307
+ if (end === -1)
308
+ continue;
309
+ const body = source.slice(nameAt + name.length, end);
310
+ if (closing) {
311
+ out.push({ at: i, kind: "close", tag: name, body: "" });
312
+ }
313
+ else {
314
+ out.push({ at: i, kind: "open", tag: name, body });
315
+ // Self-closing opens and closes at once.
316
+ if (body.trimEnd().endsWith("/")) {
317
+ out.push({ at: i + 1, kind: "close", tag: name, body: "" });
318
+ }
319
+ }
320
+ i = end;
321
+ }
322
+ return out;
323
+ }
324
+ /**
325
+ * Which class each element wears, and how deep it sits.
326
+ *
327
+ * The FIRST element carrying a module class is the component's own surface; everything
328
+ * below it is a part. That is the same rule the utility reader follows with
329
+ * `rootClasses`, applied to a different way of spelling a class.
330
+ */
331
+ export function readModuleUsage(source, local) {
332
+ const out = [];
333
+ const ref = new RegExp(`\\b${local}\\.([A-Za-z_$][\\w$]*)`, "g");
334
+ const bracket = new RegExp(`\\b${local}\\[`, "g");
335
+ // Depth by counting opens and closes as they appear, which is enough for well-formed
336
+ // JSX and degrades to a flat list rather than to a wrong tree.
337
+ const events = scanTags(source);
338
+ let depth = 0;
339
+ for (const event of events) {
340
+ if (event.kind === "close") {
341
+ depth = Math.max(0, depth - 1);
342
+ continue;
343
+ }
344
+ const body = event.body;
345
+ if (bracket.test(body)) {
346
+ // `styles[key]` picks a class at runtime, and the class is not in the source.
347
+ // Impossible rather than hard, so it is not attempted.
348
+ bracket.lastIndex = 0;
349
+ }
350
+ ref.lastIndex = 0;
351
+ const named = [...body.matchAll(ref)].map((m) => m[1]);
352
+ if (named.length > 0) {
353
+ /**
354
+ * THE TERNARY IS A VARIANT, and the enumeration is why this is here: 85 of 200
355
+ * real interpolated templates are `${cond ? styles.a : styles.b}`. The condition
356
+ * names the prop; each branch names a class in this file.
357
+ */
358
+ const variants = [];
359
+ const TERNARY = new RegExp(`([!A-Za-z_$][\\w$.!]*)\\s*\\?\\s*(?:${local}\\.([\\w$]+)|["'][^"']*["'])\\s*:\\s*(?:${local}\\.([\\w$]+)|["'][^"']*["'])`, "g");
360
+ for (const t of body.matchAll(TERNARY)) {
361
+ const prop = t[1].replace(/^!/, "").split(".").pop() ?? "";
362
+ if (!prop)
363
+ continue;
364
+ variants.push({
365
+ prop,
366
+ whenTrue: t[2] ?? null,
367
+ whenFalse: t[3] ?? null,
368
+ });
369
+ }
370
+ // The resting class is the first one that is not a ternary branch.
371
+ const inTernary = new Set(variants.flatMap((v) => [v.whenTrue, v.whenFalse].filter(Boolean)));
372
+ const resting = named.find((n) => !inTernary.has(n)) ?? named[0];
373
+ out.push({ className: resting, depth, tag: event.tag, variants });
374
+ }
375
+ depth += 1;
376
+ }
377
+ return out;
378
+ }
379
+ // ---------------------------------------------------------------------------
380
+ // The bridge: a module read + its usage, as one Transcription
381
+ // ---------------------------------------------------------------------------
382
+ /**
383
+ * ONE FUNNEL, so a module-styled component arrives in exactly the shape a
384
+ * utility-styled one does.
385
+ *
386
+ * Everything downstream - the anatomy tree, the floor, the compiler, the vitrine -
387
+ * already knows how to read a `Transcription`. Adding a second shape beside it would
388
+ * mean every consumer learning a second one, and a consumer that learns half is how
389
+ * "valid and unread" happens.
390
+ *
391
+ * `declared` resolves `var(--their-token)` to the ref for the token they named, the same
392
+ * way the utility reader does, so the value re-themes instead of being frozen.
393
+ */
394
+ export function transcribeModule(read, usage, declared) {
395
+ const resolve = (block) => {
396
+ const out = {};
397
+ for (const [prop, value] of Object.entries(block)) {
398
+ out[prop] = value.replace(/var\(\s*(--[a-zA-Z0-9_-]+)\s*(?:,[^)]*)?\)/g, (whole, name) => declared.has(name) ? tokenRefFor(name) : whole);
399
+ }
400
+ return out;
401
+ };
402
+ const sorted = [...usage].sort((a, b) => a.depth - b.depth);
403
+ const root = sorted[0];
404
+ const parts = {};
405
+ const layers = [];
406
+ let count = 0;
407
+ const take = (className) => {
408
+ const c = read.classes[className];
409
+ if (!c)
410
+ return null;
411
+ const base = resolve(c.base);
412
+ const dark = resolve(c.dark);
413
+ const states = {};
414
+ for (const [state, block] of Object.entries(c.states)) {
415
+ states[state] = resolve(block);
416
+ }
417
+ count +=
418
+ Object.keys(base).length +
419
+ Object.keys(dark).length +
420
+ Object.values(states).reduce((n, b) => n + Object.keys(b).length, 0);
421
+ return { base, dark, states, own: c };
422
+ };
423
+ const rootTaken = root ? take(root.className) : null;
424
+ for (const u of sorted) {
425
+ const taken = u === root ? rootTaken : take(u.className);
426
+ if (!taken)
427
+ continue;
428
+ if (u !== root) {
429
+ parts[partName(u.className)] = {
430
+ base: taken.base,
431
+ dark: taken.dark,
432
+ states: taken.states,
433
+ };
434
+ }
435
+ // A breakpoint block and a dark state are LAYERS, because neither the flat map nor
436
+ // the dark half can hold a condition of two halves.
437
+ for (const [width, block] of Object.entries(taken.own.at)) {
438
+ const style = resolve(block);
439
+ if (Object.keys(style).length === 0)
440
+ continue;
441
+ count += Object.keys(style).length;
442
+ layers.push({ at: width, style });
443
+ }
444
+ for (const [state, block] of Object.entries(taken.own.darkStates)) {
445
+ const style = resolve(block);
446
+ if (Object.keys(style).length === 0)
447
+ continue;
448
+ count += Object.keys(style).length;
449
+ layers.push({ when: { state, scheme: "dark" }, style });
450
+ }
451
+ /**
452
+ * `${selected ? styles.selected : styles.notSelected}` - a variant, read from the
453
+ * markup. Both branches name a class in this file, so both become a layer under the
454
+ * prop the condition names.
455
+ */
456
+ for (const v of u.variants) {
457
+ for (const [option, className] of [
458
+ ["true", v.whenTrue],
459
+ ["false", v.whenFalse],
460
+ ]) {
461
+ if (!className)
462
+ continue;
463
+ const branch = read.classes[className];
464
+ if (!branch)
465
+ continue;
466
+ const style = resolve(branch.base);
467
+ if (Object.keys(style).length === 0)
468
+ continue;
469
+ count += Object.keys(style).length;
470
+ layers.push({ when: { variant: { [v.prop]: option } }, style });
471
+ }
472
+ }
473
+ }
474
+ /**
475
+ * The tree, from the depth the markup put each element at. `children` of the root are
476
+ * everything one level down; deeper than that is folded up rather than guessed, because
477
+ * the depth counter degrades to a flat list on malformed JSX and a wrong tree is worse
478
+ * than a shallow one.
479
+ */
480
+ const tree = sorted
481
+ .filter((u) => u !== root)
482
+ .map((u) => ({ as: formFor(u.tag), part: partName(u.className) }));
483
+ return {
484
+ base: rootTaken?.base ?? {},
485
+ dark: rootTaken?.dark ?? {},
486
+ states: rootTaken?.states ?? {},
487
+ layers,
488
+ parts,
489
+ tree,
490
+ keyframes: read.keyframes,
491
+ read: count,
492
+ };
493
+ }
494
+ /** The html tag → the preview form. The same nine the anatomy contract accepts. */
495
+ function formFor(tag) {
496
+ if (/^(img|picture|video|canvas|svg)$/.test(tag))
497
+ return "image";
498
+ if (/^h[1-6]$/.test(tag))
499
+ return "heading";
500
+ if (/^(button|a)$/.test(tag))
501
+ return "button";
502
+ if (/^(input|textarea|select)$/.test(tag))
503
+ return "field";
504
+ if (/^(span|p|label|strong|em|small|li|td|th|dt|dd)$/.test(tag))
505
+ return "text";
506
+ if (/^(ul|ol|table|tbody|thead|tr|section|article|nav|aside|header|footer|form|main|div)$/.test(tag)) {
507
+ return "stack";
508
+ }
509
+ // A capitalised tag is a component of theirs, and the frontier reader decides what it
510
+ // reaches - not this.
511
+ return /^[A-Z]/.test(tag) ? "component" : "stack";
512
+ }
513
+ /**
514
+ * A declared custom property as a token ref in the document's namespace. Their name is
515
+ * the path, which is "your names travel unchanged" one level deeper.
516
+ */
517
+ function tokenRefFor(name) {
518
+ const bare = name.replace(/^--/, "");
519
+ if (bare.startsWith("color-")) {
520
+ const rest = bare.slice("color-".length);
521
+ const m = /^(.*)-(\d{2,4})$/.exec(rest);
522
+ return m ? `{color.${m[1]}.${m[2]}}` : `{color.${rest}}`;
523
+ }
524
+ if (bare.startsWith("radius-"))
525
+ return `{radius.${bare.slice(7)}}`;
526
+ if (bare.startsWith("spacing-"))
527
+ return `{spacing.${bare.slice(8)}}`;
528
+ if (bare.startsWith("shadow-"))
529
+ return `{shadow.${bare.slice(7)}}`;
530
+ if (bare.startsWith("text-"))
531
+ return `{typography.scale.${bare.slice(5)}.fontSize}`;
532
+ if (bare.startsWith("font-"))
533
+ return `{typography.families.${bare.slice(5)}}`;
534
+ return `var(${name})`;
535
+ }
@@ -235,6 +235,20 @@ export function readUtility(utility, declared) {
235
235
  const keyword = TYPE_KEYWORD[core];
236
236
  if (keyword)
237
237
  return keyword;
238
+ /**
239
+ * BORDER WIDTH - `border`, `border-2`, `border-b`, `border-t-4`.
240
+ *
241
+ * `border-b border-lightgray-300` on a real table row gave up the colour and lost the
242
+ * WIDTH, so the row reproduced its border colour and no border (spec, 01/08). Tailwind
243
+ * splits the two across separate utilities and only one of them was in a table.
244
+ */
245
+ const width = BORDER_WIDTH.exec(core);
246
+ if (width) {
247
+ const side = width[1] ? BORDER_SIDE[width[1]] : "";
248
+ const property = side ? `border${side}Width` : "borderWidth";
249
+ const value = width[2] ? `${width[2]}px` : "1px";
250
+ return { property, value };
251
+ }
238
252
  /**
239
253
  * AN ARBITRARY VALUE POINTING AT ONE OF THEIR OWN TOKENS.
240
254
  *
@@ -258,8 +272,22 @@ export function readUtility(utility, declared) {
258
272
  if (bracket !== -1 && core.endsWith("]")) {
259
273
  const head = core.slice(0, bracket);
260
274
  const inner = core.slice(bracket + 2, -1);
261
- const property = COLOR_PROPERTY[head] ?? SPACING_PROPERTY[head] ?? BRACKET_PROPERTY[head];
262
275
  const value = resolveArbitrary(inner, declared);
276
+ /**
277
+ * `text-[…]` IS AMBIGUOUS AND THE VALUE DECIDES.
278
+ *
279
+ * `text-[1.8em]` is a font size and `text-[#fff]` is a colour, and the head alone
280
+ * cannot tell them apart. Reading the head first put `fontSize: 1.8em` into `color`
281
+ * on a real `Text` component - a wrong value, which is worse than a missing one
282
+ * (probe, 01/08). The same ambiguity the scale branch below already resolves.
283
+ */
284
+ const property = head === "text"
285
+ ? value && LOOKS_LIKE_LENGTH.test(value.value)
286
+ ? "fontSize"
287
+ : COLOR_PROPERTY.text
288
+ : (COLOR_PROPERTY[head] ??
289
+ SPACING_PROPERTY[head] ??
290
+ BRACKET_PROPERTY[head]);
263
291
  if (property && value) {
264
292
  return { property, value: value.value, token: value.token };
265
293
  }
@@ -380,6 +408,19 @@ export function readUtility(utility, declared) {
380
408
  * `rounded-[…]` is a radius, `shadow-[…]` an elevation, `text-[…]` ambiguous by design
381
409
  * (a colour or a size) and resolved by what the value looks like.
382
410
  */
411
+ /** `border`, `border-2`, `border-b`, `border-b-2` - a width, never a colour. The colour
412
+ * is a separate utility (`border-ocean-500`) and the colour table already reads it. */
413
+ const BORDER_WIDTH = /^border(?:-([xytrbl]))?(?:-(\d+))?$/;
414
+ const BORDER_SIDE = {
415
+ t: "Top",
416
+ r: "Right",
417
+ b: "Bottom",
418
+ l: "Left",
419
+ x: "Inline",
420
+ y: "Block",
421
+ };
422
+ /** A length or a token ref to one - not a colour. `1.8em`, `20px`, `{typography.…}`. */
423
+ const LOOKS_LIKE_LENGTH = /^-?[\d.]+(?:px|rem|em|ch|ex|vw|vh|vmin|vmax|%|pt|cm|mm|in)$|^\{(?:typography|spacing|radius)\.|^(?:calc|clamp|min|max)\(/;
383
424
  const BRACKET_PROPERTY = {
384
425
  rounded: "borderRadius",
385
426
  shadow: "boxShadow",
@@ -454,6 +495,51 @@ function refFor(name) {
454
495
  * `group-data-[checked]:` value written into the resting state would be a look
455
496
  * the component never has.
456
497
  */
498
+ /**
499
+ * `style={{ … }}` - 7 of 35 components in a real library and 73 of 317 in a real app.
500
+ *
501
+ * A literal, so it re-themes nowhere - and that is exactly why it travels. v1 is a
502
+ * MIRROR and v2 is where a literal becomes a token they approve, which is the rule this
503
+ * pipeline already runs on everywhere else. The alternative is the component arriving
504
+ * empty, and an empty component is not more honest than a literal one (dono, 01/08).
505
+ *
506
+ * Only the FIRST inline style in the file, matching what `rootClasses` does: it is the
507
+ * element the component returns, and everything deeper is a part this reader does not
508
+ * attempt.
509
+ */
510
+ const INLINE_STYLE = /style=\{\{([^{}]*(?:\{[^{}]*\}[^{}]*)*)\}\}/;
511
+ export function readInlineStyle(source) {
512
+ const m = INLINE_STYLE.exec(source);
513
+ if (!m)
514
+ return {};
515
+ const out = {};
516
+ for (const piece of m[1].split(",")) {
517
+ const colon = piece.indexOf(":");
518
+ if (colon === -1)
519
+ continue;
520
+ const prop = piece
521
+ .slice(0, colon)
522
+ .trim()
523
+ .replace(/^["']|["']$/g, "");
524
+ let value = piece.slice(colon + 1).trim();
525
+ // A value computed at runtime is not in the source, so there is nothing to read.
526
+ if (!value || /[`$]|\?|=>|\(/.test(value))
527
+ continue;
528
+ value = value.replace(/^["']|["']$/g, "");
529
+ if (!/^[a-zA-Z][\w]*$/.test(prop))
530
+ continue;
531
+ if (/[<>{}@;]/.test(value))
532
+ continue;
533
+ // React accepts a bare number for a length property and means pixels.
534
+ if (/^-?\d+(\.\d+)?$/.test(value) && !UNITLESS.test(prop)) {
535
+ value = `${value}px`;
536
+ }
537
+ out[prop] = value;
538
+ }
539
+ return out;
540
+ }
541
+ /** Properties React leaves unitless, so a bare number is not pixels. */
542
+ const UNITLESS = /^(opacity|zIndex|flex|flexGrow|flexShrink|order|lineHeight|fontWeight|zoom|columnCount|fillOpacity|strokeOpacity|animationIterationCount)$/;
457
543
  export function transcribe(classes, declared) {
458
544
  const out = {
459
545
  base: {},
@@ -598,20 +684,3 @@ export function rootClasses(source) {
598
684
  .flatMap((m) => m[1].split(/\s+/))
599
685
  .filter((c) => c.length > 0 && !c.includes("${"));
600
686
  }
601
- /**
602
- * Transcribe the parts a skill named, using the same reader the root used.
603
- *
604
- * `declared` comes from the census itself on the `--census` path, so a run that
605
- * only sends a file it was handed still resolves their tokens by name.
606
- */
607
- export function transcribeParts(parts, declared) {
608
- const out = {};
609
- for (const part of parts) {
610
- const name = part.name?.trim();
611
- if (!name || typeof part.classes !== "string")
612
- continue;
613
- const t = transcribe(part.classes.split(/\s+/).filter(Boolean), declared);
614
- out[name] = { base: t.base, dark: t.dark, states: t.states };
615
- }
616
- return out;
617
- }
@@ -525,6 +525,116 @@ export function readInlineConditions(source) {
525
525
  }
526
526
  return layers;
527
527
  }
528
+ /**
529
+ * A LOOKUP RECORD - the shape 16 of 35 components in a real library use, against 2 for
530
+ * `cva`.
531
+ *
532
+ * const colorStyles: Record<TagColor, string> = {
533
+ * primary: "bg-ocean-500 text-white",
534
+ * danger: "bg-danger-400 text-white",
535
+ * };
536
+ * className={cn("inline-flex px-2.5 rounded-full", colorStyles[color], className)}
537
+ *
538
+ * As structured as `cva` and more common by five to one. The first version of this file
539
+ * read `cva` and the conditional expression - the two shapes I assumed - and covered 6%
540
+ * of the library. Enumerating first is the rule that produced this function (dono, 01/08).
541
+ *
542
+ * THE PROP COMES FROM THE INDEX, not from the record's name. `colorStyles[color]` says
543
+ * the axis is `color`; the variable could be called anything. When the index is not a
544
+ * bare identifier - `styles[key]`, `map[a ?? b]` - the axis is unknowable and the record
545
+ * is skipped rather than attached to a guess.
546
+ */
547
+ const LOOKUP_RECORD = /(?:const|let)\s+([A-Za-z_$][\w$]*)\s*(?::\s*(?:Record|Partial<Record)<[^>]*>+)?\s*=\s*\{/g;
548
+ /** `colorStyles[color]` - the record, and the prop that indexes it. */
549
+ const RECORD_INDEX = /([A-Za-z_$][\w$]*)\s*\[\s*([A-Za-z_$][\w$]*)\s*\]/g;
550
+ export function readLookupRecords(source,
551
+ /** Filled with axis → options, because the axis NAMES have to travel too: a first pass
552
+ * sent the styles and not the names, so a Tag with eight colours reported no axis at
553
+ * all (probe, 01/08). */
554
+ axesOut) {
555
+ const clean = stripComments(source);
556
+ // Which prop indexes which record. Read first, because a record nobody indexes is a
557
+ // constant rather than a variant axis.
558
+ const axisOf = new Map();
559
+ RECORD_INDEX.lastIndex = 0;
560
+ for (const m of clean.matchAll(RECORD_INDEX)) {
561
+ if (!axisOf.has(m[1]))
562
+ axisOf.set(m[1], m[2]);
563
+ }
564
+ const layers = [];
565
+ LOOKUP_RECORD.lastIndex = 0;
566
+ for (const m of clean.matchAll(LOOKUP_RECORD)) {
567
+ const symbol = m[1];
568
+ const axis = axisOf.get(symbol);
569
+ if (!axis)
570
+ continue;
571
+ const open = (m.index ?? 0) + m[0].length - 1;
572
+ const end = endOf(clean, open);
573
+ if (end === -1)
574
+ continue;
575
+ const entries = topLevelEntries(clean.slice(open + 1, end - 1));
576
+ // Two options is what makes a closed set somebody chose from - the same rule the
577
+ // contract writer and the doctor already follow.
578
+ const options = entries.filter((e) => literalClasses(e.value).length > 0);
579
+ if (options.length < 2)
580
+ continue;
581
+ if (axesOut) {
582
+ const names = options.map((o) => o.key);
583
+ axesOut[axis] = [...new Set([...(axesOut[axis] ?? []), ...names])];
584
+ }
585
+ for (const option of options) {
586
+ const per = splitByCondition(literalClasses(option.value), {
587
+ [axis]: option.key,
588
+ });
589
+ layers.push(...per.layers);
590
+ }
591
+ }
592
+ return layers;
593
+ }
594
+ /**
595
+ * A TERNARY BETWEEN TWO CLASS LITERALS - 8 of 35 in the same library.
596
+ *
597
+ * className={cn("base", isActive ? "bg-ocean-500" : "bg-transparent")}
598
+ *
599
+ * Both branches are a look, so both are a layer: the truthy one under the prop being
600
+ * `true` and the falsy one under `false`. Reading only the truthy half would say the
601
+ * component has one conditional look when it has two.
602
+ */
603
+ const CLASS_TERNARY = /([!A-Za-z_$][\w$.!]*)\s*(?:===\s*["']([^"']+)["']\s*)?\?\s*["']([^"']*)["']\s*:\s*["']([^"']*)["']/g;
604
+ export function readClassTernaries(source) {
605
+ const layers = [];
606
+ CLASS_TERNARY.lastIndex = 0;
607
+ for (const m of stripComments(source).matchAll(CLASS_TERNARY)) {
608
+ const negated = m[1].startsWith("!");
609
+ const prop = m[1].replace(/^!/, "").split(".").pop() ?? "";
610
+ if (!prop)
611
+ continue;
612
+ // `x === "solid" ? a : b` names the option; a bare `x ? a : b` is the boolean, and a
613
+ // negated condition swaps which branch means true.
614
+ const whenTrue = m[2] ?? (negated ? "false" : "true");
615
+ const whenFalse = m[2] ? null : negated ? "true" : "false";
616
+ for (const [option, raw] of [
617
+ [whenTrue, m[3]],
618
+ [whenFalse, m[4]],
619
+ ]) {
620
+ if (!option)
621
+ continue;
622
+ const classes = raw.split(/\s+/).filter(Boolean);
623
+ if (classes.length === 0)
624
+ continue;
625
+ const per = splitByCondition(classes, { [prop]: option });
626
+ layers.push(...per.layers);
627
+ }
628
+ }
629
+ return layers;
630
+ }
631
+ /**
632
+ * A LOOKUP INDEXED BY A TERNARY, which is how the same record serves a boolean.
633
+ *
634
+ * Left out on purpose, and said so rather than half-read: `styles[selected ? "on" : "off"]`
635
+ * would need both the record and the condition resolved together, and the enumeration
636
+ * found exactly four of them in 530 files. Four is not worth a wrong reading.
637
+ */
528
638
  /**
529
639
  * The whole reading of one file's variant styling, transcribed - ready for a recipe.
530
640
  *
@@ -533,7 +643,13 @@ export function readInlineConditions(source) {
533
643
  */
534
644
  export function transcribeVariants(source, declared) {
535
645
  const reads = readVariants(source);
536
- const inline = readInlineConditions(source);
646
+ const fromRecords = {};
647
+ const inline = [
648
+ ...readInlineConditions(source),
649
+ // The two shapes the enumeration found to be five times more common than `cva`.
650
+ ...readLookupRecords(source, fromRecords),
651
+ ...readClassTernaries(source),
652
+ ];
537
653
  const axes = {};
538
654
  const defaults = {};
539
655
  const allLayers = [];
@@ -553,6 +669,10 @@ export function transcribeVariants(source, declared) {
553
669
  }
554
670
  }
555
671
  allLayers.push(...inline);
672
+ for (const [axis, options] of Object.entries(fromRecords)) {
673
+ if (options.length >= 2)
674
+ axes[axis] = options;
675
+ }
556
676
  const layers = allLayers
557
677
  .map((layer) => {
558
678
  /**
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "synthesisui",
3
- "version": "0.16.81",
3
+ "version": "0.16.83",
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": {