synthesisui 0.16.65 → 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.
@@ -10,6 +10,7 @@ import { describeConvention, detectConventions, IDIOM_LABEL, } from "../doctor/i
10
10
  import { diagnose, scanSource } from "../doctor/scan.js";
11
11
  import { parseSchemeBlocks } from "../doctor/scheme-blocks.js";
12
12
  import { buildTable } from "../doctor/tokens.js";
13
+ import { rootClasses, transcribe, } from "../doctor/transcribe.js";
13
14
  import { body, paint, section } from "../output.js";
14
15
  import { walk, walkAll } from "./doctor.js";
15
16
  /**
@@ -278,6 +279,14 @@ export async function takeCensus(root) {
278
279
  // Kept for the reference check below, which needs every file at once: a name
279
280
  // is only broken if NOTHING declares it, anywhere.
280
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 = {};
281
290
  for await (const file of walk(root)) {
282
291
  const src = await readFile(file, "utf8").catch(() => "");
283
292
  if (!src)
@@ -292,7 +301,20 @@ export async function takeCensus(root) {
292
301
  scanComponentsInto(tally, rel, src, internal);
293
302
  // The other half: what this file EXPORTS, with the axes its types
294
303
  // declare. A library composes almost nothing and exports everything.
295
- 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
+ }
296
318
  }
297
319
  }
298
320
  const d = diagnose(reports);
@@ -389,6 +411,7 @@ export async function takeCensus(root) {
389
411
  declared: Object.fromEntries(table.byName),
390
412
  ...(brokenRefs.length > 0 ? { brokenRefs } : {}),
391
413
  ...(conventions.length > 0 ? { conventions } : {}),
414
+ ...(Object.keys(looks).length > 0 ? { looks } : {}),
392
415
  ...(schemes.alt.size > 0
393
416
  ? { declaredAlt: Object.fromEntries(schemes.alt) }
394
417
  : {}),
@@ -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.65",
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": {