synthesisui 0.16.83 → 0.16.84

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.
@@ -5,12 +5,13 @@ import { readCredentials, readToken, resolveRegistry, sameRegistry, } from "../c
5
5
  import { architectureRule, describeArchitecture, describeChoice, detectArchitectures, } from "../doctor/architecture.js";
6
6
  import { findBrokenRefs } from "../doctor/broken-refs.js";
7
7
  import { nestingRules, propRules, readDefinitionProps, readNesting, } from "../doctor/call-sites.js";
8
+ import { asCatalogueTable, describeFallback, fetchCatalogue, } from "../doctor/catalogue-fetch.js";
8
9
  import { describeClassStyle, detectClassStyle, } from "../doctor/class-style.js";
9
10
  import { isNearDuplicate, isNeutral, lightness, } from "../doctor/color-distance.js";
10
- import { describeGate, gateComponent, groupSkips, } from "../doctor/component-gate.js";
11
+ import { describeGate, gateComponent, groupSkips, SCREENS_FOR_SYSTEM, } from "../doctor/component-gate.js";
11
12
  import { emptyTally, internalSpecifiers, scanComponentsInto, tallyToInventory, } from "../doctor/components-scan.js";
12
13
  import { countShape, describeCoverage, summarizeCoverage, } from "../doctor/coverage.js";
13
- import { crosswalk, isLibrary, observedRules } from "../doctor/crosswalk.js";
14
+ import { crosswalk, floorSize, isLibrary, observedRules, useLiveCatalogue, } from "../doctor/crosswalk.js";
14
15
  import { moduleImports, readModuleCss, readModuleUsage, transcribeModule, } from "../doctor/css-modules.js";
15
16
  import { reconcile, scanDefinitions, } from "../doctor/definitions-scan.js";
16
17
  import { describeConvention, describeRemainder, detectConventions, } from "../doctor/idiom.js";
@@ -214,8 +215,36 @@ function distinctValues(d) {
214
215
  }
215
216
  return kept.sort((a, b) => b.count - a.count || a.value.localeCompare(b.value));
216
217
  }
218
+ /**
219
+ * WHICH SCREEN A FILE BELONGS TO.
220
+ *
221
+ * The route segment, because that is what a framework already decided: everything under
222
+ * `app/dashboard/audience` is one screen however deep the folder goes. Route GROUPS are
223
+ * skipped - `(dashboard)` is an organisational parenthesis, not a place a person visits.
224
+ *
225
+ * For a project with no `app/` or `pages/`, the first two folders are the closest honest
226
+ * answer, and a library never reaches this because the test is skipped there.
227
+ */
228
+ function screenOf(rel) {
229
+ const parts = rel.split("/");
230
+ const i = parts.findIndex((p) => p === "app" || p === "pages");
231
+ if (i === -1)
232
+ return parts.slice(0, 2).join("/");
233
+ const after = parts.slice(i + 1).filter((p) => !p.startsWith("("));
234
+ return `${parts[i]}/${after.slice(0, 2).join("/")}`;
235
+ }
217
236
  export async function takeCensus(root, opts) {
218
237
  const scopeLabel = opts?.scopeLabel;
238
+ /**
239
+ * THE LIVE CATALOGUE, once, before anything is matched.
240
+ *
241
+ * The written table had 26 names against a catalogue of 41, so twenty of our own
242
+ * components were invisible to matching and their `Textarea` did not meet our
243
+ * `textarea` (dono, 01/08). Fetched rather than shipped, and the fallback is announced
244
+ * with what it costs - a smaller answer is fine, a silently smaller one is not.
245
+ */
246
+ const fetched = await fetchCatalogue(opts?.registry);
247
+ useLiveCatalogue(fetched.ok ? asCatalogueTable(fetched.index) : null);
219
248
  const css = await harvestOwnCss(root);
220
249
  const table = buildTable({ css, source: "yours" });
221
250
  const schemes = parseSchemeBlocks(css);
@@ -245,6 +274,12 @@ export async function takeCensus(root, opts) {
245
274
  const nesting = new Map();
246
275
  /** Component → what its own definition names and whether it forwards the rest. */
247
276
  const propsOf = new Map();
277
+ /**
278
+ * Component → the distinct SCREENS that compose it, which is what separates a design
279
+ * system from a folder: 242 of 271 in a real app live on one screen or none, and
280
+ * filtering them takes the vitrine from 271 to 29 (dono, 01/08).
281
+ */
282
+ const screensOf = new Map();
248
283
  /** Component → the axes its own `cva` declares. See `variant-read.ts`. */
249
284
  const variantAxes = new Map();
250
285
  /** How much came out of CSS Modules, for the coverage report. */
@@ -299,6 +334,14 @@ export async function takeCensus(root, opts) {
299
334
  // HOW IT IS USED, not only what it is: which props people pass, what it passes
300
335
  // on, and what shows up inside it. See `call-sites.ts`.
301
336
  readNesting(rel, src, nesting);
337
+ // Which screen this file belongs to, and what it composes. One pass, and the
338
+ // answer is a count of PLACES rather than of occurrences - a loop is one place.
339
+ const screen = screenOf(rel);
340
+ for (const m of src.matchAll(/<([A-Z][A-Za-z0-9_]*)/g)) {
341
+ const at = screensOf.get(m[1]) ?? new Set();
342
+ at.add(screen);
343
+ screensOf.set(m[1], at);
344
+ }
302
345
  const found = [];
303
346
  for (const d of scanDefinitions(rel, src)) {
304
347
  const verdict = gateComponent({
@@ -440,6 +483,51 @@ export async function takeCensus(root, opts) {
440
483
  })));
441
484
  const d = diagnose(reports);
442
485
  const allComposed = tallyToInventory(tally, 400);
486
+ /**
487
+ * THE FEATURE FILTER, after the walk - because the count needs every file.
488
+ *
489
+ * A component composed on one screen is that screen's own markup; two screens is a
490
+ * decision the product shares. Measured on a real app: 242 of 271 live on one screen or
491
+ * none, and filtering them takes the vitrine from 271 to 29 - `Table`, `Tag`, `Tooltip`,
492
+ * `Button`, `Loader`, `Header` (dono, 01/08).
493
+ *
494
+ * SKIPPED FOR A LIBRARY, and this is the whole reason the test is here rather than in
495
+ * the walk: a library composes nothing inside itself, so every export would read as
496
+ * used on no screen. Measured the same way `isLibrary` measures it, from the tally
497
+ * rather than from a second opinion.
498
+ */
499
+ /**
500
+ * NO SCREENS MEANS NO TEST, and this is the guard rather than a component count.
501
+ *
502
+ * A first version required four components before calling something a library, and a
503
+ * scope with three had every export filtered as a feature - which is the failure this
504
+ * test was built to prevent, arriving from the other side (spec, 01/08).
505
+ *
506
+ * The honest question is simpler: does this scope HAVE screens? A `packages/ui` has no
507
+ * `app/` and no `pages/`, so there is nothing to count and the count would be zero for
508
+ * everything. An app has them, and then the number means something.
509
+ */
510
+ const hasScreens = [...screensOf.values()].some((at) => [...at].some((s) => s.startsWith("app/") || s.startsWith("pages/")));
511
+ if (hasScreens) {
512
+ const keep = [];
513
+ for (const d of defined) {
514
+ const screens = screensOf.get(d.name)?.size ?? 0;
515
+ if (screens >= SCREENS_FOR_SYSTEM) {
516
+ keep.push(d);
517
+ continue;
518
+ }
519
+ skips.push({
520
+ name: d.name,
521
+ file: d.file,
522
+ why: "feature",
523
+ because: screens === 0
524
+ ? `\`${d.name}\` is composed on no screen at all - it is markup nothing reaches yet`
525
+ : `\`${d.name}\` is composed on one screen only, so it is that screen's own markup rather than a decision the product shares. Two screens is the line, and the count is from your files`,
526
+ });
527
+ }
528
+ defined.length = 0;
529
+ defined.push(...keep);
530
+ }
443
531
  /**
444
532
  * THE EVIDENCE PASS - a second walk that touches ONE reading.
445
533
  *
@@ -670,6 +758,14 @@ export async function takeCensus(root, opts) {
670
758
  Object.keys(part.base).length +
671
759
  Object.keys(part.dark).length +
672
760
  Object.values(part.states).reduce((k, block) => k + Object.keys(block).length, 0), 0), 0));
761
+ if (fetched.ok) {
762
+ console.log("");
763
+ console.log(body(paint.faint(`Matched against the live catalogue - ${fetched.index.count} components.`)));
764
+ }
765
+ else {
766
+ console.log("");
767
+ console.log(body(describeFallback(fetched, floorSize())));
768
+ }
673
769
  const coverageLines = describeCoverage(coverage);
674
770
  if (coverageLines.length > 0) {
675
771
  console.log("");
@@ -1,5 +1,6 @@
1
1
  import { readFile } from "node:fs/promises";
2
2
  import { relative, resolve } from "node:path";
3
+ import { readToken, resolveRegistry } from "../config.js";
3
4
  import { fileRequest } from "../doctor/requests.js";
4
5
  import { diagnose, scanSource } from "../doctor/scan.js";
5
6
  import { nearestToken, tokenFor } from "../doctor/tokens.js";
@@ -93,6 +94,29 @@ const TOOLS = [
93
94
  required: ["name"],
94
95
  },
95
96
  },
97
+ {
98
+ name: "recipe_vocabulary",
99
+ description: "What a recipe CAN hold: every state that compiles, every preview form, the floor per kind, and the rules a media region needs. Call this BEFORE writing a recipe, not after - the reader that skipped it sent a `dark:` inside a variant and a state the compiler cannot spell, and both were dropped in silence. Served from the catalogue, so it grows as the contract grows.",
100
+ inputSchema: { type: "object", properties: {} },
101
+ },
102
+ {
103
+ name: "validate_recipe",
104
+ description: "Would this recipe survive being sent? Answers with what would NOT - the properties held by the schema and dropped by the compiler, and what is missing before it can be drawn at all - by name, with the reason. Never a boolean: a boolean tells you to try again, a list tells you what to fix. Call it on every recipe before sending, and write what it returns into `_synthesisui/not-expressed.md`.",
105
+ inputSchema: {
106
+ type: "object",
107
+ properties: {
108
+ name: {
109
+ type: "string",
110
+ description: "The component name, for the report.",
111
+ },
112
+ recipe: {
113
+ type: "object",
114
+ description: "The candidate recipe, in the document's own shape.",
115
+ },
116
+ },
117
+ required: ["recipe"],
118
+ },
119
+ },
96
120
  {
97
121
  name: "request_component",
98
122
  description: "File a component request when nothing in the catalogue covers what you need. This is the OTHER HALF of the refusal rule: you already say which entry you considered and why it did not fit - said in chat, that reasoning evaporates; filed here, it becomes the queue the system's author works from. File it at the moment you build the workaround, while the reasoning is still yours.",
@@ -217,6 +241,126 @@ async function listComponents(root) {
217
241
  * back says what the platform UNDERSTOOD - so a reading that came out thin is
218
242
  * visible instead of being discovered later in a preview.
219
243
  */
244
+ /**
245
+ * WHAT A RECIPE CAN HOLD - served, not written here.
246
+ *
247
+ * The reader had no way to ask, so it sent what it found and the parts we could not hold
248
+ * were dropped in silence: a state the compiler cannot spell, a `dark:` inside a variant,
249
+ * a part name with a dot in it. Four times in one day (dono, 01/08).
250
+ *
251
+ * Fetched rather than embedded, and the owner's reason is the one that decides it: "the
252
+ * properties need to be dynamic - the more we map, the more precision". A list written
253
+ * into a published package is a list that stops matching the contract.
254
+ */
255
+ async function recipeVocabulary() {
256
+ const answer = await askCatalogue("vocabulary");
257
+ if (!answer.ok)
258
+ return answer.because;
259
+ const v = answer.body;
260
+ const lines = [
261
+ "WHAT A RECIPE CAN HOLD",
262
+ "",
263
+ `properties: any CSS property, camelCase. Part names match ${v.partName} - flat and kebab-case, because a nested name compiles to two classes and is invalid CSS.`,
264
+ "",
265
+ `states that compile (${v.states.length}): ${v.states.join(", ")}`,
266
+ `preview forms: ${v.forms.join(", ")}`,
267
+ `a layer's conditions: ${v.layerConditions.join(", ")} - and they combine, so a ghost button's hover at the md breakpoint is one layer`,
268
+ "",
269
+ "THE FLOOR - below this a preview cannot be told apart from the page:",
270
+ ...Object.entries(v.floor).map(([kind, needs]) => ` ${kind}: ${needs.join("; ")}`),
271
+ "",
272
+ "WHAT TO GO LOOK FOR, per kind - work the list and say which ones their code answers:",
273
+ ...Object.entries(v.checklist).map(([kind, items]) => ` ${kind}:\n${items.map((i) => ` - ${i}`).join("\n")}`),
274
+ "",
275
+ "A MEDIA REGION cannot be used without these, and four of the five are about what happens when the picture is not there:",
276
+ ...v.media.map((m) => ` ${m.need} - ${m.why}`),
277
+ ];
278
+ return lines.join("\n");
279
+ }
280
+ /**
281
+ * WOULD THIS RECIPE SURVIVE? - the handshake that makes a loss visible when it happens.
282
+ *
283
+ * Nothing validated a recipe before it was sent: zero mentions of `validate` in the entire
284
+ * import path. So the reader sent, the document held half of it, and nobody noticed until
285
+ * somebody looked at a blank component weeks later.
286
+ */
287
+ async function validateRecipe(name, recipe) {
288
+ const answer = await askCatalogue("validate", { name, recipe });
289
+ if (!answer.ok)
290
+ return answer.because;
291
+ const v = answer.body;
292
+ if (v.ok) {
293
+ return `${name}: everything you sent would survive. Send it.`;
294
+ }
295
+ const lines = [`${name}: this would NOT arrive whole.`, ""];
296
+ if (v.rejected.length > 0) {
297
+ lines.push("REFUSED by the contract - fix these or the whole recipe is dropped:");
298
+ for (const r of v.rejected)
299
+ lines.push(` ${r.at}: ${r.why}`);
300
+ lines.push("");
301
+ }
302
+ if (v.lost.length > 0) {
303
+ lines.push("HELD BY THE SCHEMA AND DROPPED BY THE COMPILER - the silent half, which is the", "one worth fixing before sending:");
304
+ for (const l of v.lost)
305
+ lines.push(` ${l}`);
306
+ lines.push("");
307
+ }
308
+ if (v.missing.length > 0) {
309
+ lines.push("BELOW THE FLOOR - it would arrive and not be drawable:");
310
+ for (const m of v.missing)
311
+ lines.push(` ${m}`);
312
+ lines.push("");
313
+ }
314
+ if (v.checklist.length > 0) {
315
+ lines.push("Go looking for these before you send it again:");
316
+ for (const c of v.checklist)
317
+ lines.push(` - ${c}`);
318
+ }
319
+ lines.push("", "Write this into `_synthesisui/not-expressed.md` - what we cannot hold is the list", "the system's author works from, and said only in chat it evaporates.");
320
+ return lines.join("\n");
321
+ }
322
+ /**
323
+ * The catalogue's door, with the session the device login already granted.
324
+ *
325
+ * Served rather than shipped for the two reasons the owner gave (dono, 01/08): a table in
326
+ * a published package can be read by anyone, and it goes stale - the crosswalk's own copy
327
+ * had 26 names against a catalogue of 41.
328
+ */
329
+ async function askCatalogue(want, post) {
330
+ const token = await readToken();
331
+ if (!token) {
332
+ return {
333
+ ok: false,
334
+ because: "Not signed in, so the catalogue could not answer. Run `synthesisui login` in the terminal and call this again - this one needs a session, unlike `doctor`.",
335
+ };
336
+ }
337
+ const base = resolveRegistry();
338
+ try {
339
+ const res = await fetch(`${base}/api/catalogue${post ? "" : `?want=${want}`}`, post
340
+ ? {
341
+ method: "POST",
342
+ headers: {
343
+ Authorization: `Bearer ${token}`,
344
+ "content-type": "application/json",
345
+ },
346
+ body: JSON.stringify(post),
347
+ }
348
+ : { headers: { Authorization: `Bearer ${token}` } });
349
+ if (!res.ok) {
350
+ return {
351
+ ok: false,
352
+ because: `The catalogue answered ${res.status}. ${res.status === 401 ? "Run `synthesisui login` and try again." : "Nothing was sent."}`,
353
+ };
354
+ }
355
+ return { ok: true, body: await res.json() };
356
+ }
357
+ catch {
358
+ return {
359
+ ok: false,
360
+ because: "No network, so the catalogue could not answer. Everything else in this server works offline; this one does not, and pretending otherwise would mean answering from a stale copy.",
361
+ };
362
+ }
363
+ }
220
364
  async function describeComponent(root, name) {
221
365
  const { documents, requires } = await loadSystem(root);
222
366
  let recipe;
@@ -337,6 +481,10 @@ async function callTool(root, name, args) {
337
481
  return text(await describeComponent(root, String(args.name ?? "")));
338
482
  case "add_component":
339
483
  return text(await addComponent(root, String(args.name ?? "")));
484
+ case "recipe_vocabulary":
485
+ return text(await recipeVocabulary());
486
+ case "validate_recipe":
487
+ return text(await validateRecipe(String(args.name ?? "this component"), args.recipe));
340
488
  case "request_component": {
341
489
  const r = await fileRequest(root, {
342
490
  kind: "component",
@@ -0,0 +1,88 @@
1
+ /**
2
+ * THE CATALOGUE, FETCHED - because a table written into a published package goes stale
3
+ * and can be read by anyone.
4
+ *
5
+ * The crosswalk's own copy was written by hand and had 26 names against a catalogue of 41
6
+ * (dono, 01/08). Twenty of OUR OWN components were invisible to matching - `Textarea`,
7
+ * `Sidebar`, `Menu`, `Stepper`, `EmptyState` and fifteen more - and five names in it no
8
+ * longer existed. Their `Textarea` did not match our `textarea`. At 300 components that
9
+ * gap is 280, and closing it by hand means a version bump and a publish for every
10
+ * component we add. That was forgotten twice in one day.
11
+ *
12
+ * THE FLOOR STAYS. The written table is not deleted: it is what answers when there is no
13
+ * session and no network, which is a real state a person can be in. What changes is that
14
+ * it stops being the truth and starts being the fallback - and the run SAYS when it fell
15
+ * back, with the number that costs, because a silently smaller answer is the thing this
16
+ * whole pipeline keeps being punished for.
17
+ */
18
+ import { readToken, resolveRegistry } from "../config.js";
19
+ /**
20
+ * One call, at the start of a run.
21
+ *
22
+ * Not per component: a handshake for each of 362 components would be 362 round trips to
23
+ * answer a question one table answers. The index is 60 tokens a component and the caller
24
+ * matches locally against it.
25
+ */
26
+ export async function fetchCatalogue(registryFlag) {
27
+ const token = await readToken();
28
+ if (!token) {
29
+ return {
30
+ ok: false,
31
+ why: "no-session",
32
+ because: "no session, so the catalogue could not be fetched - `synthesisui login` and re-run to match against the full list",
33
+ };
34
+ }
35
+ const base = resolveRegistry(registryFlag);
36
+ try {
37
+ const res = await fetch(`${base}/api/catalogue?want=index`, {
38
+ headers: { Authorization: `Bearer ${token}` },
39
+ });
40
+ if (!res.ok) {
41
+ return {
42
+ ok: false,
43
+ why: res.status === 401 ? "no-session" : "refused",
44
+ because: res.status === 401
45
+ ? "the session was refused - `synthesisui login` and re-run"
46
+ : `the catalogue answered ${res.status}`,
47
+ };
48
+ }
49
+ const index = (await res.json());
50
+ if (!Array.isArray(index?.components)) {
51
+ return {
52
+ ok: false,
53
+ why: "refused",
54
+ because: "the catalogue answered in a shape this version does not know",
55
+ };
56
+ }
57
+ return { ok: true, index };
58
+ }
59
+ catch {
60
+ return {
61
+ ok: false,
62
+ why: "offline",
63
+ because: "no network, so the catalogue could not be fetched",
64
+ };
65
+ }
66
+ }
67
+ /**
68
+ * The fetched index as the crosswalk's own table: canonical name → the words it answers
69
+ * to, plus the axes, which is what `axisOverlap` compares against.
70
+ */
71
+ export function asCatalogueTable(index) {
72
+ const synonyms = {};
73
+ const axes = {};
74
+ for (const entry of index.components) {
75
+ synonyms[entry.name] = entry.also;
76
+ axes[entry.name] = entry.axes;
77
+ }
78
+ return { synonyms, axes };
79
+ }
80
+ /**
81
+ * What falling back COST, said with the number.
82
+ *
83
+ * "20 of our own components could not be matched" is a sentence somebody can act on;
84
+ * "using the built-in list" is not.
85
+ */
86
+ export function describeFallback(result, floorSize) {
87
+ return `${result.because}. Matching used the ${floorSize} names built into this CLI instead of the live catalogue, so a component of yours that lines up with something we added recently will read as exclusively yours. That is a smaller answer, not a wrong one - re-run with a session to get the full one.`;
88
+ }
@@ -136,6 +136,35 @@ function adapterFor(source, internal) {
136
136
  }
137
137
  return seen > 0 ? theirs : null;
138
138
  }
139
+ /**
140
+ * HOW MANY SCREENS USE IT - the question that separates a design system from a folder.
141
+ *
142
+ * The gate took a real app from 704 to 271, and 271 names in a vitrine is the same
143
+ * problem with different numbers. `RegisterMultipleCSV`, `FeatureFlagUpdateForm`,
144
+ * `BoxHeaderInfo`, `SendProgressModal` are exclusive, correctly gated, and not design
145
+ * system: they are pieces of one screen.
146
+ *
147
+ * Enumerated before it was built, which is why the threshold is what it is (dono, 01/08):
148
+ *
149
+ * 42 used on NO screen BoxHeaderInfo, Register, Search, Switcher
150
+ * 200 used on ONE screen FeatureFlagUpdateForm, RegisterMultipleCSV
151
+ * 20 used on two screens SkeletonLoader, Group, Header, UploadButton
152
+ * 2 used on three ConfirmButton, Box
153
+ * 7 used on four or more Table, Tag, Tooltip, Button, Loader
154
+ *
155
+ * 242 of 271 (89%) live on one screen or none. Filtering them takes the vitrine from 271
156
+ * to 29 - and the 29 are `Table`, `Tag`, `Tooltip`, `Button`, `Loader`, `Header`, which is
157
+ * what a design system is.
158
+ *
159
+ * TWO SCREENS IS THE LINE, and the distribution is why: the drop from 200 to 20 happens
160
+ * exactly there. A component two screens share is a decision about the product; a
161
+ * component one screen has is that screen's own markup.
162
+ *
163
+ * NOT APPLIED TO A LIBRARY. A library composes nothing inside itself, so every export
164
+ * would read as used on no screen - the same mistake library mode was built to stop. The
165
+ * caller passes the counts only when it has them, and absent means the test is skipped.
166
+ */
167
+ export const SCREENS_FOR_SYSTEM = 2;
139
168
  export function gateComponent(input) {
140
169
  const { name, file, source } = input;
141
170
  if (RESERVED_FILE.test(file) || RESERVED_FILE_OTHER.test(file)) {
@@ -174,6 +203,15 @@ export function gateComponent(input) {
174
203
  because: `\`${name}\` is a capitalised export in a file with no markup - a constant, a config object or a helper`,
175
204
  };
176
205
  }
206
+ if (input.screens != null && input.screens < SCREENS_FOR_SYSTEM) {
207
+ return {
208
+ ok: false,
209
+ why: "feature",
210
+ because: input.screens === 0
211
+ ? `\`${name}\` is composed on no screen at all - it is a piece of markup nothing reaches yet`
212
+ : `\`${name}\` is composed on one screen only, so it is that screen's own markup rather than a decision the product shares. Two screens is the line, and the count is from your files`,
213
+ };
214
+ }
177
215
  if (!HAS_STYLING.test(source)) {
178
216
  return {
179
217
  ok: false,
@@ -195,6 +233,7 @@ export const SKIP_LABEL = {
195
233
  screen: "whole screens, and the frames around them",
196
234
  provider: "providers, contexts and wrappers",
197
235
  adapter: "adapters for another library's components",
236
+ feature: "pieces of one screen, not of the system",
198
237
  config: "constants, config and helpers",
199
238
  "no-jsx": "capitalised exports with no markup",
200
239
  "no-styling": "components that decide nothing about how anything looks",
@@ -204,6 +243,7 @@ export function groupSkips(skips) {
204
243
  "screen",
205
244
  "route",
206
245
  "provider",
246
+ "feature",
207
247
  "adapter",
208
248
  "no-styling",
209
249
  "config",
@@ -155,11 +155,41 @@ const AXIS_SYNONYM = {
155
155
  placement: "side",
156
156
  position: "side",
157
157
  };
158
+ /**
159
+ * THE LIVE TABLE, when a run fetched one.
160
+ *
161
+ * Module-level and set once per run, so every call site gets the same answer without
162
+ * threading the table through nine functions that do not care about it. Absent means the
163
+ * written `SYNONYM` floor answers - which is a smaller answer, and the run says so.
164
+ *
165
+ * The written table had 26 names against a catalogue of 41: twenty of our own components
166
+ * could not be matched at all (dono, 01/08). It stays as the offline floor, not as truth.
167
+ */
168
+ let live = null;
169
+ export function useLiveCatalogue(table) {
170
+ live = table;
171
+ }
172
+ /** How many names the written floor knows - the number a fallback message needs. */
173
+ export function floorSize() {
174
+ return new Set(Object.values(SYNONYM)).size;
175
+ }
158
176
  export function canonicalName(name) {
159
177
  const head = name
160
178
  .split(".")[0]
161
179
  .toLowerCase()
162
180
  .replace(/[^a-z]/g, "");
181
+ /**
182
+ * The live catalogue first, and its own name counts as a synonym for itself: their
183
+ * `Textarea` matching our `textarea` needed nothing clever, only a table that knew
184
+ * `textarea` exists.
185
+ */
186
+ if (live) {
187
+ for (const [canonical, also] of Object.entries(live.synonyms)) {
188
+ const words = [canonical, ...also].map((w) => w.replace(/[^a-z]/g, ""));
189
+ if (words.includes(head))
190
+ return canonical;
191
+ }
192
+ }
163
193
  if (SYNONYM[head])
164
194
  return SYNONYM[head];
165
195
  // A compound like `WidgetCard` or `ArrowDownIcon` is only canonical when the
@@ -379,7 +409,10 @@ opts) {
379
409
  sharedCount(component.props, mine.find((m) => m.name === t.name)?.props ?? {}) >= TWIN_MIN_SHARED &&
380
410
  sharedMeaning(component.props, mine.find((m) => m.name === t.name)?.props ?? {}) >= 1)
381
411
  .sort((a, b) => b.overlap - a.overlap);
382
- if (canonical && CATALOGUE[canonical]) {
412
+ // The LIVE catalogue counts as knowing the name: gating on the written table
413
+ // meant a component matched by the live one fell through to `nearly` (the same
414
+ // staleness, one branch down).
415
+ if (canonical && (CATALOGUE[canonical] || live?.axes[canonical])) {
383
416
  const alsoClaiming = (claims.get(canonical) ?? []).filter((n) => n !== component.name);
384
417
  /**
385
418
  * LIBRARY MODE: the match is a NOTE, not a verdict.
@@ -537,6 +537,49 @@ extensions are configured at construction, not toggled later
537
537
  of their manifest and attaches it (\`"^2.1.0 in packages/ui"\`), so the rule does not go stale the
538
538
  day they upgrade and the information is not lost either.
539
539
 
540
+ ### Ask what a recipe can hold, BEFORE you write one
541
+
542
+ \`\`\`
543
+ recipe_vocabulary what a recipe CAN hold - states, forms, the floor per kind
544
+ validate_recipe(name, r) would this survive? what would NOT, by name
545
+ \`\`\`
546
+
547
+ Both are MCP tools on the \`synthesisui\` server. **Call \`recipe_vocabulary\` once, at the
548
+ start**, and work from what it says rather than from what you remember - it is served from
549
+ the live contract, so it is right about a state that was added last week and this file is
550
+ not.
551
+
552
+ Then **validate every recipe before you send it.** This is not a formality. Nothing checked
553
+ a recipe before these tools existed, and four separate readings were lost in silence: a
554
+ \`dark:\` inside a variant, a state the compiler cannot spell, a part name with a dot in it,
555
+ a layer with an empty style. Each of those was correct work that arrived as nothing.
556
+
557
+ \`validate_recipe\` never answers yes or no. It answers with what would not survive:
558
+
559
+ \`\`\`
560
+ REFUSED by the contract fix it or the WHOLE recipe is dropped
561
+ HELD BY THE SCHEMA AND the silent half - it validates, it compiles to nothing.
562
+ DROPPED BY THE COMPILER This is the one worth fixing before sending.
563
+ BELOW THE FLOOR it would arrive and not be drawable
564
+ \`\`\`
565
+
566
+ ### Write down what we could not hold
567
+
568
+ Everything \`validate_recipe\` reports goes into \`_synthesisui/not-expressed.md\`, appended
569
+ per component:
570
+
571
+ \`\`\`
572
+ ## MetricCard
573
+ - layers[2].when.scheme … (what the validator said, VERBATIM)
574
+ - below the floor: a fill, so it reads as a surface rather than as bare text
575
+ - the source you read, quoted, so the gap can be checked without opening their repo
576
+ \`\`\`
577
+
578
+ **Verbatim, and with the source beside it.** This file is how a gap on OUR side gets fixed
579
+ instead of being worked around: said only in chat it evaporates, and paraphrased it becomes
580
+ unfindable. If the file ends up empty, say so - that is the good outcome and it is worth
581
+ one line.
582
+
540
583
  ### The floor: what a preview needs before it can draw anything
541
584
 
542
585
  The owner asked this directly - "to render the button, what do I need to have?" - and the
@@ -599,6 +642,9 @@ found them wastes a turn and risks contradicting the measurement:
599
642
  - **whether a component forwards the rest of its props**, and which it names
600
643
  - **which components appear inside which** at their call sites, as pairs
601
644
  - **the folder architecture**, and it will ask you which one has priority
645
+ - **which of your components are ours**, matched against the LIVE catalogue rather than a
646
+ list baked into the CLI. If a run says it fell back to the built-in names, the match is a
647
+ smaller answer than it should be and the reason is on screen
602
648
 
603
649
  What is still yours: the anatomy tree, the part names, the frontiers, the roles, the
604
650
  concept, and the rules that are not about a class name.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "synthesisui",
3
- "version": "0.16.83",
3
+ "version": "0.16.84",
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": {