synthesisui 0.16.101 → 0.16.103

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,9 +1627,39 @@ 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
- const read = census.reading?.components;
1629
- if (!read)
1648
+ /**
1649
+ * A READING IS OPTIONAL HERE. THE SKETCH IS NOT.
1650
+ *
1651
+ * This returned on line one when nobody had authored a reading, and a DERIVED
1652
+ * anatomy needs no reading at all - so the deriver shipped and never ran on the
1653
+ * path the owner actually uses. Measured on his own re-import (02/08): the census
1654
+ * carried `rootFrom` on 13 looks and `from` on 162 sketch nodes - the new fields
1655
+ * arrived - and `tree` on 0 of 36. Two components then read as empty contracts
1656
+ * and came back below the floor.
1657
+ *
1658
+ * "Medido 100%, convertido 0%", one week after the law that names it. The guard
1659
+ * is now about having something to convert, not about who asked.
1660
+ */
1661
+ const read = census.reading?.components ?? {};
1662
+ if (!census.looks || Object.keys(census.looks).length === 0)
1630
1663
  return;
1631
1664
  const declared = new Map(Object.entries(census.declared));
1632
1665
  // The manifest is read ONCE for the whole run: the version belongs in a rule's
@@ -1658,27 +1691,72 @@ async function resolveReadParts(census, root) {
1658
1691
  crosswalked.set(c.name, safePartName(target));
1659
1692
  }
1660
1693
  const resolveRef = (name) => crosswalked.get(name) ?? null;
1661
- for (const [component, entry] of Object.entries(read)) {
1694
+ for (const component of Object.keys(looks)) {
1695
+ const entry = read[component];
1662
1696
  const anatomy = entry?.anatomy;
1697
+ /**
1698
+ * THE SKETCH IS THE ANATOMY WHEN NOBODY AUTHORED ONE.
1699
+ *
1700
+ * Measured on the owner's census before this existed: 36 of 36 components had
1701
+ * a sketch, 259 of their nodes carried a class string, and 0 of 36 recipes
1702
+ * carried a tree - because a tree only appeared when the skill wrote one out
1703
+ * component by component. `Modal` therefore shipped with an empty recipe: its
1704
+ * root is an unstyled `BaseDialog.Root` and its backdrop, popup and close
1705
+ * button are three levels below it (dono, 02/08).
1706
+ *
1707
+ * An AUTHORED read still wins. The skill can see the props, the prop types
1708
+ * and the call sites, so its names beat derived ones - this only fills the
1709
+ * silence, which was total.
1710
+ */
1711
+ const derived = Array.isArray(anatomy) && anatomy.length > 0
1712
+ ? null
1713
+ : anatomyFromSketch(looks[component]?.sketch, resolveRef, (pkg) => deps[pkg]);
1714
+ if (derived?.read.length) {
1715
+ for (const note of derived.notes)
1716
+ notes.add(note);
1717
+ }
1663
1718
  const resolved = Array.isArray(anatomy) && anatomy.length > 0
1664
1719
  ? resolveAnatomy(anatomy, declared, deps, resolveRef, entry?.root,
1665
1720
  // The component's own sketch, so a node naming itself by index reads
1666
1721
  // the exact class string the census measured (dono, 01/08).
1667
1722
  looks[component]?.sketch)
1668
- : Array.isArray(entry?.parts) && entry.parts.length > 0
1669
- ? resolveFlatParts(entry.parts, declared)
1670
- : null;
1723
+ : derived && derived.read.length > 0
1724
+ ? resolveAnatomy(derived.read, declared, deps, resolveRef, entry?.root, looks[component]?.sketch)
1725
+ : Array.isArray(entry?.parts) && entry.parts.length > 0
1726
+ ? resolveFlatParts(entry.parts, declared)
1727
+ : null;
1671
1728
  if (!resolved)
1672
1729
  continue;
1673
1730
  const hasParts = Object.keys(resolved.parts).length > 0;
1674
1731
  if (!hasParts && resolved.tree.length === 0)
1675
1732
  continue;
1676
1733
  const look = looks[component];
1734
+ /**
1735
+ * THE DEPENDENCIES, INCLUDING THE ONES WE DRAW OURSELVES.
1736
+ *
1737
+ * A headless primitive is drawn rather than named - right for the appearance,
1738
+ * a lie about the dependency. Their Modal reconstructed as divs looks the same
1739
+ * and has no focus trap and no escape key, so the package is declared with the
1740
+ * split said out loud: the pixels are theirs, the behaviour is the library's.
1741
+ */
1742
+ const pinned = deps ?? {};
1743
+ const libraries = [
1744
+ ...resolved.external,
1745
+ ...(derived?.libraries ?? [])
1746
+ .filter((lib) => !resolved.external.some((e) => e.from === lib.from))
1747
+ .map((lib) => ({
1748
+ from: lib.from,
1749
+ ...(pinned[lib.from] ? { version: pinned[lib.from] } : {}),
1750
+ ...(lib.kind === "headless" || lib.kind === "icon"
1751
+ ? { kind: lib.kind }
1752
+ : {}),
1753
+ })),
1754
+ ];
1677
1755
  const carried = {
1678
1756
  ...(hasParts ? { parts: resolved.parts } : {}),
1679
1757
  ...(resolved.tree.length > 0 ? { tree: resolved.tree } : {}),
1680
1758
  ...(resolved.composes.length > 0 ? { composes: resolved.composes } : {}),
1681
- ...(resolved.external.length > 0 ? { external: resolved.external } : {}),
1759
+ ...(libraries.length > 0 ? { external: libraries } : {}),
1682
1760
  ...(resolved.root ? { root: resolved.root } : {}),
1683
1761
  };
1684
1762
  looks[component] = look
@@ -1704,15 +1782,19 @@ async function resolveReadParts(census, root) {
1704
1782
  if (resolved.tree.length > 0)
1705
1783
  shaped += 1;
1706
1784
  edges += resolved.composes.length;
1707
- for (const e of resolved.external)
1785
+ for (const e of libraries)
1708
1786
  libs.add(e.from);
1709
1787
  for (const n of resolved.notes)
1710
1788
  notes.add(n);
1711
1789
  }
1712
- const total = Object.keys(read).length;
1790
+ const authored = Object.keys(read).length;
1791
+ const total = authored || Object.keys(census.looks ?? {}).length;
1713
1792
  if (named > 0 || shaped > 0) {
1714
1793
  console.log("");
1715
- console.log(body(`${named} part${named === 1 ? "" : "s"} your reading named across ${total} component${total === 1 ? "" : "s"} - each previews as what it IS rather than as a text box`));
1794
+ console.log(body(
1795
+ // "Your reading" only when there WAS one - otherwise the names came off
1796
+ // their own markup, and claiming otherwise credits somebody's absent work.
1797
+ `${named} part${named === 1 ? "" : "s"} named across ${total} component${total === 1 ? "" : "s"}${authored > 0 ? " - your reading and their markup" : " - read off their own markup"}, so each previews as what it IS rather than as a text box`));
1716
1798
  }
1717
1799
  if (shaped > 0) {
1718
1800
  console.log(body(`${shaped} of them arrived with a SHAPE - the parts nested as they nest in your code, so the preview is the component and not a row of labels`));
@@ -1824,6 +1906,20 @@ export async function runImport(opts) {
1824
1906
  if (scope)
1825
1907
  census.scope = scope;
1826
1908
  }
1909
+ /**
1910
+ * DERIVE THE ANATOMY ON THE MEASURING PATH TOO.
1911
+ *
1912
+ * The send path resolves the reading above; a plain `--dry` measure never
1913
+ * reached that function, so the census on disk - the file the skill reads to
1914
+ * author its reading, and the file every one of our real-repo specs reads -
1915
+ * carried a sketch for every component and a tree for none. The person's own
1916
+ * re-import proved it: 36 sketches, 162 stamped nodes, 0 trees (dono, 02/08).
1917
+ *
1918
+ * Written into the census means the skill sees the shape it would otherwise
1919
+ * have to author from scratch, and can spend its judgement on the names.
1920
+ */
1921
+ if (!opts.census)
1922
+ await resolveReadParts(census, root);
1827
1923
  summarize(census);
1828
1924
  // Only when nobody has scoped yet. Telling someone to scope to the folder
1829
1925
  // they just scoped to reads as the tool not having noticed.
@@ -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,54 @@
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*\}/;
26
+ /**
27
+ * THE TEXT THAT SITS DIRECTLY INSIDE ONE ELEMENT - its own gaps, not its
28
+ * children's contents.
29
+ *
30
+ * The first version of the slot check read only the span between an element's
31
+ * open tag and the next tag of any kind, so it saw `{children}` only when it was
32
+ * the FIRST thing inside. A real panel renders a header and THEN the caller's
33
+ * content, which is the mixed case the reader has a form for - and it was
34
+ * invisible. Found by driving `runImport` over a Base-UI dialog whose `{children}`
35
+ * follows its close button (02/08).
36
+ *
37
+ * Attribution matters as much as detection: `{children}` inside a CHILD is that
38
+ * child's slot, so the walk collects the gaps at this element's own level and
39
+ * skips whole subtrees.
40
+ */
41
+ function directText(source, events, index) {
42
+ const open = events[index];
43
+ const openEnd = source.indexOf(">", open.at);
44
+ if (openEnd === -1)
45
+ return "";
46
+ let from = openEnd + 1;
47
+ const gaps = [];
48
+ let rel = 0;
49
+ for (let j = index + 1; j < events.length; j++) {
50
+ const event = events[j];
51
+ if (event.kind === "open") {
52
+ if (rel === 0)
53
+ gaps.push(source.slice(from, event.at));
54
+ rel += 1;
55
+ continue;
56
+ }
57
+ rel -= 1;
58
+ // Our own close: the last gap runs up to it, and the element ends here.
59
+ if (rel < 0) {
60
+ gaps.push(source.slice(from, event.at));
61
+ break;
62
+ }
63
+ if (rel === 0) {
64
+ const closeEnd = source.indexOf(">", event.at);
65
+ from = closeEnd === -1 ? event.at : closeEnd + 1;
66
+ }
67
+ }
68
+ return gaps.join(" ");
69
+ }
23
70
  /**
24
71
  * Past this a component is a page in disguise, and the gate already said no.
25
72
  *
@@ -114,6 +161,12 @@ function staticCallClasses(body) {
114
161
  * and the slot form already says so.
115
162
  */
116
163
  export function sketchOf(source, name) {
164
+ /**
165
+ * READ THE IMPORTS BEFORE SLICING - they live at the top of the file, and the
166
+ * span below cuts them off. One map per file, so a 16-node component costs one
167
+ * pass over the header rather than sixteen lookups into the disk.
168
+ */
169
+ const imports = importMap(source);
117
170
  /**
118
171
  * ONE COMPONENT'S MARKUP, when the file declares more than one.
119
172
  *
@@ -141,6 +194,12 @@ export function sketchOf(source, name) {
141
194
  if (out.length >= MAX_NODES)
142
195
  break;
143
196
  const node = { tag: event.tag, depth };
197
+ // What a capitalised tag IS, from the file's own imports - see `SketchNode.from`.
198
+ if (/^[A-Z]/.test(event.tag)) {
199
+ const pkg = packageOfTag(event.tag, imports);
200
+ if (pkg)
201
+ node.from = pkg;
202
+ }
144
203
  const cls = CLASS_ATTR.exec(event.body);
145
204
  if (cls?.[1].trim())
146
205
  node.classes = cls[1].trim();
@@ -163,6 +222,11 @@ export function sketchOf(source, name) {
163
222
  node.text = between;
164
223
  }
165
224
  }
225
+ // The caller's content, as a fact - see `SketchNode.slot`. Read from the
226
+ // element's DIRECT text, so a `{children}` after a header still counts and one
227
+ // inside a child belongs to that child.
228
+ if (CHILDREN.test(directText(source, events, i)))
229
+ node.slot = true;
166
230
  out.push(node);
167
231
  depth += 1;
168
232
  }
@@ -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
+ }
@@ -148,11 +148,11 @@ file to pick between. Nothing is sent by \`--dry\`. Read the file.
148
148
  ### 2. Walk them through the decisions - ALL of them, before any reading
149
149
 
150
150
  **Every question this skill asks is answerable from the census you just took.** Source, name,
151
- primary, theme, canvas, our-kit-or-theirs, architecture - the numbers behind all seven are in
152
- the file on their disk, and none of them needs the anatomy.
151
+ primary, theme, canvas, architecture - the numbers behind all six are in the file on their disk,
152
+ and none of them needs the anatomy.
153
153
 
154
154
  They used to be asked AFTER the reading, and that put six hundred lines of work between the
155
- person's first answer and their second (dono, 01/08). A person who answers seven questions in
155
+ person's first answer and their second (dono, 01/08). A person who answers six questions in
156
156
  ninety seconds and then watches a bar has had a good four minutes; the same person interrupted
157
157
  twice in the middle of a silence has had a bad twenty.
158
158
 
@@ -160,7 +160,7 @@ So: ask everything here, in one pass, and **do not come back**. If something you
160
160
  the reading contradicts an answer, you do not reopen the question - you say so in the report at
161
161
  the end and let them change it there.
162
162
 
163
- Seven decisions are theirs. **Ask them as separate questions with selectable options** - use your
163
+ Six decisions are theirs. **Ask them as separate questions with selectable options** - use your
164
164
  question tool, one call per decision, so they pick instead of reading a wall and composing a
165
165
  reply. A single block containing everything is a report, and a report gets read, not answered.
166
166
 
@@ -244,35 +244,37 @@ canvas #222326 darkgray-500 - what DashboardLayout paints on <main>
244
244
  Say it and let them object. Offering five near-blacks to pick between is asking someone to
245
245
  re-decide something their code already decided.
246
246
 
247
- **2f. Our kit, or theirs alone** - ask this ONLY when they already have a component library,
248
- which is when \`--scope\` pointed at one and the CLI said so. An app with no components of its own
249
- has nothing to weigh and should never see the question.
247
+ **2f. Our kit** - do not ask. A project that already has a component library gets **theirs
248
+ alone**, and you say so in one line:
250
249
 
251
250
  \`\`\`
252
- Every system born here comes with 45 components of ours - button, card, input,
253
- table, the rest of a working toolkit. You already have 37.
254
-
255
- add ours to yours (recommended) 68 components, and the 23 governed by your
256
- own rules are the 23 you wrote
257
- mine alone 37 components, all of them yours. Our
258
- layouts that need a component you do not
259
- have stay out too. Add any of ours later
260
- from the library, one at a time
251
+ This system is yours alone - the 37 components you wrote, and none of ours.
252
+ Add any of ours later from the library, one at a time.
261
253
  \`\`\`
262
254
 
263
- Send it as \`"standards": "none"\` in the reading when they choose the second, and leave the field
264
- out when they choose the first. **Recommend adding ours**, and say why in one line: a component of
265
- ours costs nothing until something composes it, and having a \`table\` the day somebody needs a
266
- table is worth more than a shorter list.
255
+ Send it as \`"standards": "none"\` in the reading. Every time, with one exception below.
256
+
257
+ **Why this stopped being a question.** Somebody who points us at their own library is telling us
258
+ what their system is, and 45 components of ours landing beside 37 of theirs makes a system of 82
259
+ where only 37 are governed by anything they wrote - the number on their own screen stops being
260
+ about them. The kit is one click away in the library afterwards, so choosing wrong here costs a
261
+ click and choosing to ask costs a pause in the middle of their import.
262
+
263
+ **The exception, and it is not a preference.** An app with NO components of its own - \`--scope\`
264
+ pointed at an app rather than a library, and the CLI reported no component library - must NOT get
265
+ \`"standards": "none"\`: theirs alone plus nothing of theirs is a system with zero components. Leave
266
+ the field out there, exactly as before.
267
267
 
268
268
  Two things to be straight about, because both are true and neither is obvious:
269
269
 
270
- - **the foundations come from the seed either way.** Their palette, their scales, their type - all
270
+ - **the foundations still come from the seed.** Their palette, their scales, their type - all
271
271
  theirs - but the seven type slots, the easing and the neutral floor arrive from ours, because
272
272
  the alternative to a type scale is no components at all rather than their components.
273
273
  - **our layouts are written in our recipes.** A landing that composes \`button\`, \`card\` and
274
- \`badge\` needs those to exist. Choosing *mine alone* keeps the layouts whose recipes they
275
- happen to have and drops the rest - a layout pointing at a recipe nobody has renders as holes.
274
+ \`badge\` needs those to exist. Theirs alone keeps the layouts whose recipes they happen to have
275
+ and drops the rest - a layout pointing at a recipe nobody has renders as holes. Say that number
276
+ when the CLI reports it, so a shorter list of layouts is a fact they were told rather than one
277
+ they discover.
276
278
 
277
279
  **2g. Which architecture has priority** - ask only when the CLI reported more than
278
280
  one, which it does under "How this project is organised".
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "synthesisui",
3
- "version": "0.16.101",
3
+ "version": "0.16.103",
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": {