synthesisui 0.16.82 → 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.
- package/dist/commands/import.js +219 -8
- package/dist/commands/mcp.js +148 -0
- package/dist/doctor/catalogue-fetch.js +88 -0
- package/dist/doctor/component-gate.js +40 -0
- package/dist/doctor/coverage.js +129 -0
- package/dist/doctor/crosswalk.js +34 -1
- package/dist/doctor/css-modules.js +535 -0
- package/dist/doctor/transcribe.js +87 -1
- package/dist/doctor/variant-read.js +121 -1
- package/dist/skill-import.js +46 -0
- package/package.json +1 -1
package/dist/commands/import.js
CHANGED
|
@@ -5,17 +5,20 @@ 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
|
-
import {
|
|
13
|
+
import { countShape, describeCoverage, summarizeCoverage, } from "../doctor/coverage.js";
|
|
14
|
+
import { crosswalk, floorSize, isLibrary, observedRules, useLiveCatalogue, } from "../doctor/crosswalk.js";
|
|
15
|
+
import { moduleImports, readModuleCss, readModuleUsage, transcribeModule, } from "../doctor/css-modules.js";
|
|
13
16
|
import { reconcile, scanDefinitions, } from "../doctor/definitions-scan.js";
|
|
14
17
|
import { describeConvention, describeRemainder, detectConventions, } from "../doctor/idiom.js";
|
|
15
18
|
import { diagnose, scanSource } from "../doctor/scan.js";
|
|
16
19
|
import { parseSchemeBlocks } from "../doctor/scheme-blocks.js";
|
|
17
20
|
import { buildTable } from "../doctor/tokens.js";
|
|
18
|
-
import { rootClasses, rootTag, transcribe, } from "../doctor/transcribe.js";
|
|
21
|
+
import { readInlineStyle, rootClasses, rootTag, transcribe, } from "../doctor/transcribe.js";
|
|
19
22
|
import { transcribeVariants } from "../doctor/variant-read.js";
|
|
20
23
|
import { body, paint, section } from "../output.js";
|
|
21
24
|
import { startProgress } from "../progress.js";
|
|
@@ -212,8 +215,36 @@ function distinctValues(d) {
|
|
|
212
215
|
}
|
|
213
216
|
return kept.sort((a, b) => b.count - a.count || a.value.localeCompare(b.value));
|
|
214
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
|
+
}
|
|
215
236
|
export async function takeCensus(root, opts) {
|
|
216
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);
|
|
217
248
|
const css = await harvestOwnCss(root);
|
|
218
249
|
const table = buildTable({ css, source: "yours" });
|
|
219
250
|
const schemes = parseSchemeBlocks(css);
|
|
@@ -243,8 +274,24 @@ export async function takeCensus(root, opts) {
|
|
|
243
274
|
const nesting = new Map();
|
|
244
275
|
/** Component → what its own definition names and whether it forwards the rest. */
|
|
245
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();
|
|
246
283
|
/** Component → the axes its own `cva` declares. See `variant-read.ts`. */
|
|
247
284
|
const variantAxes = new Map();
|
|
285
|
+
/** How much came out of CSS Modules, for the coverage report. */
|
|
286
|
+
let moduleFiles = 0;
|
|
287
|
+
let moduleDeclarations = 0;
|
|
288
|
+
const moduleKeyframes = {};
|
|
289
|
+
/** How each component expresses style, so the census can state its own coverage. */
|
|
290
|
+
const shapes = new Map();
|
|
291
|
+
/** Component FILES the gate accepted - the honest denominator for coverage. A first
|
|
292
|
+
* version used the whole inventory, which includes everything they compose from a
|
|
293
|
+
* package, and reported 128 for a library of 36 (probe, 01/08). */
|
|
294
|
+
let componentFiles = 0;
|
|
248
295
|
/**
|
|
249
296
|
* A census over a real monorepo reads 2797 files, and the terminal said nothing for
|
|
250
297
|
* most of it. Twenty silent minutes is indistinguishable from twenty broken ones
|
|
@@ -287,6 +334,14 @@ export async function takeCensus(root, opts) {
|
|
|
287
334
|
// HOW IT IS USED, not only what it is: which props people pass, what it passes
|
|
288
335
|
// on, and what shows up inside it. See `call-sites.ts`.
|
|
289
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
|
+
}
|
|
290
345
|
const found = [];
|
|
291
346
|
for (const d of scanDefinitions(rel, src)) {
|
|
292
347
|
const verdict = gateComponent({
|
|
@@ -309,6 +364,10 @@ export async function takeCensus(root, opts) {
|
|
|
309
364
|
for (const d of found) {
|
|
310
365
|
propsOf.set(d.name, readDefinitionProps(d.name, src));
|
|
311
366
|
}
|
|
367
|
+
if (found.length > 0) {
|
|
368
|
+
countShape(src, found[0].name, shapes);
|
|
369
|
+
componentFiles += 1;
|
|
370
|
+
}
|
|
312
371
|
defined.push(...found);
|
|
313
372
|
// THE LOOK, from their own class names. One transcription per file, keyed
|
|
314
373
|
// by the component it defines - the root element's classes are the
|
|
@@ -330,20 +389,77 @@ export async function takeCensus(root, opts) {
|
|
|
330
389
|
* paid for twice already.
|
|
331
390
|
*/
|
|
332
391
|
const v = transcribeVariants(src, declaredValues);
|
|
333
|
-
|
|
334
|
-
|
|
392
|
+
/**
|
|
393
|
+
* THE CSS MODULE, if this component styles that way.
|
|
394
|
+
*
|
|
395
|
+
* 239 of 317 components in a real app do, and they arrived with ZERO
|
|
396
|
+
* declarations - 5,777 real CSS declarations sat in 530 stylesheets nobody
|
|
397
|
+
* read (dono, 01/08). Read here rather than in a pass of its own so a
|
|
398
|
+
* component that uses BOTH a module and utilities comes out as one look.
|
|
399
|
+
*/
|
|
400
|
+
const modules = moduleImports(src);
|
|
401
|
+
let fromModule = null;
|
|
402
|
+
for (const mod of modules) {
|
|
403
|
+
const cssPath = join(dirname(file), mod.specifier);
|
|
404
|
+
const moduleCss = await readFile(cssPath, "utf8").catch(() => "");
|
|
405
|
+
if (!moduleCss)
|
|
406
|
+
continue;
|
|
407
|
+
const usage = readModuleUsage(src, mod.local);
|
|
408
|
+
if (usage.length === 0)
|
|
409
|
+
continue;
|
|
410
|
+
const readModule = transcribeModule(readModuleCss(moduleCss), usage, declaredValues);
|
|
411
|
+
if (readModule.read === 0)
|
|
412
|
+
continue;
|
|
413
|
+
fromModule = readModule;
|
|
414
|
+
moduleFiles += 1;
|
|
415
|
+
moduleDeclarations += readModule.read;
|
|
416
|
+
for (const [name, frames] of Object.entries(readModule.keyframes)) {
|
|
417
|
+
if (!moduleKeyframes[name])
|
|
418
|
+
moduleKeyframes[name] = frames;
|
|
419
|
+
}
|
|
420
|
+
// One module per component is the shape every one of the 530 follows.
|
|
421
|
+
break;
|
|
422
|
+
}
|
|
423
|
+
// The MODULE first and the utilities on top: a component that has both is
|
|
424
|
+
// adding a utility to a module-styled element, not the other way round.
|
|
425
|
+
/**
|
|
426
|
+
* `style={{ }}` UNDER everything: a literal is the weakest source, so a class or
|
|
427
|
+
* a module rule that names the same property wins. It travels rather than being
|
|
428
|
+
* dropped because the alternative is the component arriving empty, and empty is
|
|
429
|
+
* not more honest than literal - v1 mirrors, v2 normalises.
|
|
430
|
+
*/
|
|
431
|
+
const inlineStyle = readInlineStyle(src);
|
|
432
|
+
const base = {
|
|
433
|
+
...inlineStyle,
|
|
434
|
+
...fromModule?.base,
|
|
435
|
+
...v.base.base,
|
|
436
|
+
...t.base,
|
|
437
|
+
};
|
|
438
|
+
const states = {
|
|
439
|
+
...fromModule?.states,
|
|
440
|
+
...v.base.states,
|
|
441
|
+
...t.states,
|
|
442
|
+
};
|
|
443
|
+
const dark = { ...fromModule?.dark, ...t.dark };
|
|
444
|
+
const layers = [...(fromModule?.layers ?? []), ...v.layers];
|
|
445
|
+
const parts = { ...fromModule?.parts, ...t.parts };
|
|
446
|
+
const tree = fromModule?.tree ?? [];
|
|
335
447
|
const size = Object.keys(base).length +
|
|
336
|
-
Object.keys(
|
|
448
|
+
Object.keys(dark).length +
|
|
337
449
|
Object.keys(states).length +
|
|
338
|
-
|
|
450
|
+
Object.keys(parts).length +
|
|
451
|
+
layers.length;
|
|
339
452
|
const tag = rootTag(src);
|
|
340
453
|
if (size > 0 || tag) {
|
|
341
454
|
looks[found[0].name] = {
|
|
342
455
|
...t,
|
|
343
456
|
base,
|
|
344
457
|
states,
|
|
458
|
+
dark,
|
|
459
|
+
...(Object.keys(parts).length > 0 ? { parts } : {}),
|
|
460
|
+
...(tree.length > 0 ? { tree } : {}),
|
|
345
461
|
...(tag ? { rootTag: tag } : {}),
|
|
346
|
-
...(
|
|
462
|
+
...(layers.length > 0 ? { layers } : {}),
|
|
347
463
|
...(Object.keys(v.axes).length > 0 ? { declaredAxes: v.axes } : {}),
|
|
348
464
|
...(Object.keys(v.defaults).length > 0
|
|
349
465
|
? { defaults: v.defaults }
|
|
@@ -367,6 +483,51 @@ export async function takeCensus(root, opts) {
|
|
|
367
483
|
})));
|
|
368
484
|
const d = diagnose(reports);
|
|
369
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
|
+
}
|
|
370
531
|
/**
|
|
371
532
|
* THE EVIDENCE PASS - a second walk that touches ONE reading.
|
|
372
533
|
*
|
|
@@ -577,6 +738,41 @@ export async function takeCensus(root, opts) {
|
|
|
577
738
|
console.log(body(paint.faint(`(${theirsOnly.length} component${theirsOnly.length === 1 ? "" : "s"} live there and not in the system - ${shown.join(", ")}${theirsOnly.length > shown.length ? ", …" : ""}. Left out on purpose: the system is one place, and a union of two codebases is not a system. Point --scope at them if they belong in it.)`)));
|
|
578
739
|
}
|
|
579
740
|
}
|
|
741
|
+
/**
|
|
742
|
+
* WHAT THIS VERSION CAN AND CANNOT READ, on their files.
|
|
743
|
+
*
|
|
744
|
+
* Before this, importing a CSS-Modules app produced 283 components with no
|
|
745
|
+
* declarations and said nothing about why - and a person seeing that concludes the
|
|
746
|
+
* product is broken, reasoning correctly from what they were shown. The gap was never
|
|
747
|
+
* the defect; the silence was (dono, 01/08).
|
|
748
|
+
*/
|
|
749
|
+
const coverage = summarizeCoverage(shapes, componentFiles, Object.keys(looks).length, Object.values(looks).reduce((n, look) => n +
|
|
750
|
+
Object.keys(look.base).length +
|
|
751
|
+
Object.keys(look.dark).length +
|
|
752
|
+
Object.values(look.states).reduce((m, block) => m + Object.keys(block).length, 0) +
|
|
753
|
+
// The LAYERS count: the record and ternary readers produce layers rather than a
|
|
754
|
+
// base, so leaving them out reported a Tag with eight colours as three
|
|
755
|
+
// declarations (probe, 01/08).
|
|
756
|
+
(look.layers ?? []).reduce((m, layer) => m + Object.keys(layer.style).length, 0) +
|
|
757
|
+
Object.values(look.parts ?? {}).reduce((m, part) => m +
|
|
758
|
+
Object.keys(part.base).length +
|
|
759
|
+
Object.keys(part.dark).length +
|
|
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
|
+
}
|
|
769
|
+
const coverageLines = describeCoverage(coverage);
|
|
770
|
+
if (coverageLines.length > 0) {
|
|
771
|
+
console.log("");
|
|
772
|
+
console.log(section("What this version reads, on your files"));
|
|
773
|
+
for (const line of coverageLines)
|
|
774
|
+
console.log(body(line));
|
|
775
|
+
}
|
|
580
776
|
/**
|
|
581
777
|
* WHAT THE GATE TURNED AWAY - said out loud, grouped, with the reason.
|
|
582
778
|
*
|
|
@@ -695,6 +891,21 @@ export async function takeCensus(root, opts) {
|
|
|
695
891
|
* printed it, and never turned it into a rule is a feature that does nothing (dono,
|
|
696
892
|
* 01/08).
|
|
697
893
|
*/
|
|
894
|
+
...(coverage.components > 0
|
|
895
|
+
? {
|
|
896
|
+
coverage: {
|
|
897
|
+
components: coverage.components,
|
|
898
|
+
read: coverage.read,
|
|
899
|
+
declarations: coverage.declarations,
|
|
900
|
+
shapes: coverage.counts.map((c) => ({
|
|
901
|
+
key: c.shape.key,
|
|
902
|
+
label: c.shape.label,
|
|
903
|
+
reads: c.shape.reads,
|
|
904
|
+
files: c.files,
|
|
905
|
+
})),
|
|
906
|
+
},
|
|
907
|
+
}
|
|
908
|
+
: {}),
|
|
698
909
|
...(architectures.length > 0
|
|
699
910
|
? {
|
|
700
911
|
architectures: architectures.slice(0, 6).map((a) => ({
|
package/dist/commands/mcp.js
CHANGED
|
@@ -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",
|