synthesisui 0.16.119 → 0.16.121

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.
@@ -317,27 +317,30 @@ versionOf) {
317
317
  * somebody pasting the snippet got a card that never opens (dono, 03/08).
318
318
  *
319
319
  * So it keeps its place in the tree with no `name` - no part, no styles, nothing for
320
- * the Canvas to draw, and an element the snippet emits. A motion wrapper stays
321
- * flattened: it is animation, and its element is genuinely absent from the output.
320
+ * the Canvas to draw, and an element the snippet emits.
321
+ *
322
+ * A MOTION WRAPPER IS THE SAME CASE, and it was flattened on the grounds that its
323
+ * element is "genuinely absent from the output". True of the Canvas, which draws no box
324
+ * for it; false of the code, where `AnimatePresence` is the difference between an
325
+ * `exit` animation that runs and one that cannot. Their panel carries
326
+ * `exit={{ height: 0 }}`, we copied it faithfully into the snippet, and with the
327
+ * wrapper flattened away it could never fire - the element unmounts before anything
328
+ * animates (dono, 04/08).
322
329
  */
323
- if (kind === "headless" && rendersNothing(tag, classes)) {
330
+ if ((kind === "headless" || kind === "motion") &&
331
+ (rendersNothing(tag, classes) || (!classes && children.length > 0))) {
324
332
  const held = children.flatMap((child) => build(child, depth + 1));
325
333
  return [
326
334
  {
327
335
  as: held.length > 0 ? "stack" : "text",
328
336
  from: String(node.from ?? tag),
329
337
  refName: tag,
338
+ /** Its own index, so its guard and its props are still reachable - see `at`. */
339
+ at: index,
330
340
  ...(held.length > 0 ? { children: held } : {}),
331
341
  },
332
342
  ];
333
343
  }
334
- if (kind === "motion" && rendersNothing(tag, classes)) {
335
- return children.flatMap((child) => build(child, depth));
336
- }
337
- // `<AnimatePresence>` and friends: no element of their own.
338
- if (kind === "motion" && !classes && children.length > 0) {
339
- return children.flatMap((child) => build(child, depth));
340
- }
341
344
  const descend = () => children.flatMap((child) => build(child, depth + 1));
342
345
  /**
343
346
  * THEIR OWN COMPONENT, and a library that draws itself: a reference, and it
@@ -349,7 +352,20 @@ versionOf) {
349
352
  {
350
353
  as: "component",
351
354
  ref: tag,
352
- ...(classes ? { at: index } : {}),
355
+ /**
356
+ * THE INDEX IS THE NODE'S IDENTITY ON THE SKETCH, not a pointer to its classes.
357
+ *
358
+ * Gated on `classes`, it meant a class-less node had no way back to what the
359
+ * sketch measured about it - and that is five facts, not one: the guard, the
360
+ * resting verdict, the animation props, the expression it writes and the props
361
+ * that make it inert. Their `Collapsible.Panel` carries no class and is guarded
362
+ * by `isOpen && !isLoading`, so the snippet showed an open panel as the resting
363
+ * state of a card that starts closed (dono, 04/08).
364
+ *
365
+ * Costs nothing: the two places that read `at` for styles take the classes at
366
+ * that index, and at a class-less index that is the empty string it already was.
367
+ */
368
+ at: index,
353
369
  ...(children.length > 0 ? { children: descend() } : {}),
354
370
  },
355
371
  ];
@@ -364,13 +380,30 @@ versionOf) {
364
380
  {
365
381
  as: "external",
366
382
  from: String(node.from ?? tag),
367
- ...(classes ? { at: index } : {}),
383
+ at: index,
368
384
  ...(children.length > 0 ? { children: descend() } : {}),
369
385
  },
370
386
  ];
371
387
  }
372
388
  if (kind === "icon") {
373
- return [{ as: "icon", name: nameOfTag(tag), at: index }];
389
+ /**
390
+ * AN ICON IS THEIR ELEMENT WEARING OUR CLASS.
391
+ *
392
+ * `<ChevronUp className="size-5 …" />` - the glyph is the library's and every pixel
393
+ * of size and colour is in their class list, which is why it is a part of ours. The
394
+ * snippet emitted `<span className="ds-collapsible-card-chevron-up">…</span>`: the
395
+ * right box in the right place, drawing nothing, and nothing in his repo spelled
396
+ * that way (dono, 04/08). The name of the part is already their tag; the element
397
+ * should be too.
398
+ */
399
+ return [
400
+ {
401
+ as: "icon",
402
+ name: nameOfTag(tag),
403
+ at: index,
404
+ ...(node.from ? { from: String(node.from), refName: tag } : {}),
405
+ },
406
+ ];
374
407
  }
375
408
  /**
376
409
  * EVERYTHING ELSE IS DRAWN: plain HTML, a headless primitive, and a
@@ -467,13 +500,19 @@ versionOf) {
467
500
  * The package AND their own spelling, because the snippet needs both - the import
468
501
  * line comes from the first and the tag from the second.
469
502
  */
470
- const fromLib = kind === "headless" && node.from
503
+ /**
504
+ * MOTION COUNTS THE SAME WAY. `AnimatePresence` carries no class and draws no pixel,
505
+ * and it is what lets the panel's `exit` animation run at all - without it the exit
506
+ * props are copied into the snippet and can never fire, because the element unmounts
507
+ * before anything animates. Headless-only dropped it (dono, 04/08).
508
+ */
509
+ const fromLib = (kind === "headless" || kind === "motion") && node.from
471
510
  ? { from: String(node.from), refName: tag }
472
511
  : {};
473
512
  const built = {
474
513
  as,
475
514
  ...(name ? { name } : {}),
476
- ...(classes ? { at: index } : {}),
515
+ at: index,
477
516
  ...fromLib,
478
517
  ...(node.text ? { text: node.text } : {}),
479
518
  };
@@ -278,6 +278,16 @@ sketch) {
278
278
  const atRest = node.atRest ?? measured?.atRest;
279
279
  if (atRest === false)
280
280
  node_.atRest = false;
281
+ /**
282
+ * WHAT THE NODE WRITES AND WHICH BEHAVIOUR PROPS IT CARRIES - the two facts that
283
+ * turn a styled shell into a component somebody can rebuild. Same lookup as the
284
+ * guard, one line up: measured on the sketch, dropped by every seam after it.
285
+ */
286
+ if (measured?.contentFrom)
287
+ node_.contentFrom = measured.contentFrom;
288
+ if (measured?.behaviourProps &&
289
+ Object.keys(measured.behaviourProps).length > 0)
290
+ node_.behaviourProps = measured.behaviourProps;
281
291
  }
282
292
  else {
283
293
  if (node.when)
@@ -325,6 +335,16 @@ sketch) {
325
335
  node_.from = measured.from;
326
336
  node_.refName = measured.tag;
327
337
  }
338
+ }
339
+ /**
340
+ * THE ANIMATION PROPS COME OFF THE SKETCH WHATEVER ELSE IS KNOWN.
341
+ *
342
+ * They used to hang off the branch that had to GUESS the element from the sketch, so
343
+ * a node whose reading already named its package - which is every restored library
344
+ * wrapper - lost them.
345
+ */
346
+ if (typeof node.at === "number" && !node_.motionProps) {
347
+ const measured = sketch?.[node.at];
328
348
  if (measured?.motionProps)
329
349
  node_.motionProps = measured.motionProps;
330
350
  }
@@ -14,6 +14,7 @@ import { emptyTally, internalSpecifiers, scanComponentsInto, tallyToInventory, }
14
14
  import { countShape, describeCoverage, summarizeCoverage, } from "../doctor/coverage.js";
15
15
  import { crosswalk, floorSize, isLibrary, observedRules, useLiveCatalogue, } from "../doctor/crosswalk.js";
16
16
  import { keyframeOffsets, moduleImports, readModuleCss, readModuleUsage, transcribeModule, } from "../doctor/css-modules.js";
17
+ import { dataContract } from "../doctor/data-contract.js";
17
18
  import { reconcile, scanDefinitions, } from "../doctor/definitions-scan.js";
18
19
  import { describeConvention, describeRemainder, detectConventions, } from "../doctor/idiom.js";
19
20
  import { callSiteNode, placeLayers, } from "../doctor/place-layers.js";
@@ -24,7 +25,7 @@ import { describeSignals, emptySignals, finishSignals, readSignalsInto, } from "
24
25
  import { sketchOf } from "../doctor/sketch.js";
25
26
  import { describeForeign, readStyleIslands, } from "../doctor/style-island.js";
26
27
  import { buildTable } from "../doctor/tokens.js";
27
- import { readInlineStyle, rootClasses, rootTag, transcribe, } from "../doctor/transcribe.js";
28
+ import { definitionSpan, readInlineStyle, rootClasses, rootTag, transcribe, } from "../doctor/transcribe.js";
28
29
  import { transcribeVariants } from "../doctor/variant-read.js";
29
30
  import { frontierKind } from "../frontier-kind.js";
30
31
  import { withLibraryStructure } from "../library-structure.js";
@@ -441,6 +442,7 @@ export async function takeCensus(root, opts) {
441
442
  ...et,
442
443
  ...(etag ? { rootTag: etag } : {}),
443
444
  ...rootPackage(etag, esketch),
445
+ ...rootBehaviour(etag, esketch),
444
446
  };
445
447
  }
446
448
  }
@@ -659,6 +661,7 @@ export async function takeCensus(root, opts) {
659
661
  ...(tree.length > 0 ? { tree } : {}),
660
662
  ...(tag ? { rootTag: tag } : {}),
661
663
  ...rootPackage(tag, sketch),
664
+ ...rootBehaviour(tag, sketch),
662
665
  ...(placed.length > 0 ? { layers: placed } : {}),
663
666
  ...(Object.keys(islandStyle).length > 0 ? { islandStyle } : {}),
664
667
  ...(v.unslotted.length > 0
@@ -1206,35 +1209,63 @@ export async function takeCensus(root, opts) {
1206
1209
  * as not exported. See `publicApi`.
1207
1210
  */
1208
1211
  const api = await publicApi(root);
1209
- if (api) {
1210
- for (const c of components) {
1211
- const file = defined.find((d) => d.name === c.name)?.file;
1212
- const source = file
1213
- ? sources.find((s) => s.file.endsWith(file))?.source
1214
- : undefined;
1215
- const required = source ? requiredProps(source, c.name) : [];
1216
- c.api = {
1217
- /**
1218
- * SILENCE WHEN THE ENTRY WAS NOT READ. An unreadable entry gives an empty export
1219
- * set, and calling that "internal" would have marked every component internal at
1220
- * once - see `PublicApi.read`.
1221
- */
1222
- ...(!api.read
1223
- ? {}
1224
- : api.exports.has(c.name)
1225
- ? { from: api.from }
1226
- : { exported: false }),
1227
- /**
1228
- * REPO-RELATIVE, so the answer needs no second search. `defined[].file` is
1229
- * relative to the scope; joined with the scope it is a path anybody can check
1230
- * with one `stat` - which is how `component` knows they already have it without
1231
- * walking a monorepo (their component sits six levels below `packages/`, and the
1232
- * shallow walk that guards against shadowing never reached it).
1233
- */
1234
- ...(file ? { file: scopeLabel ? `${scopeLabel}/${file}` : file } : {}),
1235
- ...(required.length > 0 ? { required } : {}),
1236
- };
1237
- }
1212
+ for (const c of components) {
1213
+ const file = defined.find((d) => d.name === c.name)?.file;
1214
+ const source = file
1215
+ ? sources.find((s) => s.file.endsWith(file))?.source
1216
+ : undefined;
1217
+ const required = source ? requiredProps(source, c.name) : [];
1218
+ /**
1219
+ * THE COMPONENT'S DATA CONTRACT - its props, its derivations, the copy an option
1220
+ * carries, and the type that names an axis's options.
1221
+ *
1222
+ * The owner asked whether the recipe was enough to rebuild the component in a new
1223
+ * project, and it was not: everything missing was LOGIC, and all of it sits in the
1224
+ * file already open here. See `dataContract`.
1225
+ *
1226
+ * Stamped whether or not the folder is a package: a `src/components` with no
1227
+ * manifest of its own has no specifier to offer and its components still have props.
1228
+ */
1229
+ const span = source ? definitionSpan(source, c.name) : null;
1230
+ const contract = source
1231
+ ? dataContract(source, span ? source.slice(span.from, span.to) : source, c.name, (c.declaredAxes ?? {}))
1232
+ : null;
1233
+ c.api = {
1234
+ /**
1235
+ * SILENCE WHEN THE ENTRY WAS NOT READ. An unreadable entry gives an empty export
1236
+ * set, and calling that "internal" would have marked every component internal at
1237
+ * once - see `PublicApi.read`.
1238
+ */
1239
+ ...(!api?.read
1240
+ ? {}
1241
+ : api.exports.has(c.name)
1242
+ ? { from: api.from }
1243
+ : { exported: false }),
1244
+ /**
1245
+ * REPO-RELATIVE, so the answer needs no second search. `defined[].file` is
1246
+ * relative to the scope; joined with the scope it is a path anybody can check
1247
+ * with one `stat` - which is how `component` knows they already have it without
1248
+ * walking a monorepo (their component sits six levels below `packages/`, and the
1249
+ * shallow walk that guards against shadowing never reached it).
1250
+ */
1251
+ ...(file ? { file: scopeLabel ? `${scopeLabel}/${file}` : file } : {}),
1252
+ ...(required.length > 0 ? { required } : {}),
1253
+ ...(contract && contract.props.length > 0
1254
+ ? { props: contract.props }
1255
+ : {}),
1256
+ ...(contract && Object.keys(contract.derived).length > 0
1257
+ ? { derived: contract.derived }
1258
+ : {}),
1259
+ ...(contract && Object.keys(contract.optionText).length > 0
1260
+ ? { optionText: contract.optionText }
1261
+ : {}),
1262
+ ...(contract && Object.keys(contract.axisType).length > 0
1263
+ ? { axisType: contract.axisType }
1264
+ : {}),
1265
+ };
1266
+ // An api with nothing in it is not a fact - it is an empty object on 36 components.
1267
+ if (Object.keys(c.api).length === 0)
1268
+ c.api = undefined;
1238
1269
  }
1239
1270
  const brokenRefs = findBrokenRefs(sources, declaredNames);
1240
1271
  const conventions = detectConventions(sources);
@@ -1878,6 +1909,27 @@ function rootPackage(tag, sketch) {
1878
1909
  ? { rootFrom: first.from }
1879
1910
  : {};
1880
1911
  }
1912
+ /**
1913
+ * THE PROPS ON THE ELEMENT THE COMPONENT RETURNS, when their value is a condition.
1914
+ *
1915
+ * 6 of their 27 conditional props sit on the root, and every one of the six is the prop
1916
+ * that makes the component controlled: `open={isOpen && !isLoading}` on their
1917
+ * `Collapsible.Root`, `checked={isChecked}` on a `Switch.Root`, `disabled` on a
1918
+ * `Field.Root`. Those are what somebody rebuilding it in another project has to wire, and
1919
+ * the root travelled with its tag, its package and its classes and none of its props
1920
+ * (dono, 04/08).
1921
+ *
1922
+ * Off the FIRST sketch node, which is the returned element - the same lookup `rootPackage`
1923
+ * makes one line up, guarded the same way.
1924
+ */
1925
+ function rootBehaviour(tag, sketch) {
1926
+ const first = sketch[0];
1927
+ if (!tag || !first || first.tag !== tag)
1928
+ return {};
1929
+ return first.behaviourProps && Object.keys(first.behaviourProps).length > 0
1930
+ ? { rootProps: first.behaviourProps }
1931
+ : {};
1932
+ }
1881
1933
  async function resolveReadParts(census, root) {
1882
1934
  /**
1883
1935
  * A READING IS OPTIONAL HERE. THE SKETCH IS NOT.
@@ -0,0 +1,170 @@
1
+ /**
2
+ * THE COMPONENT'S DATA CONTRACT - what it takes, what it derives, what it writes.
3
+ *
4
+ * The pipeline reads appearance and structure well, and a recipe made of those two can be
5
+ * RECONSTRUCTED here and not REGENERATED somewhere else. The owner asked for the second
6
+ * thing (dono, 04/08): from the recipe, in a new project, Claude should write a component
7
+ * that behaves the same in that project's own vocabulary.
8
+ *
9
+ * What was missing is all logic, and all of it is in the file we already open:
10
+ *
11
+ * the props, with their types and which are required 29 of their 38 declare one
12
+ * the derivations `const isLoading = status === "loading"` 9
13
+ * the text a lookup table holds `statusConfig.waiting.text = "Waiting"` 6
14
+ * the exported type of an axis `type CollapsibleCardStatus = "waiting" | …` 3
15
+ *
16
+ * NONE OF IT IS INFERRED. A prop with no type says no type; a table with no text says no
17
+ * text. The one thing this refuses to do is invent a shape - a component whose props come
18
+ * from `React.ComponentProps<"div">` gets an empty list, because guessing which props
19
+ * somebody must pass is worse than saying nothing.
20
+ */
21
+ /** `export type CollapsibleCardStatus = "waiting" | "fail" | …` */
22
+ const UNION = /export\s+type\s+([A-Za-z_$][\w$]*)\s*=\s*((?:"[^"]*"\s*\|\s*)+"[^"]*")\s*;/g;
23
+ /** The first `{ … }` of a `({ … })` parameter list, balanced. */
24
+ function paramBlock(body) {
25
+ const open = body.indexOf("({");
26
+ if (open === -1)
27
+ return null;
28
+ let depth = 0;
29
+ for (let i = open + 1; i < body.length; i++) {
30
+ if (body[i] === "{")
31
+ depth++;
32
+ else if (body[i] === "}") {
33
+ depth--;
34
+ if (depth === 0)
35
+ return body.slice(open + 2, i);
36
+ }
37
+ }
38
+ return null;
39
+ }
40
+ /** Split on top-level commas, so `{ a = { b: 1 }, c }` reads as two entries. */
41
+ function topLevel(text) {
42
+ const out = [];
43
+ let depth = 0;
44
+ let part = "";
45
+ for (const c of text) {
46
+ if ("([{".includes(c))
47
+ depth++;
48
+ else if (")]}".includes(c))
49
+ depth--;
50
+ if (c === "," && depth === 0) {
51
+ out.push(part);
52
+ part = "";
53
+ }
54
+ else
55
+ part += c;
56
+ }
57
+ out.push(part);
58
+ return out;
59
+ }
60
+ /**
61
+ * Their props interface, field by field - name, type, and whether it is required.
62
+ *
63
+ * Named after the component when they name it that way (29 of their 38 do), and the first
64
+ * `*Props` in the file otherwise. Nothing at all when they extend a library's type: the
65
+ * fields are not there to read and a list of ours would be an invention.
66
+ */
67
+ function propsFromType(source, name) {
68
+ const scoped = new RegExp(`(?:export\\s+)?(?:interface|type)\\s+${name}Props\\s*(?:extends [^{]+)?(?:=\\s*)?\\{([\\s\\S]*?)\\n\\}`).exec(source);
69
+ const generic = /(?:export\s+)?(?:interface|type)\s+\w*Props\s*(?:extends [^{]+)?(?:=\s*)?\{([\s\S]*?)\n\}/.exec(source);
70
+ const block = scoped?.[1] ?? generic?.[1];
71
+ if (!block)
72
+ return [];
73
+ const out = [];
74
+ for (const line of block.split("\n")) {
75
+ const m = /^\s*(?:readonly\s+)?([A-Za-z_$][\w$]*)(\?)?\s*:\s*(.+?);?\s*$/.exec(line);
76
+ if (!m)
77
+ continue;
78
+ const type = m[3].replace(/;$/, "").trim();
79
+ out.push({
80
+ name: m[1],
81
+ ...(type ? { type: type.slice(0, 80) } : {}),
82
+ ...(m[2] ? {} : { required: true }),
83
+ });
84
+ }
85
+ return out;
86
+ }
87
+ /**
88
+ * THE WHOLE CONTRACT, from the file and the component's own body.
89
+ *
90
+ * `body` is the definition span - the component's own function - so a file that declares
91
+ * two components does not lend one of them the other's defaults. `source` is the whole
92
+ * file, because a type and a lookup table live outside the function.
93
+ */
94
+ export function dataContract(source, body, name,
95
+ /** Axis → options, from the census - so a table is only read against a real axis. */
96
+ axes) {
97
+ const props = propsFromType(source, name);
98
+ const byName = new Map(props.map((p) => [p.name, p]));
99
+ /** The destructuring says which props have a default, and names the ones with no type. */
100
+ const block = paramBlock(body);
101
+ if (block) {
102
+ for (const entry of topLevel(block)) {
103
+ const eq = entry.indexOf("=");
104
+ const key = (eq === -1 ? entry : entry.slice(0, eq)).split(":")[0].trim();
105
+ if (!/^[A-Za-z_$][\w$]*$/.test(key))
106
+ continue;
107
+ const found = byName.get(key) ?? { name: key };
108
+ if (!byName.has(key)) {
109
+ props.push(found);
110
+ byName.set(key, found);
111
+ }
112
+ if (eq !== -1) {
113
+ const value = entry.slice(eq + 1).trim();
114
+ if (value)
115
+ found.default = value.slice(0, 60);
116
+ }
117
+ }
118
+ }
119
+ /**
120
+ * THE DERIVATIONS. Only the ones whose expression is a CONDITION - a comparison or a
121
+ * boolean chain - because those are what the conditional look gates on. A `const config
122
+ * = statusConfig[status]` is a lookup, and the table it reads is captured below instead.
123
+ */
124
+ const derived = {};
125
+ for (const m of body.matchAll(/const\s+([A-Za-z_$][\w$]*)\s*(?::[^=]+)?=\s*([^;\n]*(?:===|!==|&&|\|\|)[^;\n]*);/g)) {
126
+ const expression = m[2].trim();
127
+ if (!derived[m[1]] && expression.length <= 120)
128
+ derived[m[1]] = expression;
129
+ }
130
+ /**
131
+ * THE COPY A LOOKUP TABLE HOLDS, per axis option. Read only against an axis the census
132
+ * declared, and only for a field whose value is prose rather than a class list - which is
133
+ * what separates `text: "Waiting"` from `dotClass: "bg-lightgray-500"`.
134
+ */
135
+ const optionText = {};
136
+ for (const table of source.matchAll(/const\s+([A-Za-z_$][\w$]*)\s*(?::[^=]+)?=\s*\{([\s\S]*?)\n\}\s*;/g)) {
137
+ const axis = Object.keys(axes).find((a) => new RegExp(`\\b${table[1]}\\s*\\[\\s*${a}\\s*\\]`).test(source));
138
+ if (!axis)
139
+ continue;
140
+ for (const entry of topLevel(table[2])) {
141
+ const option = /^\s*"?([\w-]+)"?\s*:\s*\{([\s\S]*)\}\s*$/.exec(entry);
142
+ if (!option || !axes[axis].includes(option[1]))
143
+ continue;
144
+ for (const field of topLevel(option[2])) {
145
+ const kv = /^\s*(\w+)\s*:\s*"([^"]+)"\s*$/.exec(field);
146
+ if (!kv)
147
+ continue;
148
+ // A class list is a look and travels as one; prose is copy.
149
+ if (/^(?:[a-z-]+:)?[a-z][\w-]*(?:\s+(?:[a-z-]+:)?[a-z][\w-]*)*$/.test(kv[2]) &&
150
+ /class/i.test(kv[1]))
151
+ continue;
152
+ if (/class/i.test(kv[1]))
153
+ continue;
154
+ (optionText[axis] ??= {})[option[1]] = kv[2].slice(0, 80);
155
+ }
156
+ }
157
+ }
158
+ /** The exported union that lists an axis's options, when they declared one. */
159
+ const axisType = {};
160
+ UNION.lastIndex = 0;
161
+ for (const m of source.matchAll(UNION)) {
162
+ const options = [...m[2].matchAll(/"([^"]*)"/g)].map((o) => o[1]);
163
+ for (const [axis, declared] of Object.entries(axes)) {
164
+ if (declared.length === options.length &&
165
+ declared.every((o) => options.includes(o)))
166
+ axisType[axis] = m[1];
167
+ }
168
+ }
169
+ return { props, derived, optionText, axisType };
170
+ }
@@ -280,6 +280,77 @@ export function motionPropsOf(body) {
280
280
  const out = parts.join(" ");
281
281
  return out.length > 400 ? null : out;
282
282
  }
283
+ /**
284
+ * The expression an element writes as its own content - `{title}`, `{a || b.c}`.
285
+ *
286
+ * Only what sits DIRECTLY between this tag and the next event, so a parent does not claim
287
+ * its child's content. `{children}` is the caller's and already travels as a slot.
288
+ */
289
+ export function contentExpressionOf(source, open) {
290
+ // A tag that closes itself has no content, and what follows it is its PARENT's.
291
+ if (open.body.trimEnd().endsWith("/"))
292
+ return null;
293
+ /** `<` + name + body + `>` - the scan reports `body` without the name or the brackets. */
294
+ const from = open.at + 1 + open.tag.length + open.body.length + 1;
295
+ /**
296
+ * UP TO THIS ELEMENT'S OWN CLOSING TAG, and no further. Bounding by the next OPENING tag
297
+ * instead swept `</h3>` into the slice, and every one of their 22 read as nothing.
298
+ *
299
+ * A slice that does not close - `{isLoading && (` before a nested element - fails the
300
+ * balanced test below and says nothing, which is right: that is a guard, and a guard
301
+ * already travels as `when`.
302
+ */
303
+ const close = source.indexOf("<", from);
304
+ const to = Math.min(close === -1 ? source.length : close, from + 200);
305
+ if (to <= from)
306
+ return null;
307
+ const between = source.slice(from, to);
308
+ const m = /^\s*\{([^{}]*(?:\{[^{}]*\}[^{}]*)*)\}\s*$/.exec(between);
309
+ if (!m)
310
+ return null;
311
+ const expression = m[1].replace(/\s+/g, " ").trim();
312
+ if (!expression || expression === "children")
313
+ return null;
314
+ /**
315
+ * `{/* Header Section *``/}` IS A COMMENT, not content. Their WidgetCard opens with one
316
+ * and it arrived as the expression its root writes - a note to a human, presented as the
317
+ * prop that feeds an element.
318
+ */
319
+ if (expression.startsWith("/*") || expression.startsWith("//"))
320
+ return null;
321
+ // A whole JSX subtree is not content; a call is not a name we can attribute.
322
+ if (/[<>]/.test(expression) || expression.length > 90)
323
+ return null;
324
+ return expression;
325
+ }
326
+ /** The state props: `disabled={isLoading}`, `open={isOpen && !isLoading}`. */
327
+ const STATE_PROP = /\b(disabled|readOnly|checked|open|selected|required|expanded|active|loading|indeterminate)=\{([^}]{1,60})\}/g;
328
+ /**
329
+ * The handler that closes the loop on one of them: `onOpenChange={setIsOpen}`.
330
+ *
331
+ * A controlled `open` with no `onOpenChange` is a disclosure that opens once and never
332
+ * closes - which is what somebody pasting the snippet would have got. Only a NAME as the
333
+ * value: an inline arrow is a function body, and copying one means copying whatever it
334
+ * calls, which is not in the recipe.
335
+ */
336
+ const HANDLER_PROP = /\b(on[A-Z]\w*)=\{([A-Za-z_$][\w$.]{0,40})\}/g;
337
+ export function conditionalPropsOf(body) {
338
+ const out = {};
339
+ STATE_PROP.lastIndex = 0;
340
+ for (const m of body.matchAll(STATE_PROP)) {
341
+ const value = m[2].replace(/\s+/g, " ").trim();
342
+ // A literal is not behaviour, and an inline function is a body we cannot copy.
343
+ if (/^(true|false)$/.test(value) || /=>/.test(value))
344
+ continue;
345
+ if (!out[m[1]])
346
+ out[m[1]] = value;
347
+ }
348
+ HANDLER_PROP.lastIndex = 0;
349
+ for (const m of body.matchAll(HANDLER_PROP))
350
+ if (!out[m[1]])
351
+ out[m[1]] = m[2].trim();
352
+ return Object.keys(out).length > 0 ? out : null;
353
+ }
283
354
  export function sketchOf(source, name) {
284
355
  /**
285
356
  * READ THE IMPORTS BEFORE SLICING - they live at the top of the file, and the
@@ -352,6 +423,16 @@ export function sketchOf(source, name) {
352
423
  const motion = motionPropsOf(event.body);
353
424
  if (motion)
354
425
  node.motionProps = motion;
426
+ /**
427
+ * WHICH PROP FEEDS THIS ELEMENT, and which behaviour props it carries - the two facts
428
+ * a class list cannot hold. See `contentFrom` and `behaviourProps`.
429
+ */
430
+ const feeds = contentExpressionOf(source, event);
431
+ if (feeds)
432
+ node.contentFrom = feeds;
433
+ const conditional = conditionalPropsOf(event.body);
434
+ if (conditional)
435
+ node.behaviourProps = conditional;
355
436
  // What has to be true for this element to exist at all - see `guardAt`.
356
437
  const guard = guardAt(source, event.at);
357
438
  if (guard) {
package/dist/guide.js CHANGED
@@ -33,6 +33,102 @@ function customFontFamilies(families) {
33
33
  }
34
34
  return out;
35
35
  }
36
+ /**
37
+ * THE STRUCTURE, WITH WHAT EACH PART IS FOR AND WHEN IT EXISTS.
38
+ *
39
+ * A flat list of class names is enough to STYLE and not enough to BUILD: an agent in
40
+ * another project reading fifteen of them cannot tell which is the thing you press, which
41
+ * one holds the revealed content, or which two are not always there. The recipe carries
42
+ * both facts per node and the guide was printing neither (dono, 04/08).
43
+ */
44
+ function anatomyLines(cname, nodes, depth = 1) {
45
+ const out = [];
46
+ for (const node of nodes ?? []) {
47
+ if (depth > 6)
48
+ break;
49
+ const pad = " ".repeat(depth);
50
+ const name = node.part
51
+ ? `\`.ds-${kebab(cname)}-${kebab(node.part)}\``
52
+ : node.refName
53
+ ? `\`<${node.refName}>\``
54
+ : node.ref
55
+ ? `your \`${node.refName ?? node.ref}\``
56
+ : node.as;
57
+ const facts = [node.as];
58
+ if (node.role && node.role !== node.as)
59
+ facts.push(node.role);
60
+ if (node.refName && node.from)
61
+ facts.push(`from \`${node.from}\``);
62
+ if (node.expects)
63
+ facts.push(`holds ${node.expects}`);
64
+ /**
65
+ * WHEN IT EXISTS, in the recipe's own words rather than in theirs - see
66
+ * `PreviewNode.only`. Their `isLoading` means nothing in another project; "only in a
67
+ * state this component has" is actionable anywhere.
68
+ */
69
+ const only = node.only;
70
+ if (only && "axis" in only)
71
+ facts.push(`only when \`data-${kebab(only.axis)}="${only.option}"\``);
72
+ else if (only && "state" in only)
73
+ facts.push("only in that state");
74
+ else if (only && "caller" in only)
75
+ facts.push("only when you pass it");
76
+ /**
77
+ * WHICH PROP FEEDS IT, AND WHAT MAKES IT INERT - the link between a list of parts and
78
+ * a list of props, which nothing carried.
79
+ *
80
+ * `{title}` is their name, said as `writes` rather than as an instruction: an agent
81
+ * reading fifteen parts needs to know THIS is where the heading text goes, and the
82
+ * prop that carries it in the new project is that project's to name.
83
+ */
84
+ if (node.contentFrom)
85
+ facts.push(`writes \`${node.contentFrom}\``);
86
+ for (const [prop, value] of Object.entries(node.behaviourProps ?? {}))
87
+ facts.push(`\`${prop}\` while \`${value}\``);
88
+ out.push(`${pad}- ${name} - ${facts.join(" · ")}`);
89
+ out.push(...anatomyLines(cname, node.children, depth + 1));
90
+ }
91
+ return out;
92
+ }
93
+ /**
94
+ * WHAT THE COMPONENT DOES, said without naming a package.
95
+ *
96
+ * The recipe says `@base-ui/react/collapsible`. In a project that uses Radix - or nothing -
97
+ * that is evidence and not an instruction. What travels is the SHAPE of the interaction,
98
+ * and it is derivable from two facts the recipe already carries: the behaviour belongs to
99
+ * a headless library, and one part has the `action` role while another holds the content.
100
+ *
101
+ * Derived, never invented: with no `action` part and no headless library there is nothing
102
+ * to say, and this says nothing.
103
+ */
104
+ function behaviourLine(cname, recipe) {
105
+ const headless = (recipe.libraries ?? []).some((l) => l.kind === "headless");
106
+ if (!headless)
107
+ return null;
108
+ const flat = [];
109
+ const walk = (nodes) => {
110
+ for (const node of nodes ?? []) {
111
+ flat.push(node);
112
+ walk(node.children);
113
+ }
114
+ };
115
+ walk(recipe.preview?.parts);
116
+ const action = flat.find((n) => n.role === "action");
117
+ if (!action)
118
+ return null;
119
+ /**
120
+ * A part that exists only in a STATE, or a slot, is the region an action reveals - which
121
+ * is what separates a disclosure from a button that merely has a name.
122
+ */
123
+ const revealed = flat.find((n) => n.as === "slot" || (n.only && "state" in n.only));
124
+ const press = action.part
125
+ ? `\`.ds-${kebab(cname)}-${kebab(action.part)}\``
126
+ : "the action";
127
+ if (!revealed) {
128
+ return `the interaction: ${press} is the pressable one - wire it yourself or with any headless primitive, and give it the ARIA the pattern calls for.`;
129
+ }
130
+ return `the interaction: ${press} is pressed and reveals the region below it. Whatever drives that - a headless library or your own state - the pressable element announces \`aria-expanded\` and points at the region with \`aria-controls\`; the region carries the matching \`id\`. This recipe styles both and drives neither.`;
131
+ }
36
132
  /** One entry per component: class, variant data-*, states, and multi-part anatomy. */
37
133
  function componentEntry(cname, recipe) {
38
134
  const cls = `.ds-${kebab(cname)}`;
@@ -50,7 +146,101 @@ function componentEntry(cname, recipe) {
50
146
  });
51
147
  partsText = `\n parts: ${items.join(", ")}`;
52
148
  }
53
- return `- **${cname}** (\`${cls}\`)${variantsText}\n ${recipe.description}${partsText}${statesText}`;
149
+ /**
150
+ * EVERYTHING ELSE THE RECIPE ALREADY KNOWS, and this entry was throwing away.
151
+ *
152
+ * Measured on a real system: the recipe holds 15 fields and the guide read 4 of them, so
153
+ * an agent in another project got a list of class names and had to invent the rest -
154
+ * which is the difference between styling this component and building one that behaves
155
+ * like it (dono, 04/08).
156
+ */
157
+ const facts = [];
158
+ if (recipe.tier)
159
+ facts.push(`a ${recipe.tier}`);
160
+ if (recipe.api?.name) {
161
+ facts.push(recipe.api.from
162
+ ? `their own \`${recipe.api.name}\`, imported from \`${recipe.api.from}\``
163
+ : `their own \`${recipe.api.name}\``);
164
+ }
165
+ /**
166
+ * WHO OWNS THE STATE, which is the first question anybody rebuilding it asks. A recipe
167
+ * that manages itself needs no props to work; one the caller drives needs the pair.
168
+ */
169
+ const runtime = recipe.runtime;
170
+ if (runtime?.stateOwner) {
171
+ const owner = runtime.stateOwner === "self"
172
+ ? "manages its own state"
173
+ : runtime.stateOwner === "caller"
174
+ ? "the caller owns its state"
175
+ : "works controlled or uncontrolled";
176
+ const pairs = (runtime.controlled ?? [])
177
+ .map((c) => `\`${c.prop}\` + \`${c.event}\``)
178
+ .join(", ");
179
+ facts.push(pairs ? `${owner} (${pairs})` : owner);
180
+ }
181
+ /**
182
+ * WHOSE BEHAVIOUR IT IS, and what that means for somebody rebuilding it. `headless` is
183
+ * the one that changes the answer: the pixels are in this recipe and the interaction is
184
+ * a library's, so ANY headless primitive of that shape will do - it does not have to be
185
+ * the one they used.
186
+ */
187
+ for (const lib of recipe.libraries ?? []) {
188
+ if (lib.kind === "headless")
189
+ facts.push(`behaviour from \`${lib.from}\` - a headless library: every pixel is in this recipe, the interaction is not. Any equivalent primitive works`);
190
+ else if (lib.kind === "opaque")
191
+ facts.push(`\`${lib.from}\` draws part of it, and that part is not in this recipe`);
192
+ }
193
+ const defaults = Object.entries(recipe.defaults ?? {})
194
+ .map(([axis, option]) => `\`data-${kebab(axis)}="${option}"\``)
195
+ .join(", ");
196
+ const defaultsText = defaults ? `\n at rest: ${defaults}` : "";
197
+ const factsText = facts.length ? `\n ${facts.join("\n ")}` : "";
198
+ /** Its own laws, computed from its own usage - and nothing read them (dono, 04/08). */
199
+ const laws = (recipe.usage ?? []).map((law) => `\n - ${law}`).join("");
200
+ const lawsText = laws ? `\n it obeys:${laws}` : "";
201
+ const behaviour = behaviourLine(cname, recipe);
202
+ const behaviourText = behaviour ? `\n ${behaviour}` : "";
203
+ /**
204
+ * THE DATA CONTRACT, in prose an agent can act on.
205
+ *
206
+ * The owner's question was whether this recipe is enough to rebuild the component in a
207
+ * NEW project, and the answer was no: everything missing was logic. The classes and the
208
+ * nesting say what it looks like; these four lines say what it takes, what it computes,
209
+ * and what its options actually SAY (dono, 04/08).
210
+ *
211
+ * Their names, verbatim, presented as evidence: in the new project the props are named
212
+ * for that project's domain, and what transfers is the shape - which props are required,
213
+ * which condition gates which element, and what copy each option carries.
214
+ */
215
+ const contract = [];
216
+ const props = recipe.api?.props ?? [];
217
+ if (props.length > 0) {
218
+ contract.push(`props, as their own type declares them: ${props
219
+ .map((prop) => `\`${prop.name}${prop.required ? "" : "?"}${prop.type ? `: ${prop.type}` : ""}\`${prop.default ? ` (default \`${prop.default}\`)` : ""}`)
220
+ .join(", ")}`);
221
+ }
222
+ const derived = Object.entries(recipe.api?.derived ?? {});
223
+ if (derived.length > 0) {
224
+ contract.push(`it computes ${derived
225
+ .map(([name_, expression]) => `\`${name_}\` = \`${expression}\``)
226
+ .join(", ")} - the conditions below are written in those names`);
227
+ }
228
+ for (const [axis, byOption] of Object.entries(recipe.api?.optionText ?? {})) {
229
+ const said = Object.entries(byOption)
230
+ .map(([option, text]) => `${option} → "${text}"`)
231
+ .join(", ");
232
+ if (said)
233
+ contract.push(`each \`${axis}\` carries its own copy: ${said} - reword these for the new domain, keep one per option`);
234
+ }
235
+ for (const [axis, type] of Object.entries(recipe.api?.axisType ?? {})) {
236
+ contract.push(`\`${axis}\` is a named union in their code (\`${type}\`) - export the same kind of type rather than inlining the strings`);
237
+ }
238
+ const contractText = contract.length ? `\n ${contract.join("\n ")}` : "";
239
+ const anatomy = anatomyLines(cname, recipe.preview?.parts);
240
+ const anatomyText = anatomy.length
241
+ ? `\n anatomy, in this nesting:\n${anatomy.join("\n")}`
242
+ : "";
243
+ return `- **${cname}** (\`${cls}\`)${variantsText}\n ${recipe.description}${factsText}${defaultsText}${partsText}${statesText}${behaviourText}${contractText}${anatomyText}${lawsText}`;
54
244
  }
55
245
  /**
56
246
  * Builds GUIDE.md - instructions *for the agent* on how to build components
@@ -27,6 +27,7 @@
27
27
  * it, and re-inserting it would be a second opinion about a styled element.
28
28
  */
29
29
  import { frontierKind } from "./frontier-kind.js";
30
+ /** A restored wrapper points at its own sketch node - see the `at` stamped in `flush`. */
30
31
  /** Index → the index of its parent, or -1. Read off `depth`, which is the nesting. */
31
32
  function parents(sketch) {
32
33
  const out = [];
@@ -49,13 +50,21 @@ function claimed(read, into = new Set()) {
49
50
  }
50
51
  return into;
51
52
  }
52
- /** Behaviour with no appearance: their element, no class list, nobody's part. */
53
+ /**
54
+ * Behaviour with no appearance: their element, no class list, nobody's part.
55
+ *
56
+ * MOTION COUNTS AS BEHAVIOUR. `AnimatePresence` carries no class and draws no pixel, and
57
+ * it is the element that lets an `exit` animation run at all - without it the panel's
58
+ * `exit={{height:0}}` is copied into the snippet and can never fire, because the element
59
+ * unmounts before anything animates (dono, 04/08). Headless-only left it out.
60
+ */
53
61
  function isBehaviour(node) {
54
62
  if (!node)
55
63
  return false;
56
64
  if ((node.classes ?? "").trim())
57
65
  return false;
58
- return frontierKind(node.from) === "headless";
66
+ const kind = frontierKind(node.from);
67
+ return kind === "headless" || kind === "motion";
59
68
  }
60
69
  /**
61
70
  * Put the missing library frame back around the nodes it wraps.
@@ -110,6 +119,18 @@ wrapped) {
110
119
  as: "stack",
111
120
  from: String(node.from ?? node.tag),
112
121
  refName: node.tag,
122
+ /**
123
+ * THE INDEX TRAVELS WITH THE RESTORED ELEMENT, so everything the sketch measured
124
+ * about it reaches the recipe by the same lookup every named node uses: the
125
+ * guard, the resting fact, the animation props.
126
+ *
127
+ * Their panel is guarded - `{isOpen && !isLoading && (<Collapsible.Panel>` - and
128
+ * a restored wrapper with no index arrived unconditional, so the snippet showed
129
+ * an open panel as the resting state of a card that starts closed (dono, 04/08).
130
+ * It also makes this idempotent for free: a second pass finds the index already
131
+ * claimed.
132
+ */
133
+ at,
113
134
  children: built,
114
135
  },
115
136
  ];
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "synthesisui",
3
- "version": "0.16.119",
3
+ "version": "0.16.121",
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": {