synthesisui 0.16.101 → 0.16.102

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.
@@ -0,0 +1,492 @@
1
+ /**
2
+ * THE SKETCH BECOMES THE ANATOMY, WITHOUT ASKING ANYBODY.
3
+ *
4
+ * Measured on the owner's own census (dono, 02/08):
5
+ *
6
+ * medido 36/36 (100%) sketch with tag + depth + classes, 259 nodes
7
+ * carrying a class string
8
+ * convertido 0/36 ( 0%) `preview.parts` empty on every single recipe
9
+ *
10
+ * The tree only ever existed when the SKILL authored `reading.components[X].anatomy`
11
+ * by hand, one component at a time. A run that did not do that - which is every
12
+ * automated run - produced 35 recipes holding nothing but the root's own
13
+ * declarations. For `Modal` that is literally nothing: its root is a
14
+ * `<BaseDialog.Root>` with no classes, and its whole appearance (backdrop, popup,
15
+ * close) sits three levels down, measured, in the census, and thrown away.
16
+ *
17
+ * So this derives the anatomy from the sketch. Every input is a FACT the census
18
+ * already holds - the tag, the nesting depth, the class string, the package the
19
+ * tag was imported from - and nothing here is inferred from a component's name.
20
+ *
21
+ * IT EMITS THE SKILL'S OWN INPUT SHAPE (`AnatomyRead[]`), on purpose: `resolveAnatomy`
22
+ * then does the part naming, the transcription, the crosswalk, the frontier
23
+ * bookkeeping and the budget exactly as it does for an authored read. One new
24
+ * producer, no new consumer, and an authored read still wins where one exists.
25
+ *
26
+ * Classes travel by `at` (the sketch INDEX) rather than being copied out: a
27
+ * retyped ten-class string is where a `hover:` goes missing, and six components
28
+ * lost their states to exactly that (audit, dono, 01/08).
29
+ */
30
+ import { frontierOf, rendersNothing, } from "./frontier-kind.js";
31
+ /** An svg's insides are geometry, not anatomy: the glyph is the node. */
32
+ const SVG_INTERNALS = new Set([
33
+ "path",
34
+ "circle",
35
+ "rect",
36
+ "line",
37
+ "polyline",
38
+ "polygon",
39
+ "ellipse",
40
+ "g",
41
+ "defs",
42
+ "lineargradient",
43
+ "radialgradient",
44
+ "stop",
45
+ "clippath",
46
+ "mask",
47
+ "use",
48
+ "symbol",
49
+ "text",
50
+ "tspan",
51
+ "filter",
52
+ "feblend",
53
+ "animate",
54
+ ]);
55
+ /** Not a visual node at all: a script or style island, and `<head>` furniture. */
56
+ const NOT_VISUAL = new Set(["style", "script", "noscript", "template", "head"]);
57
+ /**
58
+ * The HTML tag → form table, and the name a tag earns.
59
+ *
60
+ * Both come from the element's own content model, which is why they can be a
61
+ * table: an `<h3>` is a heading in any library, and a `<td>` is a cell. The
62
+ * name is what the part is CALLED in the stylesheet, so it uses the vocabulary
63
+ * their own markup used rather than a word we picked.
64
+ */
65
+ const BY_TAG = {
66
+ img: { as: "image", name: "image" },
67
+ picture: { as: "image", name: "image" },
68
+ video: { as: "image", name: "media" },
69
+ canvas: { as: "image", name: "canvas" },
70
+ svg: { as: "icon", name: "icon" },
71
+ h1: { as: "heading", name: "title" },
72
+ h2: { as: "heading", name: "title" },
73
+ h3: { as: "heading", name: "title" },
74
+ h4: { as: "heading", name: "title" },
75
+ h5: { as: "heading", name: "title" },
76
+ h6: { as: "heading", name: "title" },
77
+ p: { as: "text", name: "text" },
78
+ small: { as: "text", name: "caption" },
79
+ strong: { as: "text", name: "text" },
80
+ em: { as: "text", name: "text" },
81
+ code: { as: "text", name: "code" },
82
+ pre: { as: "text", name: "code" },
83
+ blockquote: { as: "text", name: "quote" },
84
+ label: { as: "text", name: "label" },
85
+ legend: { as: "text", name: "legend" },
86
+ button: { as: "button", name: "action" },
87
+ a: { as: "button", name: "link" },
88
+ summary: { as: "button", name: "summary" },
89
+ input: { as: "field", name: "input" },
90
+ textarea: { as: "field", name: "input" },
91
+ select: { as: "field", name: "input" },
92
+ option: { as: "text", name: "option" },
93
+ table: { as: "stack", name: "table" },
94
+ thead: { as: "stack", name: "head" },
95
+ tbody: { as: "stack", name: "body" },
96
+ tfoot: { as: "stack", name: "foot" },
97
+ tr: { as: "row", name: "row" },
98
+ td: { as: "text", name: "cell" },
99
+ th: { as: "text", name: "column" },
100
+ caption: { as: "text", name: "caption" },
101
+ ul: { as: "stack", name: "list" },
102
+ ol: { as: "stack", name: "list" },
103
+ li: { as: "text", name: "item" },
104
+ dl: { as: "stack", name: "list" },
105
+ dt: { as: "text", name: "term" },
106
+ dd: { as: "text", name: "definition" },
107
+ form: { as: "stack", name: "form" },
108
+ fieldset: { as: "stack", name: "group" },
109
+ header: { as: "stack", name: "header" },
110
+ footer: { as: "stack", name: "footer" },
111
+ main: { as: "stack", name: "body" },
112
+ nav: { as: "row", name: "nav" },
113
+ aside: { as: "stack", name: "aside" },
114
+ section: { as: "stack" },
115
+ article: { as: "stack" },
116
+ details: { as: "stack", name: "details" },
117
+ figure: { as: "stack", name: "figure" },
118
+ figcaption: { as: "text", name: "caption" },
119
+ hr: { as: "icon", name: "divider" },
120
+ span: { as: "text", name: "text" },
121
+ div: { as: "stack" },
122
+ };
123
+ /**
124
+ * WHAT THE CALLER PUTS IN A SLOT, from the parent's content model.
125
+ *
126
+ * `expects` is meant to be their words, and a derived read has none - but an
127
+ * HTML content model is not a guess: the children of a `<tr>` ARE cells and the
128
+ * children of a `<tbody>` ARE rows. Where the model says nothing general, this
129
+ * says nothing rather than inventing a description.
130
+ */
131
+ const EXPECTS = {
132
+ tr: "the cells",
133
+ thead: "the rows",
134
+ tbody: "the rows",
135
+ table: "the rows",
136
+ ul: "the items",
137
+ ol: "the items",
138
+ dl: "the terms",
139
+ select: "the options",
140
+ optgroup: "the options",
141
+ nav: "the links",
142
+ };
143
+ /**
144
+ * Tags with no direction of their own: the client's classes decide.
145
+ *
146
+ * A `<tr>` is a row and a `<tbody>` is a stack whatever anybody writes on them -
147
+ * that is the table model, not a style. A `<div>` is neither until its own
148
+ * `flex-col` says so, and reading that from the class list is the whole reason
149
+ * `arranged` outranks any shape we would infer.
150
+ */
151
+ const NEUTRAL = new Set([
152
+ "div",
153
+ "section",
154
+ "article",
155
+ "form",
156
+ "fieldset",
157
+ "header",
158
+ "footer",
159
+ "main",
160
+ "aside",
161
+ "figure",
162
+ "nav",
163
+ "details",
164
+ ]);
165
+ /** `flex-col`, `grid` and `block` arrange DOWN; a bare `flex` arranges ACROSS. */
166
+ function arrangement(classes) {
167
+ const list = ` ${classes ?? ""} `;
168
+ if (/\s(flex-col|flex-col-reverse|grid|block|table)\s/.test(list))
169
+ return "stack";
170
+ if (/\s(flex|inline-flex|flex-row|flex-row-reverse)\s/.test(list))
171
+ return "row";
172
+ return "stack";
173
+ }
174
+ /** `ChevronDown` → `chevron-down`; `BaseDialog.Popup` → `popup`. */
175
+ function nameOfTag(tag) {
176
+ const member = tag.includes(".") ? (tag.split(".").pop() ?? tag) : tag;
177
+ return member
178
+ .replace(/([a-z0-9])([A-Z])/g, "$1-$2")
179
+ .toLowerCase()
180
+ .replace(/[^a-z0-9]+/g, "-")
181
+ .replace(/^-|-$/g, "");
182
+ }
183
+ /**
184
+ * A HEADLESS PRIMITIVE'S OWN MEMBER NAME, when it says what the thing is.
185
+ *
186
+ * `Root`, `Trigger`, `Popup`, `Panel`, `Backdrop`, `Item`, `Indicator`, `Thumb`
187
+ * is the shared vocabulary of every headless library, and it is the client's own
188
+ * word for that element - so it makes a better part name than anything we would
189
+ * pick, and a better form than "div with children".
190
+ */
191
+ const MEMBER_FORM = {
192
+ trigger: "button",
193
+ close: "button",
194
+ submit: "button",
195
+ arrow: "icon",
196
+ indicator: "icon",
197
+ thumb: "icon",
198
+ icon: "icon",
199
+ separator: "icon",
200
+ label: "text",
201
+ value: "text",
202
+ description: "text",
203
+ title: "heading",
204
+ input: "field",
205
+ control: "field",
206
+ text: "text",
207
+ caption: "text",
208
+ option: "text",
209
+ image: "image",
210
+ avatar: "image",
211
+ track: "stack",
212
+ range: "icon",
213
+ bar: "icon",
214
+ handle: "icon",
215
+ checkbox: "icon",
216
+ radio: "icon",
217
+ tab: "button",
218
+ backdrop: "stack",
219
+ overlay: "stack",
220
+ popup: "stack",
221
+ panel: "stack",
222
+ content: "stack",
223
+ list: "stack",
224
+ viewport: "stack",
225
+ group: "stack",
226
+ item: "row",
227
+ row: "row",
228
+ };
229
+ /**
230
+ * Derive the anatomy of ONE component from its sketch.
231
+ *
232
+ * `defines` is the census crosswalk (their name → the slug it reaches), used to
233
+ * tell their own library imported through an alias from a third party the table
234
+ * has never seen. See `frontierOf`.
235
+ *
236
+ * Node 0 is skipped: it is the element the component RETURNS, its classes are
237
+ * already the recipe's `base`, and emitting it again would draw the component
238
+ * inside a copy of itself.
239
+ */
240
+ export function anatomyFromSketch(sketch, defines,
241
+ /**
242
+ * Their manifest: a package name → the range they pinned. This is what tells
243
+ * `@frontend-hub/ui: workspace:*` (their own library, one folder over) apart
244
+ * from `recharts: ^2.12.0`, since both are spelled the same way. See
245
+ * `frontierOf`.
246
+ */
247
+ versionOf) {
248
+ const notes = [];
249
+ const libraries = [];
250
+ if (!Array.isArray(sketch) || sketch.length === 0)
251
+ return { read: [], notes, libraries };
252
+ /** Children of each index, from the depth column. */
253
+ const kids = sketch.map(() => []);
254
+ const roots = [];
255
+ const stack = [];
256
+ for (let i = 0; i < sketch.length; i++) {
257
+ const depth = Number(sketch[i]?.depth ?? 0);
258
+ while (stack.length > depth)
259
+ stack.pop();
260
+ const parent = stack.length > 0 ? stack[stack.length - 1] : -1;
261
+ if (parent >= 0)
262
+ kids[parent].push(i);
263
+ else
264
+ roots.push(i);
265
+ stack.push(i);
266
+ }
267
+ const build = (index, depth) => {
268
+ const node = sketch[index];
269
+ if (!node)
270
+ return [];
271
+ const tag = String(node.tag ?? "");
272
+ const lower = tag.toLowerCase();
273
+ const classes = node.classes?.trim() || undefined;
274
+ /**
275
+ * THE CHILDREN THAT DRAW SOMETHING. An `<svg>` full of `<path>`s is a glyph,
276
+ * not a stack of two shapes, and counting its geometry as content turned every
277
+ * inline icon into a container - their Tooltip's arrow previewed as a box
278
+ * holding a box (probe, 02/08).
279
+ */
280
+ const children = (kids[index] ?? []).filter((child) => {
281
+ const tag = String(sketch[child]?.tag ?? "").toLowerCase();
282
+ return !SVG_INTERNALS.has(tag) && !NOT_VISUAL.has(tag);
283
+ });
284
+ if (NOT_VISUAL.has(lower)) {
285
+ notes.push(`\`<${tag}>\` is a ${lower} island - its rules are real and this contract has no form that holds them`);
286
+ return [];
287
+ }
288
+ // An svg's insides are geometry: the glyph is the node, and it stops here.
289
+ if (SVG_INTERNALS.has(lower))
290
+ return [];
291
+ const capitalised = /^[A-Z]/.test(tag);
292
+ const kind = capitalised
293
+ ? frontierOf(tag, node.from, defines, versionOf)
294
+ : "own";
295
+ // The dependency, whatever we decide to draw - see `DerivedAnatomy.libraries`.
296
+ if (node.from &&
297
+ kind !== "own" &&
298
+ !libraries.some((l) => l.from === node.from))
299
+ libraries.push({ from: node.from, kind });
300
+ /**
301
+ * DECLARED IN THIS FILE, so there is no package and no recipe to point at.
302
+ *
303
+ * 20 of 181 capitalised nodes in the owner's library are this: `ToolbarButton`,
304
+ * `Shimmer`, `CustomLegend`. They are the client's own markup wearing the
305
+ * client's own classes, so they are DRAWN - the opposite of a frontier. The
306
+ * crosswalk would happily resolve `ToolbarButton` to `button` and turn a local
307
+ * helper into a reference to a component it is not.
308
+ */
309
+ const local = capitalised && !node.from;
310
+ /**
311
+ * A WRAPPER THAT RENDERS NOTHING passes its children up in its own place: a
312
+ * portal teleports and a provider is context. Keeping it would draw a box
313
+ * that does not exist in the client's output.
314
+ */
315
+ if ((kind === "motion" || kind === "headless") &&
316
+ rendersNothing(tag, classes)) {
317
+ return children.flatMap((child) => build(child, depth));
318
+ }
319
+ // `<AnimatePresence>` and friends: no element of their own.
320
+ if (kind === "motion" && !classes && children.length > 0) {
321
+ return children.flatMap((child) => build(child, depth));
322
+ }
323
+ const descend = () => children.flatMap((child) => build(child, depth + 1));
324
+ /**
325
+ * THEIR OWN COMPONENT, and a library that draws itself: a reference, and it
326
+ * stops. What their code nests INSIDE it is that component's content, which
327
+ * the frontier has no field for - counted rather than silently dropped.
328
+ */
329
+ if (kind === "own" && capitalised && !local) {
330
+ return [
331
+ {
332
+ as: "component",
333
+ ref: tag,
334
+ ...(classes ? { at: index } : {}),
335
+ ...(children.length > 0 ? { children: descend() } : {}),
336
+ },
337
+ ];
338
+ }
339
+ if (kind === "opaque") {
340
+ /**
341
+ * An opaque library draws its own insides, so anything nested under it in
342
+ * their code is a CHILD they passed to it - a legend, an axis, a fallback -
343
+ * and it is theirs. What we do not claim is the drawing.
344
+ */
345
+ return [
346
+ {
347
+ as: "external",
348
+ from: String(node.from ?? tag),
349
+ ...(classes ? { at: index } : {}),
350
+ ...(children.length > 0 ? { children: descend() } : {}),
351
+ },
352
+ ];
353
+ }
354
+ if (kind === "icon") {
355
+ return [{ as: "icon", name: nameOfTag(tag), at: index }];
356
+ }
357
+ /**
358
+ * EVERYTHING ELSE IS DRAWN: plain HTML, a headless primitive, and a
359
+ * component the file declares itself. All three wear the client's classes
360
+ * and all three are ours to reproduce.
361
+ */
362
+ const member = tag.includes(".")
363
+ ? (tag.split(".").pop() ?? "").toLowerCase()
364
+ : "";
365
+ const byTag = BY_TAG[lower];
366
+ const hasKids = children.length > 0;
367
+ let as;
368
+ let name;
369
+ if (capitalised) {
370
+ // A primitive's member name is the client's own word for the element.
371
+ name = nameOfTag(tag);
372
+ /**
373
+ * THE LAST WORD OF THE MEMBER IS THE NOUN. `ItemText` is text, `ItemIndicator`
374
+ * is an indicator, `ScrollUpArrow` is an arrow - every headless library
375
+ * compounds its member names the same way, and reading only the whole string
376
+ * made a Select's item label preview as a glyph (probe, 02/08).
377
+ */
378
+ const noun = name.split("-").pop() ?? name;
379
+ const form = MEMBER_FORM[member] ?? MEMBER_FORM[name] ?? MEMBER_FORM[noun];
380
+ if (form === "row" || form === "stack")
381
+ as = arrangement(classes);
382
+ else if (form)
383
+ as = form;
384
+ else
385
+ as = hasKids ? arrangement(classes) : node.text ? "text" : "icon";
386
+ }
387
+ else if (byTag) {
388
+ name = byTag.name;
389
+ if (NEUTRAL.has(lower)) {
390
+ /**
391
+ * A leaf box with no content of its own is a drawn SHAPE - a dot, a
392
+ * track, a divider - and `icon` is the form for "a bare shape with no
393
+ * text". An empty `stack` would preview as nothing at all.
394
+ */
395
+ as =
396
+ hasKids || node.slot
397
+ ? arrangement(classes)
398
+ : node.text
399
+ ? "text"
400
+ : "icon";
401
+ }
402
+ else
403
+ as = byTag.as;
404
+ }
405
+ else {
406
+ as = hasKids ? arrangement(classes) : "icon";
407
+ }
408
+ /**
409
+ * ANYTHING HOLDING ELEMENTS ARRANGES THEM - the contract gives children to
410
+ * `row` and `stack` only, and a leaf that has some gets them hoisted out to
411
+ * become its siblings, which destroys the nesting the client wrote.
412
+ *
413
+ * This is the same law the usage generator applies one level up: a trigger
414
+ * that wraps a heading is not a `<button>`, because a button may not contain
415
+ * one. The part is still called `trigger`, which is what carries the role.
416
+ */
417
+ if (hasKids && as !== "row" && as !== "stack")
418
+ as = arrangement(classes);
419
+ // A plain structural box earns a name only when it carries decisions - and
420
+ // it must earn one, because the name is what the class string travels on.
421
+ if (!name && classes)
422
+ name = "box";
423
+ /**
424
+ * A NODE WHOSE CONTENT IS THE CALLER'S is a slot, and it keeps its own name
425
+ * and classes - the border and the hover on a `<tr>{children}</tr>` are real
426
+ * and belong on that element.
427
+ */
428
+ if (node.slot && !hasKids) {
429
+ const only = EXPECTS[lower];
430
+ return [
431
+ {
432
+ as: "slot",
433
+ ...(name ? { name } : {}),
434
+ at: index,
435
+ ...(only ? { expects: only } : {}),
436
+ },
437
+ ];
438
+ }
439
+ const built = {
440
+ as,
441
+ ...(name ? { name } : {}),
442
+ ...(classes ? { at: index } : {}),
443
+ ...(node.text ? { text: node.text } : {}),
444
+ };
445
+ const inside = hasKids ? descend() : [];
446
+ /**
447
+ * MIXED CONTENT IS BOTH: their markup AND the caller's. A panel that renders
448
+ * a header and then `{children}` has two kinds of content and dropping either
449
+ * one is a hole somebody has to explain.
450
+ */
451
+ const expects = EXPECTS[lower];
452
+ const withSlot = node.slot
453
+ ? [...inside, { as: "slot", ...(expects ? { expects } : {}) }]
454
+ : inside;
455
+ if (withSlot.length > 0) {
456
+ built.children = withSlot;
457
+ if (as !== "row" && as !== "stack")
458
+ built.as = arrangement(classes);
459
+ }
460
+ return [built];
461
+ };
462
+ /**
463
+ * The component's own root is node 0 and its classes are the recipe's `base`.
464
+ * A root that renders nothing (a portal, a provider) hands its children over,
465
+ * which is how a Tooltip's popup reaches the tree at all.
466
+ *
467
+ * A component returning a FRAGMENT has several depth-0 elements and only the
468
+ * first one's classes became `base`, so the rest are emitted as siblings
469
+ * instead of being dropped with the fragment.
470
+ */
471
+ const top = roots.length > 0 ? roots[0] : 0;
472
+ const read = [
473
+ ...(kids[top] ?? []).flatMap((child) => build(child, 0)),
474
+ ...roots.slice(1).flatMap((sibling) => build(sibling, 0)),
475
+ ];
476
+ /**
477
+ * THE ROOT'S OWN `{children}` - the component whose entire content is the
478
+ * caller's.
479
+ *
480
+ * `<tr className="border-b hover:bg-ocean-50/30">{children}</tr>` is the whole
481
+ * of a real DataTableRow, and skipping node 0 skips the one thing it holds. Six
482
+ * components in the owner's library are exactly this shape, and they previewed
483
+ * as empty boxes: a Chat that says nothing about messages reads as a component
484
+ * we failed to extract (dono, 01/08).
485
+ */
486
+ const rootNode = sketch[top];
487
+ if (rootNode?.slot) {
488
+ const only = EXPECTS[String(rootNode.tag ?? "").toLowerCase()];
489
+ read.push({ as: "slot", ...(only ? { expects: only } : {}) });
490
+ }
491
+ return { read, notes, libraries };
492
+ }
@@ -56,7 +56,17 @@ const STRUCTURAL = /^(root|wrapper|base|el|outer)$/i;
56
56
  /** A pathological transcription of somebody's DOM, bounded. Six levels reaches
57
57
  * any real component; past that it is layout divs all the way down. */
58
58
  const MAX_DEPTH = 6;
59
- const MAX_NODES = 48;
59
+ /**
60
+ * The same ceiling the sketch measured, for the same reason.
61
+ *
62
+ * 48 was the budget for an anatomy somebody TYPED, where 48 nodes is a very long
63
+ * message. A DERIVED anatomy is as long as their component is, and the sketch
64
+ * already measured that ceiling on a real library: exactly one component passes
65
+ * 60 nodes (`TextEditor`, 67) and nothing passes 150. At 48 that component lost
66
+ * its editable region - the whole point of it - which is the same truncation the
67
+ * sketch cap was raised to stop (dono, 02/08).
68
+ */
69
+ const MAX_NODES = 150;
60
70
  /**
61
71
  * `Actions.Generate` → `actions-generate`, and never `actions.generate`.
62
72
  *
@@ -182,9 +192,21 @@ sketch) {
182
192
  const { as, coerced: wasCoerced } = formOf(node);
183
193
  if (wasCoerced)
184
194
  coerced += 1;
185
- // A FRONTIER carries a reference and nothing else. A name or a class list
186
- // on one would be us claiming styles that belong to somebody else.
195
+ /**
196
+ * A FRONTIER carries a reference, not a name and not a style block: the
197
+ * appearance there belongs to somebody else's recipe.
198
+ *
199
+ * WHAT IT DOES CARRY IS WHAT THEIR CODE PUTS INSIDE IT. `<Card>` wrapping a
200
+ * header and a chart is the parent's markup, wearing the parent's classes,
201
+ * and stopping at the name dropped 55 of 369 elements across a real library
202
+ * - 15% of its markup, including the whole visible content of two charts
203
+ * (probe, 02/08).
204
+ */
187
205
  if (FRONTIERS.has(as)) {
206
+ const held = Array.isArray(node.children)
207
+ ? walk(node.children, depth + 1)
208
+ : [];
209
+ const holds = held.length > 0 ? { children: held } : {};
188
210
  if (as === "component") {
189
211
  const name = String(node.ref ?? "").trim();
190
212
  if (!name)
@@ -204,6 +226,7 @@ sketch) {
204
226
  ref: slug || safePartName(name),
205
227
  ...(slug && slug !== safePartName(name) ? { refName: name } : {}),
206
228
  ...frontierClasses(node, sketch),
229
+ ...holds,
207
230
  });
208
231
  continue;
209
232
  }
@@ -218,6 +241,7 @@ sketch) {
218
241
  from,
219
242
  ...(version ? { version } : {}),
220
243
  ...frontierClasses(node, sketch),
244
+ ...holds,
221
245
  });
222
246
  continue;
223
247
  }
@@ -1,5 +1,6 @@
1
1
  import { mkdir, readdir, readFile, stat, writeFile } from "node:fs/promises";
2
2
  import { basename, dirname, join, relative } from "node:path";
3
+ import { anatomyFromSketch } from "../anatomy-from-sketch.js";
3
4
  import { resolveAnatomy, resolveFlatParts, safePartName, } from "../anatomy-read.js";
4
5
  import { readCredentials, readToken, resolveRegistry, sameRegistry, } from "../config.js";
5
6
  import { architectureRule, describeArchitecture, describeChoice, detectArchitectures, } from "../doctor/architecture.js";
@@ -421,6 +422,7 @@ export async function takeCensus(root, opts) {
421
422
  ...(esketch.length > 0 ? { sketch: esketch } : {}),
422
423
  ...et,
423
424
  ...(etag ? { rootTag: etag } : {}),
425
+ ...rootPackage(etag, esketch),
424
426
  };
425
427
  }
426
428
  }
@@ -526,6 +528,7 @@ export async function takeCensus(root, opts) {
526
528
  ...(Object.keys(parts).length > 0 ? { parts } : {}),
527
529
  ...(tree.length > 0 ? { tree } : {}),
528
530
  ...(tag ? { rootTag: tag } : {}),
531
+ ...rootPackage(tag, sketch),
529
532
  ...(layers.length > 0 ? { layers } : {}),
530
533
  ...(Object.keys(v.axes).length > 0 ? { declaredAxes: v.axes } : {}),
531
534
  ...(Object.keys(defaults).length > 0 ? { defaults } : {}),
@@ -1624,6 +1627,23 @@ function sayReach(c) {
1624
1627
  * Their declared tokens come from the census itself, so a run that only sends a
1625
1628
  * file it was handed still resolves a `bg-ocean-500` by their own name.
1626
1629
  */
1630
+ /**
1631
+ * THE PACKAGE THE ROOT TAG CAME FROM, read off the sketch that just measured it.
1632
+ *
1633
+ * The sketch stamps every capitalised tag with its own import specifier, and node
1634
+ * 0 IS the returned element - so this costs a lookup rather than a second parse.
1635
+ * The tag guard is there because a component whose root the sketch could not reach
1636
+ * (a return the scanner did not follow) must say nothing rather than borrow the
1637
+ * first node's package.
1638
+ */
1639
+ function rootPackage(tag, sketch) {
1640
+ if (!tag || !/^[A-Z]/.test(tag))
1641
+ return {};
1642
+ const first = sketch[0];
1643
+ return first && first.tag === tag && first.from
1644
+ ? { rootFrom: first.from }
1645
+ : {};
1646
+ }
1627
1647
  async function resolveReadParts(census, root) {
1628
1648
  const read = census.reading?.components;
1629
1649
  if (!read)
@@ -1658,27 +1678,72 @@ async function resolveReadParts(census, root) {
1658
1678
  crosswalked.set(c.name, safePartName(target));
1659
1679
  }
1660
1680
  const resolveRef = (name) => crosswalked.get(name) ?? null;
1661
- for (const [component, entry] of Object.entries(read)) {
1681
+ for (const component of Object.keys(looks)) {
1682
+ const entry = read[component];
1662
1683
  const anatomy = entry?.anatomy;
1684
+ /**
1685
+ * THE SKETCH IS THE ANATOMY WHEN NOBODY AUTHORED ONE.
1686
+ *
1687
+ * Measured on the owner's census before this existed: 36 of 36 components had
1688
+ * a sketch, 259 of their nodes carried a class string, and 0 of 36 recipes
1689
+ * carried a tree - because a tree only appeared when the skill wrote one out
1690
+ * component by component. `Modal` therefore shipped with an empty recipe: its
1691
+ * root is an unstyled `BaseDialog.Root` and its backdrop, popup and close
1692
+ * button are three levels below it (dono, 02/08).
1693
+ *
1694
+ * An AUTHORED read still wins. The skill can see the props, the prop types
1695
+ * and the call sites, so its names beat derived ones - this only fills the
1696
+ * silence, which was total.
1697
+ */
1698
+ const derived = Array.isArray(anatomy) && anatomy.length > 0
1699
+ ? null
1700
+ : anatomyFromSketch(looks[component]?.sketch, resolveRef, (pkg) => deps[pkg]);
1701
+ if (derived?.read.length) {
1702
+ for (const note of derived.notes)
1703
+ notes.add(note);
1704
+ }
1663
1705
  const resolved = Array.isArray(anatomy) && anatomy.length > 0
1664
1706
  ? resolveAnatomy(anatomy, declared, deps, resolveRef, entry?.root,
1665
1707
  // The component's own sketch, so a node naming itself by index reads
1666
1708
  // the exact class string the census measured (dono, 01/08).
1667
1709
  looks[component]?.sketch)
1668
- : Array.isArray(entry?.parts) && entry.parts.length > 0
1669
- ? resolveFlatParts(entry.parts, declared)
1670
- : null;
1710
+ : derived && derived.read.length > 0
1711
+ ? resolveAnatomy(derived.read, declared, deps, resolveRef, entry?.root, looks[component]?.sketch)
1712
+ : Array.isArray(entry?.parts) && entry.parts.length > 0
1713
+ ? resolveFlatParts(entry.parts, declared)
1714
+ : null;
1671
1715
  if (!resolved)
1672
1716
  continue;
1673
1717
  const hasParts = Object.keys(resolved.parts).length > 0;
1674
1718
  if (!hasParts && resolved.tree.length === 0)
1675
1719
  continue;
1676
1720
  const look = looks[component];
1721
+ /**
1722
+ * THE DEPENDENCIES, INCLUDING THE ONES WE DRAW OURSELVES.
1723
+ *
1724
+ * A headless primitive is drawn rather than named - right for the appearance,
1725
+ * a lie about the dependency. Their Modal reconstructed as divs looks the same
1726
+ * and has no focus trap and no escape key, so the package is declared with the
1727
+ * split said out loud: the pixels are theirs, the behaviour is the library's.
1728
+ */
1729
+ const pinned = deps ?? {};
1730
+ const libraries = [
1731
+ ...resolved.external,
1732
+ ...(derived?.libraries ?? [])
1733
+ .filter((lib) => !resolved.external.some((e) => e.from === lib.from))
1734
+ .map((lib) => ({
1735
+ from: lib.from,
1736
+ ...(pinned[lib.from] ? { version: pinned[lib.from] } : {}),
1737
+ ...(lib.kind === "headless" || lib.kind === "icon"
1738
+ ? { kind: lib.kind }
1739
+ : {}),
1740
+ })),
1741
+ ];
1677
1742
  const carried = {
1678
1743
  ...(hasParts ? { parts: resolved.parts } : {}),
1679
1744
  ...(resolved.tree.length > 0 ? { tree: resolved.tree } : {}),
1680
1745
  ...(resolved.composes.length > 0 ? { composes: resolved.composes } : {}),
1681
- ...(resolved.external.length > 0 ? { external: resolved.external } : {}),
1746
+ ...(libraries.length > 0 ? { external: libraries } : {}),
1682
1747
  ...(resolved.root ? { root: resolved.root } : {}),
1683
1748
  };
1684
1749
  looks[component] = look
@@ -1704,7 +1769,7 @@ async function resolveReadParts(census, root) {
1704
1769
  if (resolved.tree.length > 0)
1705
1770
  shaped += 1;
1706
1771
  edges += resolved.composes.length;
1707
- for (const e of resolved.external)
1772
+ for (const e of libraries)
1708
1773
  libs.add(e.from);
1709
1774
  for (const n of resolved.notes)
1710
1775
  notes.add(n);
@@ -0,0 +1,73 @@
1
+ /**
2
+ * WHERE EACH NAME IN A FILE CAME FROM.
3
+ *
4
+ * The sketch recorded `tag: "Dialog.Root"` and threw away the one fact that says
5
+ * what it is. `Dialog.Root` out of `@base-ui/react` is an unstyled div waiting
6
+ * for the client's classes; `Dialog.Root` out of a styled kit draws itself. Same
7
+ * identifier, opposite handling - so the package has to travel with the node, and
8
+ * the reader already has the file open when it could be stamping it.
9
+ *
10
+ * Regex over the import statements rather than a parse: an import is one of five
11
+ * shapes, all of them flat, and the reader is deliberately AST-free everywhere
12
+ * else in this module for the same reason (speed on a repo of 704 files).
13
+ */
14
+ /**
15
+ * `import X, { a, b as c } from "pkg"` / `import * as NS from "pkg"`.
16
+ *
17
+ * The two clause slots are the default and the named block, in either order, and
18
+ * `type` imports are skipped by the caller's own filter below - a type never
19
+ * appears as a tag.
20
+ */
21
+ const IMPORT = /import\s+(?!type\s)([\w$]+|\*\s+as\s+[\w$]+|\{[^}]*\})(?:\s*,\s*(\{[^}]*\}|[\w$]+))?\s+from\s*["']([^"']+)["']/g;
22
+ /**
23
+ * Every local name this file binds, mapped to the specifier it came from.
24
+ *
25
+ * An alias is stored under the LOCAL name, because the local name is what the
26
+ * JSX says: `import { Select as BaseSelect }` means the sketch's `BaseSelect.Item`
27
+ * has to find `@base-ui/react/select`.
28
+ */
29
+ export function importMap(source) {
30
+ const out = {};
31
+ IMPORT.lastIndex = 0;
32
+ let match = IMPORT.exec(source);
33
+ while (match) {
34
+ const pkg = match[3];
35
+ for (const clause of [match[1], match[2]]) {
36
+ if (!clause)
37
+ continue;
38
+ if (clause.startsWith("*")) {
39
+ const ns = clause.split(/\s+as\s+/)[1]?.trim();
40
+ if (ns)
41
+ out[ns] = pkg;
42
+ continue;
43
+ }
44
+ if (!clause.startsWith("{")) {
45
+ out[clause.trim()] = pkg;
46
+ continue;
47
+ }
48
+ for (const raw of clause.slice(1, -1).split(",")) {
49
+ const entry = raw.trim().replace(/^type\s+/, "");
50
+ if (!entry)
51
+ continue;
52
+ const [, local] = entry.split(/\s+as\s+/);
53
+ const name = (local ?? entry).trim();
54
+ if (name)
55
+ out[name] = pkg;
56
+ }
57
+ }
58
+ match = IMPORT.exec(source);
59
+ }
60
+ return out;
61
+ }
62
+ /**
63
+ * The specifier a TAG resolves to, or undefined when the file declares it itself.
64
+ *
65
+ * Undefined is a real answer and not a failure: 20 of 181 capitalised nodes in
66
+ * the owner's library are declared in the same file (`ToolbarButton`, `Shimmer`,
67
+ * `CustomLegend`), and those are the client's own markup wearing the client's own
68
+ * classes - which is the opposite of a third party.
69
+ */
70
+ export function packageOfTag(tag, imports) {
71
+ const head = tag.includes(".") ? tag.split(".")[0] : tag;
72
+ return imports[head];
73
+ }
@@ -19,7 +19,10 @@
19
19
  * same in an atomic library, a feature-foldered app, or a single flat directory.
20
20
  */
21
21
  import { scanTags } from "./css-modules.js";
22
+ import { importMap, packageOfTag } from "./imports.js";
22
23
  import { definitionSpan } from "./transcribe.js";
24
+ /** `{children}` / `{props.children}` - the caller's content, spelled either way. */
25
+ const CHILDREN = /\{\s*(?:props\.)?children\s*\}/;
23
26
  /**
24
27
  * Past this a component is a page in disguise, and the gate already said no.
25
28
  *
@@ -114,6 +117,12 @@ function staticCallClasses(body) {
114
117
  * and the slot form already says so.
115
118
  */
116
119
  export function sketchOf(source, name) {
120
+ /**
121
+ * READ THE IMPORTS BEFORE SLICING - they live at the top of the file, and the
122
+ * span below cuts them off. One map per file, so a 16-node component costs one
123
+ * pass over the header rather than sixteen lookups into the disk.
124
+ */
125
+ const imports = importMap(source);
117
126
  /**
118
127
  * ONE COMPONENT'S MARKUP, when the file declares more than one.
119
128
  *
@@ -141,6 +150,12 @@ export function sketchOf(source, name) {
141
150
  if (out.length >= MAX_NODES)
142
151
  break;
143
152
  const node = { tag: event.tag, depth };
153
+ // What a capitalised tag IS, from the file's own imports - see `SketchNode.from`.
154
+ if (/^[A-Z]/.test(event.tag)) {
155
+ const pkg = packageOfTag(event.tag, imports);
156
+ if (pkg)
157
+ node.from = pkg;
158
+ }
144
159
  const cls = CLASS_ATTR.exec(event.body);
145
160
  if (cls?.[1].trim())
146
161
  node.classes = cls[1].trim();
@@ -162,6 +177,9 @@ export function sketchOf(source, name) {
162
177
  !between.includes("<")) {
163
178
  node.text = between;
164
179
  }
180
+ // The caller's content, as a fact - see `SketchNode.slot`.
181
+ if (CHILDREN.test(between))
182
+ node.slot = true;
165
183
  }
166
184
  out.push(node);
167
185
  depth += 1;
@@ -0,0 +1,200 @@
1
+ /**
2
+ * WHAT A PACKAGE IS, SO A NODE KNOWS WHETHER WE CAN DRAW IT.
3
+ *
4
+ * The reader had exactly two answers for a capitalised element: `component` (one
5
+ * of theirs) or `external` (somebody else's, drawn as a named block and stopped).
6
+ * Measured across the owner's library - 372 sketch nodes, resolved against each
7
+ * file's own imports (dono, 02/08):
8
+ *
9
+ * 191 (51%) HTML
10
+ * 69 (18%) headless primitives @base-ui/react/* 36 carry classes
11
+ * 35 ( 9%) one of theirs @ui/lib/SignalUI/* 32 carry classes
12
+ * 34 ( 9%) icon glyphs lucide-react 13 carry classes
13
+ * 20 ( 5%) local to the file ToolbarButton, Shimmer 4 carry classes
14
+ * 20 ( 5%) genuinely opaque recharts, tiptap 5 carry classes
15
+ * 3 ( 0%) motion wrappers AnimatePresence 0 carry classes
16
+ *
17
+ * 122 of 372 nodes - a third of the library's markup - fell into `external` and
18
+ * stopped there. Seven components (20% of the library) had a headless primitive
19
+ * as their ROOT, so `collapsible-card` and `radio-card` carried six and eight
20
+ * real declarations behind a "3rd-party" badge and a chrome box.
21
+ *
22
+ * THE DISTINCTION IS NOT COSMETIC. A headless library ships behaviour and no
23
+ * appearance: every pixel of a Base-UI dialog is in the CLIENT's class list, so
24
+ * the whole visual truth is ours to read and draw. An opaque library ships the
25
+ * appearance itself: `<SketchPicker>` draws a colour wheel we cannot reproduce
26
+ * from a class string, and pretending otherwise would be inventing it.
27
+ *
28
+ * DECIDED BY PACKAGE, NEVER BY COMPONENT NAME. `Dialog.Root` from
29
+ * `@base-ui/react` and `Dialog.Root` from a styled kit are the same identifier
30
+ * and opposite answers, so the identifier cannot be the input. Nothing here
31
+ * matches a component name, which is what keeps it working on a library nobody
32
+ * here has seen.
33
+ */
34
+ /**
35
+ * Behaviour without appearance. Matched as a PREFIX, so `@base-ui/react/dialog`
36
+ * and `@radix-ui/react-dialog` both land without an entry each.
37
+ */
38
+ const HEADLESS = [
39
+ "@base-ui/react",
40
+ "@base-ui-components/react",
41
+ "@mui/base",
42
+ "radix-ui",
43
+ "@radix-ui/react-",
44
+ "@headlessui/",
45
+ "react-aria",
46
+ "@react-aria/",
47
+ "@react-stately/",
48
+ "@ariakit/",
49
+ "ariakit",
50
+ "@reach/",
51
+ "@zag-js/",
52
+ "@ark-ui/",
53
+ "vaul",
54
+ "cmdk",
55
+ "downshift",
56
+ "@tanstack/react-table",
57
+ "@tanstack/react-virtual",
58
+ ];
59
+ /** Glyph packages: one element, one path, a size and a colour. */
60
+ const ICONS = [
61
+ "lucide-react",
62
+ "lucide-preact",
63
+ "react-icons",
64
+ "react-feather",
65
+ "@radix-ui/react-icons",
66
+ "@heroicons/",
67
+ "@tabler/icons",
68
+ "@mui/icons-material",
69
+ "@ant-design/icons",
70
+ "@phosphor-icons/",
71
+ "phosphor-react",
72
+ "@fortawesome/",
73
+ "react-bootstrap-icons",
74
+ "@iconify/",
75
+ "iconoir-react",
76
+ ];
77
+ /** Animation wrappers whose own element is either absent or a plain div. */
78
+ const MOTION = [
79
+ "framer-motion",
80
+ "motion",
81
+ "@react-spring/",
82
+ "react-transition-group",
83
+ ];
84
+ /**
85
+ * A relative or aliased specifier is the client's own code, whatever it is
86
+ * called. `@/`, `~/` and `#` are the three alias spellings a bundler hands out.
87
+ */
88
+ function isPath(pkg) {
89
+ return (pkg.startsWith(".") ||
90
+ pkg.startsWith("/") ||
91
+ pkg.startsWith("@/") ||
92
+ pkg.startsWith("~") ||
93
+ pkg.startsWith("#"));
94
+ }
95
+ const startsWithAny = (pkg, list) => list.some((entry) => pkg === entry || pkg.startsWith(entry));
96
+ /**
97
+ * The kind of a package specifier, from the table and nothing else.
98
+ *
99
+ * An UNKNOWN bare specifier answers `opaque`, because that is what it is: a
100
+ * third-party package we have no reason to believe we can draw. Their own
101
+ * library imported through a tsconfig alias (`@ui/lib/SignalUI/atoms/Card`) also
102
+ * lands here, and the caller corrects it with the one fact that settles it - the
103
+ * census crosswalk already knows whether the NAME is a component they define.
104
+ * See `frontierOf`.
105
+ */
106
+ export function frontierKind(pkg) {
107
+ const id = (pkg ?? "").trim();
108
+ if (!id)
109
+ return "own";
110
+ if (isPath(id))
111
+ return "own";
112
+ if (startsWithAny(id, ICONS))
113
+ return "icon";
114
+ if (startsWithAny(id, HEADLESS))
115
+ return "headless";
116
+ if (startsWithAny(id, MOTION))
117
+ return "motion";
118
+ return "opaque";
119
+ }
120
+ /** `@scope/name/sub` → `@scope/name`; `name/sub` → `name`. */
121
+ function packageRoot(pkg) {
122
+ const parts = pkg.split("/");
123
+ return pkg.startsWith("@") ? parts.slice(0, 2).join("/") : parts[0];
124
+ }
125
+ /**
126
+ * A range that means "this is in this repository": a workspace sibling, a linked
127
+ * folder, a tarball on disk. The one fact that tells their own published library
128
+ * apart from a third party, since both are spelled `@scope/name`.
129
+ */
130
+ const LOCAL_RANGE = /^(workspace:|file:|link:|portal:)/;
131
+ /**
132
+ * The kind of ONE ELEMENT: the table, then their manifest, then the crosswalk.
133
+ *
134
+ * ORDER IS THE WHOLE TRICK, and getting it wrong is a bug a spec caught here
135
+ * before a person did. `Tooltip` is a component they define AND a component
136
+ * `recharts` exports, and their PieChart imports the recharts one - so a
137
+ * crosswalk consulted too early claims a third-party chart tooltip as theirs.
138
+ *
139
+ * So the question the manifest answers comes first: is this specifier a package
140
+ * they installed, or a path into their own source? `recharts: ^2.12.0` is a third
141
+ * party; `@frontend-hub/ui: workspace:*` is their own library one folder over;
142
+ * `@ui/lib/SignalUI/atoms/Card` is not in the manifest at all, because it is a
143
+ * tsconfig alias. Only then does the crosswalk get asked.
144
+ *
145
+ * `defines` is the census's crosswalk: their name → the slug it reaches, or null.
146
+ * `versionOf` is their manifest: a package name → the range they pinned.
147
+ */
148
+ export function frontierOf(tag, pkg, defines, versionOf) {
149
+ const kind = frontierKind(pkg);
150
+ if (kind !== "opaque" || !pkg)
151
+ return kind;
152
+ const head = tag.includes(".") ? tag.split(".")[0] : tag;
153
+ const version = versionOf?.(packageRoot(pkg));
154
+ if (version)
155
+ return LOCAL_RANGE.test(version) ? "own" : "opaque";
156
+ /**
157
+ * NO MANIFEST ANSWER, so the specifier's own shape is what is left. An alias
158
+ * into their source addresses a FILE (`@ui/lib/SignalUI/atoms/Card`); a package
159
+ * they never declared addresses a package (`recharts`, `@scope/name`). Asking
160
+ * the crosswalk only for the first shape is what keeps `Tooltip` honest when
161
+ * the manifest was unreadable.
162
+ */
163
+ const deep = pkg.split("/").length > (pkg.startsWith("@") ? 2 : 1);
164
+ return deep && defines?.(head) ? "own" : "opaque";
165
+ }
166
+ /**
167
+ * Members of a headless namespace that render NOTHING - a portal, a positioner,
168
+ * a context provider.
169
+ *
170
+ * This is the one place a member NAME is read, and it is the shared vocabulary
171
+ * of every headless library rather than one library's private spelling: Base-UI,
172
+ * Radix, Ark and Ariakit all spell a portal `Portal`. It is also gated on the
173
+ * node carrying no classes at all, so a `Positioner` the client styled survives
174
+ * as a real box - which is exactly what happens in their Select.
175
+ */
176
+ const NON_RENDERING = new Set([
177
+ "portal",
178
+ "positioner",
179
+ "provider",
180
+ "presence",
181
+ "animatepresence",
182
+ "anchor",
183
+ "fragment",
184
+ /**
185
+ * `Root` earns its place here only when it carries nothing. A headless `Root`
186
+ * is sometimes the visible surface - their `Collapsible.Root` holds the card's
187
+ * six declarations - and sometimes a bare context wrapper, as their
188
+ * `BaseSelect.Root` is. The class list is what separates the two, and the gate
189
+ * above reads exactly that: styled roots survive, empty ones step aside rather
190
+ * than becoming a part literally named `root`.
191
+ */
192
+ "root",
193
+ ]);
194
+ /** `BaseDialog.Portal` → true. `BaseSelect.Positioner` with `z-50 pt-1` → false. */
195
+ export function rendersNothing(tag, classes) {
196
+ if (classes?.trim())
197
+ return false;
198
+ const member = tag.includes(".") ? tag.split(".").pop() : tag;
199
+ return NON_RENDERING.has((member ?? "").toLowerCase());
200
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "synthesisui",
3
- "version": "0.16.101",
3
+ "version": "0.16.102",
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": {