synthesisui 0.16.95 → 0.16.97

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.
@@ -96,11 +96,38 @@ function formOf(node) {
96
96
  * `declared` is their own tokens, so a `bg-ocean-500` on a nested part resolves
97
97
  * to the name they gave it rather than to a literal.
98
98
  */
99
+ /**
100
+ * WHAT THE PARENT PAINTS ON A FRONTIER, kept as written.
101
+ *
102
+ * A `component` or `external` node names another component and stops - and what
103
+ * the parent adds at the call site is the PARENT's decision, which had nowhere
104
+ * to go. Measured (not-expressed.md, dono, 01/08): `MetricCard` composes
105
+ * `<Card className="flex-1 dark:bg-darkgray-300">` and the dark override was
106
+ * lost, so the preview drew the wrong dark; `Paginate` lost a `min-w-[120px]`
107
+ * floor on both its buttons.
108
+ *
109
+ * Kept as the raw string rather than transcribed: this is an override on top of
110
+ * a recipe we do not own, and the renderer applies it as a class. Reading it into
111
+ * declarations would mean deciding which of the two wins, which is the compiler's
112
+ * job and not this module's.
113
+ */
114
+ function frontierClasses(node, sketch) {
115
+ const raw = typeof node.classes === "string"
116
+ ? node.classes
117
+ : typeof node.at === "number"
118
+ ? sketch?.[node.at]?.classes
119
+ : undefined;
120
+ const trimmed = raw?.trim();
121
+ return trimmed ? { classes: trimmed } : {};
122
+ }
99
123
  export function resolveAnatomy(read, declared, deps,
100
124
  /** Their name → the slug it reaches in this system. See `RefResolver`. */
101
125
  resolve,
102
126
  /** What the component returns, when the skill said and it is not a plain tag. */
103
- root) {
127
+ root,
128
+ /** This component's sketch, so a node can name its classes by INDEX instead
129
+ * of retyping them - see `AnatomyRead.at`. */
130
+ sketch) {
104
131
  const parts = {};
105
132
  const composes = [];
106
133
  const external = [];
@@ -165,9 +192,12 @@ root) {
165
192
  * kebab, which is what a system generated by us would want anyway.
166
193
  */
167
194
  const slug = resolve?.(name) ?? safePartName(name);
168
- out.push(slug && slug !== safePartName(name)
169
- ? { as, ref: slug, refName: name }
170
- : { as, ref: slug || safePartName(name) });
195
+ out.push({
196
+ as,
197
+ ref: slug || safePartName(name),
198
+ ...(slug && slug !== safePartName(name) ? { refName: name } : {}),
199
+ ...frontierClasses(node, sketch),
200
+ });
171
201
  continue;
172
202
  }
173
203
  const from = String(node.from ?? "").trim();
@@ -176,7 +206,12 @@ root) {
176
206
  const version = deps?.[from];
177
207
  if (!external.some((e) => e.from === from))
178
208
  external.push({ from, version });
179
- out.push(version ? { as, from, version } : { as, from });
209
+ out.push({
210
+ as,
211
+ from,
212
+ ...(version ? { version } : {}),
213
+ ...frontierClasses(node, sketch),
214
+ });
180
215
  continue;
181
216
  }
182
217
  const node_ = { as };
@@ -189,9 +224,16 @@ root) {
189
224
  if (key !== wanted) {
190
225
  notes.push(`two parts named \`${wanted}\` - the second is \`${key}\``);
191
226
  }
192
- const classes = typeof node.classes === "string"
193
- ? node.classes.split(/\s+/).filter(Boolean)
194
- : [];
227
+ /**
228
+ * THE CENSUS'S OWN STRING WINS when the node points at it. A retyped
229
+ * list is a copy that can lose a modifier; an index cannot.
230
+ */
231
+ const raw = typeof node.classes === "string"
232
+ ? node.classes
233
+ : typeof node.at === "number"
234
+ ? (sketch?.[node.at]?.classes ?? "")
235
+ : "";
236
+ const classes = raw.split(/\s+/).filter(Boolean);
195
237
  const t = transcribe(classes, declared);
196
238
  parts[key] = { base: t.base, dark: t.dark, states: t.states };
197
239
  /**
@@ -409,11 +409,16 @@ export async function takeCensus(root, opts) {
409
409
  for (const extra of found.slice(1)) {
410
410
  const et = transcribe(rootClasses(src, extra.name), declaredValues);
411
411
  const etag = rootTag(src, extra.name);
412
+ // ITS OWN MARKUP, sliced at the definition boundary - the whole file's
413
+ // sketch belonged to the first component and the second read an empty
414
+ // one (not-expressed.md, dono, 01/08).
415
+ const esketch = sketchOf(src, extra.name);
412
416
  const esize = Object.keys(et.base).length +
413
417
  Object.keys(et.dark).length +
414
418
  Object.keys(et.states).length;
415
- if (esize > 0 || etag) {
419
+ if (esize > 0 || etag || esketch.length > 0) {
416
420
  looks[extra.name] = {
421
+ ...(esketch.length > 0 ? { sketch: esketch } : {}),
417
422
  ...et,
418
423
  ...(etag ? { rootTag: etag } : {}),
419
424
  };
@@ -510,7 +515,7 @@ export async function takeCensus(root, opts) {
510
515
  * and a file read whose content is here is a pipeline optimization failure -
511
516
  * the owner's rule, adopted verbatim (dono, 01/08).
512
517
  */
513
- const sketch = sketchOf(src);
518
+ const sketch = sketchOf(src, found[0].name);
514
519
  if (size > 0 || tag) {
515
520
  looks[found[0].name] = {
516
521
  ...(sketch.length > 0 ? { sketch } : {}),
@@ -1656,7 +1661,10 @@ async function resolveReadParts(census, root) {
1656
1661
  for (const [component, entry] of Object.entries(read)) {
1657
1662
  const anatomy = entry?.anatomy;
1658
1663
  const resolved = Array.isArray(anatomy) && anatomy.length > 0
1659
- ? resolveAnatomy(anatomy, declared, deps, resolveRef, entry?.root)
1664
+ ? resolveAnatomy(anatomy, declared, deps, resolveRef, entry?.root,
1665
+ // The component's own sketch, so a node naming itself by index reads
1666
+ // the exact class string the census measured (dono, 01/08).
1667
+ looks[component]?.sketch)
1660
1668
  : Array.isArray(entry?.parts) && entry.parts.length > 0
1661
1669
  ? resolveFlatParts(entry.parts, declared)
1662
1670
  : null;
@@ -368,10 +368,90 @@ async function validateRecipe(name, recipe) {
368
368
  * what would NOT survive. A list of 30 "this one is fine" lines is a wall the
369
369
  * reader has to skim to find the two that matter.
370
370
  */
371
- async function validateRecipes(entries) {
371
+ /**
372
+ * RESOLVE `at` BEFORE VALIDATING - the validator never sees the census.
373
+ *
374
+ * A node styled by `at` carries no `classes`, so it looks EMPTY to the validator
375
+ * and complete to the importer: four recipes came back BELOW THE FLOOR on a real
376
+ * run and all four were whole, because their pixels live in parts the sketch
377
+ * holds and the validator could not reach (not-expressed.md, dono, 01/08). The
378
+ * verdict was a false accusation about our own optimisation.
379
+ *
380
+ * This side HAS the census - the same file the lock and the sketch come from - so
381
+ * the index is resolved here, once, and the validator judges the recipe somebody
382
+ * is actually about to send. `classes` already present wins, exactly as the
383
+ * importer resolves it.
384
+ */
385
+ async function resolveAtNodes(root, entries) {
386
+ let looks = {};
387
+ try {
388
+ const raw = await readFile(join(root, "_synthesisui", "census.json"), "utf8");
389
+ looks = JSON.parse(raw).looks ?? {};
390
+ }
391
+ catch {
392
+ // No census: nothing to resolve, and validating the recipe as sent is still
393
+ // better than refusing to validate at all.
394
+ return entries;
395
+ }
396
+ /** Their spelling → the sketch, matched the way the contract kebabs a name. */
397
+ const sketchFor = (name) => {
398
+ if (looks[name]?.sketch)
399
+ return looks[name].sketch;
400
+ const slug = name.replace(/[^a-z0-9]/gi, "").toLowerCase();
401
+ for (const [theirs, look] of Object.entries(looks)) {
402
+ if (theirs.replace(/[^a-z0-9]/gi, "").toLowerCase() === slug)
403
+ return look.sketch;
404
+ }
405
+ return undefined;
406
+ };
407
+ return entries.map((entry) => {
408
+ const sketch = sketchFor(String(entry.name ?? ""));
409
+ if (!sketch)
410
+ return entry;
411
+ const walk = (nodes) => {
412
+ if (!Array.isArray(nodes))
413
+ return nodes;
414
+ return nodes.map((raw) => {
415
+ const node = raw;
416
+ const at = typeof node.at === "number" ? node.at : null;
417
+ const classes = typeof node.classes === "string"
418
+ ? node.classes
419
+ : at != null
420
+ ? sketch[at]?.classes
421
+ : undefined;
422
+ return {
423
+ ...node,
424
+ ...(classes ? { classes } : {}),
425
+ ...(node.children ? { children: walk(node.children) } : {}),
426
+ };
427
+ });
428
+ };
429
+ const recipe = entry.recipe;
430
+ if (!recipe)
431
+ return entry;
432
+ const anatomy = recipe.anatomy ??
433
+ recipe.preview?.parts;
434
+ if (!Array.isArray(anatomy))
435
+ return entry;
436
+ return {
437
+ ...entry,
438
+ recipe: recipe.anatomy
439
+ ? { ...recipe, anatomy: walk(recipe.anatomy) }
440
+ : {
441
+ ...recipe,
442
+ preview: {
443
+ ...recipe.preview,
444
+ parts: walk(recipe.preview.parts),
445
+ },
446
+ },
447
+ };
448
+ });
449
+ }
450
+ async function validateRecipes(root, entries) {
372
451
  if (entries.length === 0)
373
452
  return "Send at least one recipe: { recipes: [{ name, recipe }] }.";
374
- const answer = await askCatalogue("validate", { recipes: entries });
453
+ const resolved = await resolveAtNodes(root, entries);
454
+ const answer = await askCatalogue("validate", { recipes: resolved });
375
455
  if (!answer.ok)
376
456
  return answer.because;
377
457
  const body = answer.body;
@@ -662,10 +742,16 @@ async function callTool(root, name, args) {
662
742
  return text(await addComponent(root, String(args.name ?? "")));
663
743
  case "recipe_vocabulary":
664
744
  return text(await recipeVocabulary());
665
- case "validate_recipe":
666
- return text(await validateRecipe(String(args.name ?? "this component"), args.recipe));
745
+ case "validate_recipe": {
746
+ // Same resolution as the batch: an `at` the validator cannot see reads as
747
+ // an empty node and earns a false floor verdict (dono, 01/08).
748
+ const [only] = await resolveAtNodes(root, [
749
+ { name: String(args.name ?? "this component"), recipe: args.recipe },
750
+ ]);
751
+ return text(await validateRecipe(String(args.name ?? "this component"), only?.recipe ?? args.recipe));
752
+ }
667
753
  case "validate_recipes":
668
- return text(await validateRecipes(Array.isArray(args.recipes)
754
+ return text(await validateRecipes(root, Array.isArray(args.recipes)
669
755
  ? args.recipes
670
756
  : []));
671
757
  case "request_component": {
@@ -7,6 +7,7 @@ import { body, section, snippet } from "../output.js";
7
7
  import { reactMajorOf, readInstalledConvention } from "../project-facts.js";
8
8
  import { fetchChangelog, fetchComponent, fetchDesignSystem, RegistryError, } from "../registry.js";
9
9
  import { add } from "./add.js";
10
+ import { doctor } from "./doctor.js";
10
11
  /**
11
12
  * The highest `v<n>` below `installed` among the folder names given, or the one
12
13
  * asked for. Pure, so the version arithmetic can be tested without a disk.
@@ -226,6 +227,26 @@ export async function upgrade(slug, opts) {
226
227
  console.log("");
227
228
  console.log(body(`⚠ Could not regenerate: ${failed.join(", ")} (removed/renamed in v${latest.version}?)`));
228
229
  }
230
+ /**
231
+ * THE CHECK RUNS ITSELF - closing the loop the owner asked for end to end
232
+ * (dono, 01/08).
233
+ *
234
+ * An upgrade that lands a v2 whose whole point is "these literals are tokens
235
+ * now" and then says "ask your agent to migrate" leaves the person with no
236
+ * way to know whether it worked. The doctor is the answer and it is one
237
+ * command, so it is not somebody's job to remember: it runs here, over the
238
+ * same root, and its report IS the receipt.
239
+ *
240
+ * Never strict. A finding is information, not a failure of the upgrade, and
241
+ * exiting non-zero on somebody's own drift would turn a successful install
242
+ * into a red terminal.
243
+ */
244
+ console.log(section("Checking the app against v" + latest.version));
245
+ await doctor({ dir: root }).catch((err) => {
246
+ // A doctor that cannot run is not an upgrade that failed - the artifacts
247
+ // are already on disk and correct.
248
+ console.log(body(`The check could not run (${err instanceof Error ? err.message : "unknown"}). Run \`npx synthesisui doctor\` when you can.`));
249
+ });
229
250
  console.log(section("Migrate the app"));
230
251
  console.log(body(`The migration brief is at _synthesisui/ds/${slug}/UPGRADE.md`));
231
252
  console.log("");
@@ -19,6 +19,7 @@
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 { definitionSpan } from "./transcribe.js";
22
23
  /**
23
24
  * Past this a component is a page in disguise, and the gate already said no.
24
25
  *
@@ -96,7 +97,22 @@ function staticCallClasses(body) {
96
97
  * sitting directly between an open and its close - dynamic children are the caller's,
97
98
  * and the slot form already says so.
98
99
  */
99
- export function sketchOf(source) {
100
+ export function sketchOf(source, name) {
101
+ /**
102
+ * ONE COMPONENT'S MARKUP, when the file declares more than one.
103
+ *
104
+ * `RadioCard.tsx` declares `RadioCard` and `RadioCardGroup`, and this used to
105
+ * hand all sixteen nodes to whoever asked first - node 15 being
106
+ * `<RadioGroup className="flex flex-col gap-3">`, the OTHER component's root.
107
+ * So one component read a sketch with a stranger's markup in it and the other
108
+ * read an empty one (not-expressed.md, dono, 01/08).
109
+ *
110
+ * A name that is not in the file, or one whose body has no braces, reads the
111
+ * whole file - which is right for the 34 files that declare one component.
112
+ */
113
+ const span = name ? definitionSpan(source, name) : null;
114
+ if (span)
115
+ source = source.slice(span.from, span.to);
100
116
  const events = scanTags(source);
101
117
  const out = [];
102
118
  let depth = 0;
@@ -146,6 +146,34 @@ const SIZE_KEYWORD = {
146
146
  px: "1px",
147
147
  "0": "0",
148
148
  };
149
+ /**
150
+ * `max-w-*` HAS ITS OWN RAMP - and it is not the spacing scale.
151
+ *
152
+ * Tailwind gives max-width a named ladder of its own (`xs` 20rem through `7xl`
153
+ * 80rem, plus `none` and `prose`), and the reader only knew the spacing scale
154
+ * and the keyword sizes - so `max-w-2xl` resolved to nothing and was reported as
155
+ * a class that "starts like a design decision and resolves to nothing"
156
+ * (not-expressed.md, dono, 01/08). It resolves to 42rem, and the class was fine;
157
+ * the table was short.
158
+ *
159
+ * Scoped to `max-w`/`max-h` on purpose: `w-2xl` is not a utility, and `p-2xl`
160
+ * means the spacing scale. A ramp applied on the wrong axis would invent a width.
161
+ */
162
+ const MAX_WIDTH = {
163
+ none: "none",
164
+ xs: "20rem",
165
+ sm: "24rem",
166
+ md: "28rem",
167
+ lg: "32rem",
168
+ xl: "36rem",
169
+ "2xl": "42rem",
170
+ "3xl": "48rem",
171
+ "4xl": "56rem",
172
+ "5xl": "64rem",
173
+ "6xl": "72rem",
174
+ "7xl": "80rem",
175
+ prose: "65ch",
176
+ };
149
177
  /** Axis-dependent: `w-screen` is 100vw and `h-screen` is 100vh. */
150
178
  const SCREEN = {
151
179
  w: "100vw",
@@ -635,6 +663,10 @@ export function readUtility(utility, declared) {
635
663
  /^(w|h|min-w|min-h|max-w|max-h|size)$/.test(sizePrefix)) {
636
664
  return { property: spaceProp, value: SIZE_KEYWORD[sizeRest] };
637
665
  }
666
+ /** `max-w-2xl` - max-width's own named ramp, on its own axis only. */
667
+ if (MAX_WIDTH[sizeRest] && /^(max-w|max-h)$/.test(sizePrefix)) {
668
+ return { property: spaceProp, value: MAX_WIDTH[sizeRest] };
669
+ }
638
670
  }
639
671
  if (prefix === "rounded") {
640
672
  /**
@@ -1107,14 +1139,159 @@ function returnAt(source, name) {
1107
1139
  if (name) {
1108
1140
  const decl = new RegExp(`(?:function|const|let|var)\\s+${name}\\b|${name}\\s*[:=]\\s*(?:function|\\()`).exec(source);
1109
1141
  if (decl) {
1110
- const from = source.slice(decl.index);
1111
- const ret = from.search(/return\s*\(/);
1112
- if (ret !== -1)
1113
- return decl.index + ret;
1142
+ const own = ownReturn(source, decl.index);
1143
+ if (own !== -1)
1144
+ return own;
1145
+ /**
1146
+ * A BRACE-LESS ARROW returns its JSX straight after `=>`, so there is no
1147
+ * body and no `return` to find - `const X = () => (<div/>)`. That is a
1148
+ * real shape and it gets the concise position.
1149
+ */
1150
+ const arrow = /=>\s*\(/.exec(source.slice(decl.index, decl.index + 400));
1151
+ if (arrow)
1152
+ return decl.index + arrow.index;
1153
+ /**
1154
+ * FOUND THE NAME AND IT RETURNS NO JSX IN PARENTHESES - so say nothing.
1155
+ *
1156
+ * The lexical fallback used to run here and it borrowed the NEXT
1157
+ * component's return: a `Helper` that returns a number handed back a
1158
+ * sibling's `<div className="p-2">`. That is the same defect the charts
1159
+ * had, one shape along - and my own spec caught it (dono, 01/08).
1160
+ */
1161
+ return -1;
1114
1162
  }
1115
1163
  }
1116
1164
  return source.search(/return\s*\(/);
1117
1165
  }
1166
+ /**
1167
+ * THE COMPONENT'S OWN `return (`, not the first one after its name.
1168
+ *
1169
+ * Scoping by name was half the answer and the charts proved it: `LineChart` is
1170
+ * declared on line 99, and the first `return (` after that is on line 158 -
1171
+ * inside `CustomTooltip`, a closure NESTED IN ITS BODY. So all three charts
1172
+ * arrived with the tooltip's surface as their base: `bg-white/90 backdrop-blur-md
1173
+ * p-3 rounded-xl shadow-2xl` on what should be a card (not-expressed.md, dono,
1174
+ * 01/08).
1175
+ *
1176
+ * A function's own return sits at the body's OWN brace depth. So: walk from the
1177
+ * declaration, find the body's opening brace, then take the first `return (`
1178
+ * that appears while depth is exactly one - anything deeper belongs to a closure
1179
+ * this component happens to contain.
1180
+ *
1181
+ * Quote-aware, because a brace inside a string is not a brace. An arrow body
1182
+ * with no braces at all (`const X = () => (<div/>)`) has no depth to track and
1183
+ * falls through to the caller's lexical search, which is right for it.
1184
+ */
1185
+ /**
1186
+ * WHERE ONE COMPONENT'S CODE STARTS AND STOPS, by name.
1187
+ *
1188
+ * A file that declares two components used to hand both bodies to whatever read
1189
+ * it first: `RadioCard.tsx` declares `RadioCard` and `RadioCardGroup`, and the
1190
+ * sketch attributed all sixteen nodes to `RadioCard` - node 15 being
1191
+ * `<RadioGroup className="flex flex-col gap-3">`, which is the OTHER component's
1192
+ * root (not-expressed.md, dono, 01/08).
1193
+ *
1194
+ * Returns null when the name is not in the file or has no braced body, and the
1195
+ * caller then reads the whole file - which is right for the 34 files that
1196
+ * declare one component.
1197
+ */
1198
+ export function definitionSpan(source, name) {
1199
+ const decl = new RegExp(`(?:function|const|let|var)\\s+${name}\\b|${name}\\s*[:=]\\s*(?:function|\\()`).exec(source);
1200
+ if (!decl)
1201
+ return null;
1202
+ const brace = bodyBrace(source, decl.index);
1203
+ if (brace === -1)
1204
+ return null;
1205
+ const end = matchingBrace(source, brace);
1206
+ return { from: decl.index, to: end === -1 ? source.length : end + 1 };
1207
+ }
1208
+ /** The `{` that opens a function body declared at `declAt`, or -1. */
1209
+ function bodyBrace(source, declAt) {
1210
+ let i = declAt;
1211
+ let paren = 0;
1212
+ let opened = false;
1213
+ for (; i < source.length; i++) {
1214
+ const ch = source[i];
1215
+ if (ch === "(") {
1216
+ paren += 1;
1217
+ opened = true;
1218
+ }
1219
+ else if (ch === ")") {
1220
+ paren -= 1;
1221
+ if (opened && paren <= 0) {
1222
+ i += 1;
1223
+ break;
1224
+ }
1225
+ }
1226
+ else if (ch === "{" && !opened) {
1227
+ return -1; // `const X = { … }` is an object, not a body
1228
+ }
1229
+ }
1230
+ return source.indexOf("{", i);
1231
+ }
1232
+ /** The `}` that closes the brace at `open`, quote-aware. */
1233
+ function matchingBrace(source, open) {
1234
+ let depth = 0;
1235
+ let quote = null;
1236
+ for (let j = open; j < source.length; j++) {
1237
+ const ch = source[j];
1238
+ if (quote) {
1239
+ if (ch === "\\")
1240
+ j += 1;
1241
+ else if (ch === quote)
1242
+ quote = null;
1243
+ continue;
1244
+ }
1245
+ if (ch === '"' || ch === "'" || ch === "`") {
1246
+ quote = ch;
1247
+ continue;
1248
+ }
1249
+ if (ch === "{")
1250
+ depth += 1;
1251
+ else if (ch === "}") {
1252
+ depth -= 1;
1253
+ if (depth === 0)
1254
+ return j;
1255
+ }
1256
+ }
1257
+ return -1;
1258
+ }
1259
+ function ownReturn(source, declAt) {
1260
+ const brace = bodyBrace(source, declAt);
1261
+ if (brace === -1)
1262
+ return -1;
1263
+ let depth = 0;
1264
+ let quote = null;
1265
+ for (let j = brace; j < source.length; j++) {
1266
+ const ch = source[j];
1267
+ if (quote) {
1268
+ if (ch === "\\")
1269
+ j += 1;
1270
+ else if (ch === quote)
1271
+ quote = null;
1272
+ continue;
1273
+ }
1274
+ if (ch === '"' || ch === "'" || ch === "`") {
1275
+ quote = ch;
1276
+ continue;
1277
+ }
1278
+ if (ch === "{")
1279
+ depth += 1;
1280
+ else if (ch === "}") {
1281
+ depth -= 1;
1282
+ // Out of the body without finding one: this component returns no JSX in
1283
+ // parentheses, and guessing from a sibling would be worse than nothing.
1284
+ if (depth === 0)
1285
+ return -1;
1286
+ }
1287
+ else if (depth === 1 &&
1288
+ ch === "r" &&
1289
+ /^return\s*\(/.test(source.slice(j))) {
1290
+ return j;
1291
+ }
1292
+ }
1293
+ return -1;
1294
+ }
1118
1295
  export function rootTag(source, name) {
1119
1296
  const ret = returnAt(source, name);
1120
1297
  if (ret === -1)
@@ -373,14 +373,14 @@ Open the project and answer the questions below. Then add a \`reading\` object t
373
373
  "components": {
374
374
  "MetricCard": {
375
375
  "anatomy": [
376
- { "as": "icon", "name": "icon", "classes": "size-8 text-ocean-500" },
376
+ { "as": "icon", "name": "icon", "at": 1 },
377
377
  {
378
378
  "as": "stack",
379
379
  "name": "body",
380
- "classes": "flex flex-col gap-1",
380
+ "at": 2,
381
381
  "children": [
382
- { "as": "text", "name": "label", "classes": "text-xs uppercase text-lightgray-500" },
383
- { "as": "heading", "name": "value", "classes": "text-2xl font-bold" }
382
+ { "as": "text", "name": "label", "at": 3 },
383
+ { "as": "heading", "name": "value", "at": 4 }
384
384
  ]
385
385
  }
386
386
  ]
@@ -388,15 +388,15 @@ Open the project and answer the questions below. Then add a \`reading\` object t
388
388
  "ArticleCard": {
389
389
  "root": "Card",
390
390
  "anatomy": [
391
- { "as": "image", "name": "cover", "classes": "aspect-video w-full rounded-t-lg" },
392
- { "as": "heading", "name": "title", "classes": "text-lg font-semibold" },
391
+ { "as": "image", "name": "cover", "at": 1 },
392
+ { "as": "heading", "name": "title", "at": 2 },
393
393
  { "as": "component", "ref": "TextEditor" },
394
394
  {
395
395
  "as": "row",
396
396
  "name": "footer",
397
- "classes": "flex items-center gap-2 border-t p-3",
397
+ "at": 3,
398
398
  "children": [
399
- { "as": "button", "name": "publish", "classes": "btn btn-primary" },
399
+ { "as": "button", "name": "publish", "at": 4 },
400
400
  { "as": "button", "name": "discard", "classes": "btn btn-ghost" }
401
401
  ]
402
402
  }
@@ -711,6 +711,21 @@ of. It previews as a block carrying its name, and it becomes a **rule** - \`Arti
711
711
  That edge is a **fact, not a habit**: you saw it in the definition, so it is true once and for
712
712
  all, and it arrives active without waiting for a third sighting.
713
713
 
714
+ **Send what the PARENT paints on it.** A frontier node takes \`at\` (or \`classes\`) like any other,
715
+ and it means something different there: not the child's look, which has a recipe of its own, but
716
+ the override the parent applies where it composes the child. Measured on a real library, four of
717
+ these were being lost:
718
+
719
+ \`\`\`
720
+ Paginate <Button className="min-w-[120px] dark:bg-ocean-800 …"> a 120px floor
721
+ MetricCard <Card className="flex-1 dark:bg-darkgray-300"> overrides Card's dark
722
+ ArticleCard <Tag className="w-fit">
723
+ Message <ReactMarkdown className="text-royal-blue-500 underline …">
724
+ \`\`\`
725
+
726
+ The MetricCard one is the sharpest: losing that override means the preview draws the wrong dark.
727
+ Same for an \`external\` node - a library's component still wears what their code puts on it.
728
+
714
729
  Three things that are NOT edges, and sending them as such would be wrong:
715
730
 
716
731
  - \`motion.div\`, \`Dialog.Root\`, \`Radio.Item\` - a library's namespace, not a component of theirs
@@ -902,9 +917,22 @@ file for, minus the one thing that is genuinely yours: the NAMES.
902
917
 
903
918
  Read that and write the anatomy: depth 1 is \`trigger\`, the dot is \`status-dot\`, the
904
919
  capitalised tag is a frontier. **Do not open the component file** - if the sketch is
905
- missing or visibly truncated (60-node cap), say so in not-expressed.md and only then
920
+ missing or visibly truncated (150-node cap), say so in not-expressed.md and only then
906
921
  read, because that gap is the census's to close.
907
922
 
923
+ **NAME THE NODE BY ITS INDEX - never retype the class string.** Each anatomy node carries
924
+ \`"at": <index into this sketch>\`, and the CLI reads the exact string the census measured:
925
+
926
+ \`\`\`json
927
+ { "as": "button", "name": "trigger", "at": 1 }
928
+ \`\`\`
929
+
930
+ Retyping is where a modifier goes missing. Six real components arrived with \`hover:\` and
931
+ \`focus:\` in their source and no state at all in the recipe, and the parser was not the
932
+ problem - the copy was (audit, dono, 01/08). An index cannot lose a class. \`classes\` still
933
+ works and still wins when you send both, which is for the one node the sketch could not
934
+ reach; every other node uses \`at\`.
935
+
908
936
  ### What the CLI now reads for you, so do not spend a turn on it
909
937
 
910
938
  **The rule under all of this: you are given EVIDENCE and asked to DECIDE.** You are never
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "synthesisui",
3
- "version": "0.16.95",
3
+ "version": "0.16.97",
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": {