synthesisui 0.16.96 → 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,6 +96,30 @@ 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,
@@ -168,9 +192,12 @@ sketch) {
168
192
  * kebab, which is what a system generated by us would want anyway.
169
193
  */
170
194
  const slug = resolve?.(name) ?? safePartName(name);
171
- out.push(slug && slug !== safePartName(name)
172
- ? { as, ref: slug, refName: name }
173
- : { 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
+ });
174
201
  continue;
175
202
  }
176
203
  const from = String(node.from ?? "").trim();
@@ -179,7 +206,12 @@ sketch) {
179
206
  const version = deps?.[from];
180
207
  if (!external.some((e) => e.from === from))
181
208
  external.push({ from, version });
182
- out.push(version ? { as, from, version } : { as, from });
209
+ out.push({
210
+ as,
211
+ from,
212
+ ...(version ? { version } : {}),
213
+ ...frontierClasses(node, sketch),
214
+ });
183
215
  continue;
184
216
  }
185
217
  const node_ = { as };
@@ -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 } : {}),
@@ -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": {
@@ -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)
@@ -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
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "synthesisui",
3
- "version": "0.16.96",
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": {