synthesisui 0.16.108 → 0.16.110
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/anatomy-read.js +24 -1
- package/dist/commands/import.js +93 -4
- package/dist/doctor/broken-refs.js +46 -0
- package/dist/doctor/place-layers.js +78 -0
- package/dist/doctor/variant-read.js +201 -7
- package/dist/skill-import.js +35 -2
- package/package.json +1 -1
package/dist/anatomy-read.js
CHANGED
|
@@ -167,6 +167,7 @@ sketch) {
|
|
|
167
167
|
const composes = [];
|
|
168
168
|
const external = [];
|
|
169
169
|
const notes = [];
|
|
170
|
+
const partAt = {};
|
|
170
171
|
let budget = MAX_NODES;
|
|
171
172
|
let coerced = 0;
|
|
172
173
|
/**
|
|
@@ -295,6 +296,27 @@ sketch) {
|
|
|
295
296
|
notes.push(`part \`${key}\`: \`${cls}\` starts like a design decision and reads as nothing - usually a typo in the source, and the element renders without it today`);
|
|
296
297
|
}
|
|
297
298
|
node_.part = key;
|
|
299
|
+
/**
|
|
300
|
+
* WHICH SKETCH NODE BECAME THIS PART.
|
|
301
|
+
*
|
|
302
|
+
* The only place both facts exist at once. A lookup table whose value is an
|
|
303
|
+
* object carries one field per element - their `statusConfig` has `dotClass`
|
|
304
|
+
* for the dot and `colorClass` for the label - and the reader can say WHICH
|
|
305
|
+
* element by the call site's class string, which is an index. Turning that
|
|
306
|
+
* index into a part name is this walk's job and nobody else's.
|
|
307
|
+
*/
|
|
308
|
+
if (typeof node.at === "number")
|
|
309
|
+
partAt[node.at] = key;
|
|
310
|
+
/**
|
|
311
|
+
* A HAND-WRITTEN READ NAMES ITS CLASSES, not an index - the skill's own
|
|
312
|
+
* examples do. The string is the sketch's own, so matching it back is exact,
|
|
313
|
+
* and without this the per-part attribution would work on a derived anatomy
|
|
314
|
+
* and quietly do nothing on an authored one.
|
|
315
|
+
*/ else if (raw) {
|
|
316
|
+
const found = (sketch ?? []).findIndex((n) => n.classes === raw);
|
|
317
|
+
if (found >= 0 && !partAt[found])
|
|
318
|
+
partAt[found] = key;
|
|
319
|
+
}
|
|
298
320
|
}
|
|
299
321
|
const text = typeof node.text === "string" ? node.text.trim() : "";
|
|
300
322
|
if (text)
|
|
@@ -434,6 +456,7 @@ sketch) {
|
|
|
434
456
|
composes,
|
|
435
457
|
external,
|
|
436
458
|
notes,
|
|
459
|
+
partAt,
|
|
437
460
|
...(rootOut ? { root: rootOut } : {}),
|
|
438
461
|
};
|
|
439
462
|
}
|
|
@@ -454,5 +477,5 @@ export function resolveFlatParts(read, declared) {
|
|
|
454
477
|
const t = transcribe(part.classes.split(/\s+/).filter(Boolean), declared);
|
|
455
478
|
parts[name] = { base: t.base, dark: t.dark, states: t.states };
|
|
456
479
|
}
|
|
457
|
-
return { parts, tree: [], composes: [], external: [], notes: [] };
|
|
480
|
+
return { parts, tree: [], composes: [], external: [], notes: [], partAt: {} };
|
|
458
481
|
}
|
package/dist/commands/import.js
CHANGED
|
@@ -16,6 +16,7 @@ import { crosswalk, floorSize, isLibrary, observedRules, useLiveCatalogue, } fro
|
|
|
16
16
|
import { moduleImports, readModuleCss, readModuleUsage, transcribeModule, } from "../doctor/css-modules.js";
|
|
17
17
|
import { reconcile, scanDefinitions, } from "../doctor/definitions-scan.js";
|
|
18
18
|
import { describeConvention, describeRemainder, detectConventions, } from "../doctor/idiom.js";
|
|
19
|
+
import { callSiteNode, placeLayers, } from "../doctor/place-layers.js";
|
|
19
20
|
import { diagnose, scanSource } from "../doctor/scan.js";
|
|
20
21
|
import { parseSchemeBlocks } from "../doctor/scheme-blocks.js";
|
|
21
22
|
import { describeSignals, emptySignals, finishSignals, readSignalsInto, } from "../doctor/signals.js";
|
|
@@ -427,6 +428,12 @@ export async function takeCensus(root, opts) {
|
|
|
427
428
|
}
|
|
428
429
|
}
|
|
429
430
|
const t = transcribe(rootClasses(src, found[0].name), declaredValues);
|
|
431
|
+
/**
|
|
432
|
+
* The transcription carries `dark` too, and spreading it put the key back after
|
|
433
|
+
* the conditional below decided to leave it out. Two writers for one field is
|
|
434
|
+
* how a decision gets undone by the line above it.
|
|
435
|
+
*/
|
|
436
|
+
const { dark: _measuredDark, ...withoutDark } = t;
|
|
430
437
|
/**
|
|
431
438
|
* THE VARIANT STYLES, FOLDED IN.
|
|
432
439
|
*
|
|
@@ -518,18 +525,49 @@ export async function takeCensus(root, opts) {
|
|
|
518
525
|
* the owner's rule, adopted verbatim (dono, 01/08).
|
|
519
526
|
*/
|
|
520
527
|
const sketch = sketchOf(src, found[0].name);
|
|
528
|
+
/**
|
|
529
|
+
* A PER-PART LAYER FINDS ITS NODE, by the class string of the call site that
|
|
530
|
+
* consumes the field. See `ReadLayer.onClasses`: the reader knows a
|
|
531
|
+
* `statusConfig.dotClass` is written on the element whose `className` reads
|
|
532
|
+
* `cn("relative size-2 rounded-full", config.dotClass)`, and that literal is
|
|
533
|
+
* exactly what the sketch stored - so this is a lookup, not a second parse.
|
|
534
|
+
*
|
|
535
|
+
* An index rather than the string, because the anatomy already names nodes by
|
|
536
|
+
* index and `resolveReadParts` moves the layer onto the part that node became.
|
|
537
|
+
*/
|
|
538
|
+
const placed = layers.map((layer) => {
|
|
539
|
+
const on = layer.onClasses;
|
|
540
|
+
if (!on)
|
|
541
|
+
return layer;
|
|
542
|
+
const { onClasses: _drop, ...rest } = layer;
|
|
543
|
+
const at = callSiteNode(sketch, on);
|
|
544
|
+
return at === null ? rest : { ...rest, on: at };
|
|
545
|
+
});
|
|
521
546
|
if (size > 0 || tag) {
|
|
522
547
|
looks[found[0].name] = {
|
|
523
548
|
...(sketch.length > 0 ? { sketch } : {}),
|
|
524
|
-
...
|
|
549
|
+
...withoutDark,
|
|
525
550
|
base,
|
|
526
551
|
states,
|
|
527
|
-
|
|
552
|
+
/**
|
|
553
|
+
* OMITTED WHEN EMPTY, so a real loss is legible.
|
|
554
|
+
*
|
|
555
|
+
* `dark` is a field of the READING, not of the contract - the platform
|
|
556
|
+
* converts it into a `{when:{scheme:"dark"}}` layer, which is the one way
|
|
557
|
+
* a recipe says dark. Emitting the key regardless made a validator report
|
|
558
|
+
* all 36 components as "not arriving whole" for a key with nothing in it,
|
|
559
|
+
* and the 7 that carry a real value were buried in the noise
|
|
560
|
+
* (not-expressed.md, dono, 03/08). 29 of 36 were empty.
|
|
561
|
+
*/
|
|
562
|
+
...(Object.keys(dark).length > 0 ? { dark } : {}),
|
|
528
563
|
...(Object.keys(parts).length > 0 ? { parts } : {}),
|
|
529
564
|
...(tree.length > 0 ? { tree } : {}),
|
|
530
565
|
...(tag ? { rootTag: tag } : {}),
|
|
531
566
|
...rootPackage(tag, sketch),
|
|
532
|
-
...(
|
|
567
|
+
...(placed.length > 0 ? { layers: placed } : {}),
|
|
568
|
+
...(v.unslotted.length > 0
|
|
569
|
+
? { unplaced: [...new Set(v.unslotted)] }
|
|
570
|
+
: {}),
|
|
533
571
|
...(Object.keys(v.axes).length > 0 ? { declaredAxes: v.axes } : {}),
|
|
534
572
|
...(Object.keys(defaults).length > 0 ? { defaults } : {}),
|
|
535
573
|
};
|
|
@@ -842,7 +880,7 @@ export async function takeCensus(root, opts) {
|
|
|
842
880
|
*/
|
|
843
881
|
const coverage = summarizeCoverage(shapes, componentFiles, Object.keys(looks).length, Object.values(looks).reduce((n, look) => n +
|
|
844
882
|
Object.keys(look.base).length +
|
|
845
|
-
Object.keys(look.dark).length +
|
|
883
|
+
Object.keys(look.dark ?? {}).length +
|
|
846
884
|
Object.values(look.states).reduce((m, block) => m + Object.keys(block).length, 0) +
|
|
847
885
|
// The LAYERS count: the record and ternary readers produce layers rather than a
|
|
848
886
|
// base, so leaving them out reported a Tag with eight colours as three
|
|
@@ -873,6 +911,31 @@ export async function takeCensus(root, opts) {
|
|
|
873
911
|
console.log(body(line));
|
|
874
912
|
console.log(body(paint.faint("Do not go looking for any of this - it is measured, and a sweep would bring back a different slice than the next one.")));
|
|
875
913
|
}
|
|
914
|
+
/**
|
|
915
|
+
* WHAT THE READER UNDERSTOOD AND REFUSED TO PLACE.
|
|
916
|
+
*
|
|
917
|
+
* The other half of the same honesty: a look with nowhere to go is not put on the
|
|
918
|
+
* component, and a component that quietly lost a decision looks like our bug. See
|
|
919
|
+
* `Transcription.unplaced` for what earns a line here.
|
|
920
|
+
*/
|
|
921
|
+
const unplacedByComponent = Object.entries(looks)
|
|
922
|
+
.filter(([, look]) => (look.unplaced ?? []).length > 0)
|
|
923
|
+
.map(([component, look]) => ({
|
|
924
|
+
component,
|
|
925
|
+
lines: look.unplaced ?? [],
|
|
926
|
+
}));
|
|
927
|
+
if (unplacedByComponent.length > 0) {
|
|
928
|
+
const total = unplacedByComponent.reduce((n, u) => n + u.lines.length, 0);
|
|
929
|
+
console.log("");
|
|
930
|
+
console.log(section("Conditional looks that were read and not placed"));
|
|
931
|
+
console.log(body(`${total} decision${total === 1 ? "" : "s"} across ${unplacedByComponent.length} component${unplacedByComponent.length === 1 ? "" : "s"} were understood and left off, because putting them on the component would paint the wrong element:`));
|
|
932
|
+
for (const u of unplacedByComponent.slice(0, 8)) {
|
|
933
|
+
console.log(body(paint.faint(` ${u.component}: ${u.lines[0]}`)));
|
|
934
|
+
}
|
|
935
|
+
if (unplacedByComponent.length > 8) {
|
|
936
|
+
console.log(body(paint.faint(` and ${unplacedByComponent.length - 8} more components`)));
|
|
937
|
+
}
|
|
938
|
+
}
|
|
876
939
|
/**
|
|
877
940
|
* THE DROPS, SAID OUT LOUD. `p-spacing-sm` on a real menu popup read as nothing and
|
|
878
941
|
* the popup shipped with no padding, invisibly - "if the compiler drops them, the drop
|
|
@@ -1774,8 +1837,34 @@ async function resolveReadParts(census, root) {
|
|
|
1774
1837
|
...(lib.kind !== "own" ? { kind: lib.kind } : {}),
|
|
1775
1838
|
})),
|
|
1776
1839
|
];
|
|
1840
|
+
/**
|
|
1841
|
+
* A PER-PART LAYER LANDS ON ITS PART.
|
|
1842
|
+
*
|
|
1843
|
+
* The reader attributed it to a sketch INDEX (see `Transcription.layers[].on`);
|
|
1844
|
+
* the anatomy is what turns an index into a part name. So this is the first
|
|
1845
|
+
* moment both halves exist - the same reason the reading is resolved here at all.
|
|
1846
|
+
*
|
|
1847
|
+
* A layer whose node no part claims stays OFF the component: pinning it to the
|
|
1848
|
+
* root is exactly the bug, and it is reported instead.
|
|
1849
|
+
*/
|
|
1850
|
+
const partAt = resolved.partAt ?? {};
|
|
1851
|
+
const split = placeLayers((look?.layers ?? []), partAt, (look?.sketch ?? []));
|
|
1852
|
+
for (const line of split.unplaced)
|
|
1853
|
+
notes.add(line);
|
|
1854
|
+
/** The per-part layers, merged into the parts the resolution just named. */
|
|
1855
|
+
for (const [part, list] of split.perPart) {
|
|
1856
|
+
const target = resolved.parts[part];
|
|
1857
|
+
if (!target || !list)
|
|
1858
|
+
continue;
|
|
1859
|
+
target.layers = [
|
|
1860
|
+
...(target.layers ?? []),
|
|
1861
|
+
...list,
|
|
1862
|
+
];
|
|
1863
|
+
}
|
|
1777
1864
|
const carried = {
|
|
1778
1865
|
...(hasParts ? { parts: resolved.parts } : {}),
|
|
1866
|
+
/** The root keeps only what is the root's - see the note above. */
|
|
1867
|
+
...(look?.layers ? { layers: split.root } : {}),
|
|
1779
1868
|
...(resolved.tree.length > 0 ? { tree: resolved.tree } : {}),
|
|
1780
1869
|
...(resolved.composes.length > 0 ? { composes: resolved.composes } : {}),
|
|
1781
1870
|
...(libraries.length > 0 ? { external: libraries } : {}),
|
|
@@ -17,8 +17,51 @@
|
|
|
17
17
|
* this product has (dono, 01/08: "a normalização será de acordo com cada
|
|
18
18
|
* projeto").
|
|
19
19
|
*/
|
|
20
|
+
import { frontierKind } from "../frontier-kind.js";
|
|
21
|
+
import { importMap } from "./imports.js";
|
|
20
22
|
/** `var(--x)`, including inside a Tailwind arbitrary value: `bg-[var(--x)]`. */
|
|
21
23
|
const VAR_REF = /var\(\s*(--[a-zA-Z0-9_-]+)/g;
|
|
24
|
+
/**
|
|
25
|
+
* VARIABLES A HEADLESS LIBRARY SETS AT RUNTIME, which no stylesheet can declare.
|
|
26
|
+
*
|
|
27
|
+
* Base UI measures the trigger and writes `--anchor-width` on the positioner element
|
|
28
|
+
* before paint, so `min-w-[var(--anchor-width)]` on their Select is correct and works.
|
|
29
|
+
* Counting it as a broken reference told the owner their own code was defective, and it
|
|
30
|
+
* cost the report its credibility on the twelve that ARE broken (not-expressed.md,
|
|
31
|
+
* dono, 03/08). Two of their thirteen were this.
|
|
32
|
+
*
|
|
33
|
+
* The list is the SHARED vocabulary of headless positioning - Base UI, Radix, Ark and
|
|
34
|
+
* Floating UI all name the anchor's width `--anchor-width` - plus the self-identifying
|
|
35
|
+
* per-library prefixes. Same reasoning as `NON_RENDERING` in `frontier-kind.ts`: a name
|
|
36
|
+
* is only read after the PACKAGE has answered.
|
|
37
|
+
*/
|
|
38
|
+
const RUNTIME_ANCHOR = new Set([
|
|
39
|
+
"--anchor-width",
|
|
40
|
+
"--anchor-height",
|
|
41
|
+
"--available-width",
|
|
42
|
+
"--available-height",
|
|
43
|
+
"--transform-origin",
|
|
44
|
+
"--popup-width",
|
|
45
|
+
"--popup-height",
|
|
46
|
+
"--trigger-width",
|
|
47
|
+
"--trigger-height",
|
|
48
|
+
]);
|
|
49
|
+
/** `--radix-popper-anchor-width` and its siblings in the other libraries. */
|
|
50
|
+
const RUNTIME_PREFIX = /^--(?:radix|reka|ark|kb|floating|reactour)-/;
|
|
51
|
+
/**
|
|
52
|
+
* Does this file import a library that supplies such a variable?
|
|
53
|
+
*
|
|
54
|
+
* THE PACKAGE DECIDES, never the variable name on its own: a project that declares no
|
|
55
|
+
* headless dependency and writes `var(--anchor-width)` really has written a reference
|
|
56
|
+
* to nothing, and saying so is the whole value of this scan.
|
|
57
|
+
*/
|
|
58
|
+
function importsHeadless(source) {
|
|
59
|
+
for (const spec of Object.values(importMap(source))) {
|
|
60
|
+
if (frontierKind(spec) === "headless")
|
|
61
|
+
return true;
|
|
62
|
+
}
|
|
63
|
+
return false;
|
|
64
|
+
}
|
|
22
65
|
/**
|
|
23
66
|
* PREFIXES A DECLARATION MAY CARRY that a reference dropped.
|
|
24
67
|
*
|
|
@@ -46,10 +89,13 @@ const NAMESPACES = [
|
|
|
46
89
|
export function findBrokenRefs(sources, declared) {
|
|
47
90
|
const seen = new Map();
|
|
48
91
|
for (const { file, source } of sources) {
|
|
92
|
+
const runtime = importsHeadless(source);
|
|
49
93
|
for (const m of source.matchAll(VAR_REF)) {
|
|
50
94
|
const name = m[1];
|
|
51
95
|
if (declared.has(name))
|
|
52
96
|
continue;
|
|
97
|
+
if (runtime && (RUNTIME_ANCHOR.has(name) || RUNTIME_PREFIX.test(name)))
|
|
98
|
+
continue;
|
|
53
99
|
const hit = seen.get(name) ?? { count: 0, files: new Set() };
|
|
54
100
|
hit.count += 1;
|
|
55
101
|
hit.files.add(file);
|
|
@@ -0,0 +1,78 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* WHOSE LOOK IS IT - the step between reading a lookup table and styling anything.
|
|
3
|
+
*
|
|
4
|
+
* A conditional look read off a record is not automatically the component's. Their
|
|
5
|
+
* CollapsibleCard keeps one table for four statuses:
|
|
6
|
+
*
|
|
7
|
+
* statusConfig.success = { dotClass: "bg-success-500 shadow-[…]",
|
|
8
|
+
* colorClass: "text-success-500", text: "Completed" }
|
|
9
|
+
*
|
|
10
|
+
* `dotClass` is worn by a 8px dot and `colorClass` by the label beside it, and merging
|
|
11
|
+
* them onto the component painted a white card with the dot's grey - four runs in a row
|
|
12
|
+
* where the owner saw a grey card and a status that had nothing to do with it
|
|
13
|
+
* (dono, 03/08). The same shape with a plain string is the bigger half: their
|
|
14
|
+
* HorizontalProgress writes `variantColors[variant]` on the FILL, so six colours landed
|
|
15
|
+
* on the outer wrapper and the whole component turned ocean-500.
|
|
16
|
+
*
|
|
17
|
+
* Measured across their library, 36 component files (dono, 03/08):
|
|
18
|
+
*
|
|
19
|
+
* 65 conditional looks come off a lookup table
|
|
20
|
+
* 28 belong to an element INSIDE the component <- painted the component before
|
|
21
|
+
* 37 belong to the returned element <- already correct, unchanged
|
|
22
|
+
* 0 could not be resolved
|
|
23
|
+
*
|
|
24
|
+
* THE READER SAYS WHICH ELEMENT BY ITS CLASS STRING, because that is the one thing
|
|
25
|
+
* both halves of the pipeline already hold: `ReadLayer.onClasses` is the unconditional
|
|
26
|
+
* part of the `className` call that consumes the field, and the sketch stored exactly
|
|
27
|
+
* that string for the node. So this is a lookup rather than a second parse of the JSX.
|
|
28
|
+
*/
|
|
29
|
+
/**
|
|
30
|
+
* The sketch index of the element whose `className` call was read, or null.
|
|
31
|
+
*
|
|
32
|
+
* Exact on the whole string: the sketch and the reader build it from the same
|
|
33
|
+
* unconditional literals, so a partial match would mean one of them changed its rule
|
|
34
|
+
* and guessing past that is how a look lands on the wrong element.
|
|
35
|
+
*/
|
|
36
|
+
export function callSiteNode(sketch, onClasses) {
|
|
37
|
+
const at = sketch.findIndex((node) => node.classes === onClasses);
|
|
38
|
+
return at >= 0 ? at : null;
|
|
39
|
+
}
|
|
40
|
+
/** True when this index is the element the component returns. */
|
|
41
|
+
export function isRootNode(sketch, at) {
|
|
42
|
+
const stamped = sketch.findIndex((node) => node.root);
|
|
43
|
+
return stamped >= 0 ? at === stamped : at === 0;
|
|
44
|
+
}
|
|
45
|
+
/**
|
|
46
|
+
* Split the layers into the ones the component wears and the ones its parts wear.
|
|
47
|
+
*
|
|
48
|
+
* `partAt` is the anatomy's answer - sketch index → the part name that node became,
|
|
49
|
+
* from `resolveAnatomy`. A layer whose node is the ROOT stays on the component, which
|
|
50
|
+
* is where a table indexed on the returned element belongs. A layer whose node no part
|
|
51
|
+
* names goes into `unplaced` instead of onto the component: pinning it to the root is
|
|
52
|
+
* exactly the bug this exists for.
|
|
53
|
+
*/
|
|
54
|
+
export function placeLayers(layers, partAt, sketch) {
|
|
55
|
+
const root = [];
|
|
56
|
+
const perPart = new Map();
|
|
57
|
+
const unplaced = [];
|
|
58
|
+
for (const layer of layers) {
|
|
59
|
+
const { on, ...rest } = layer;
|
|
60
|
+
if (typeof on !== "number") {
|
|
61
|
+
root.push(rest);
|
|
62
|
+
continue;
|
|
63
|
+
}
|
|
64
|
+
const part = partAt[on];
|
|
65
|
+
if (part) {
|
|
66
|
+
const list = perPart.get(part) ?? [];
|
|
67
|
+
list.push(rest);
|
|
68
|
+
perPart.set(part, list);
|
|
69
|
+
continue;
|
|
70
|
+
}
|
|
71
|
+
if (isRootNode(sketch, on)) {
|
|
72
|
+
root.push(rest);
|
|
73
|
+
continue;
|
|
74
|
+
}
|
|
75
|
+
unplaced.push(`a conditional look belongs to an element the anatomy does not name, so it is not applied to the component - that would paint the wrong element`);
|
|
76
|
+
}
|
|
77
|
+
return { root, perPart, unplaced };
|
|
78
|
+
}
|
|
@@ -548,11 +548,120 @@ export function readInlineConditions(source) {
|
|
|
548
548
|
const LOOKUP_RECORD = /(?:const|let)\s+([A-Za-z_$][\w$]*)\s*(?::\s*(?:Record|Partial<Record)<[^>]*>+)?\s*=\s*\{/g;
|
|
549
549
|
/** `colorStyles[color]` - the record, and the prop that indexes it. */
|
|
550
550
|
const RECORD_INDEX = /([A-Za-z_$][\w$]*)\s*\[\s*([A-Za-z_$][\w$]*)\s*\]/g;
|
|
551
|
+
/**
|
|
552
|
+
* The FIELDS of an option whose value is an object, or `[]` when it is a plain
|
|
553
|
+
* string. `{ dotClass: "bg-x", text: "Waiting" }` → two entries.
|
|
554
|
+
*/
|
|
555
|
+
function objectFields(value) {
|
|
556
|
+
const trimmed = value.trim();
|
|
557
|
+
if (!trimmed.startsWith("{"))
|
|
558
|
+
return [];
|
|
559
|
+
const end = endOf(trimmed, 0);
|
|
560
|
+
if (end === -1)
|
|
561
|
+
return [];
|
|
562
|
+
return topLevelEntries(trimmed.slice(1, end - 1));
|
|
563
|
+
}
|
|
564
|
+
/**
|
|
565
|
+
* THE STATIC CLASSES OF THE `className` CALL THAT CONSUMES A LOOKUP.
|
|
566
|
+
*
|
|
567
|
+
* `cn("relative size-2 rounded-full", config.dotClass)` identifies the element
|
|
568
|
+
* without a second parse of the JSX: that literal is exactly what the sketch stored
|
|
569
|
+
* for the node, so the caller can resolve it to an index and move the layer onto the
|
|
570
|
+
* part it belongs to.
|
|
571
|
+
*
|
|
572
|
+
* TWO SPELLINGS, because both are real in one library. A field is matched by the FIELD
|
|
573
|
+
* name and the symbol is matched loosely, since their code reads the record into a
|
|
574
|
+
* local first (`const config = statusConfig[status]`); a record whose value is a plain
|
|
575
|
+
* string has no field, and there the indexing expression is what is written at the call
|
|
576
|
+
* site (`variantColors[variant]`), so the SYMBOL is the reliable half.
|
|
577
|
+
*/
|
|
578
|
+
function callSiteClasses(source, symbol, field) {
|
|
579
|
+
const use = field
|
|
580
|
+
? new RegExp(`\\b[A-Za-z_$][\\w$]*(?:\\[[^\\]]*\\])?\\.${field}\\b`)
|
|
581
|
+
: new RegExp(`\\b${symbol}\\s*\\[`);
|
|
582
|
+
for (const m of source.matchAll(/className=\{\s*(?:[A-Za-z_$][\w$]*\.)?(?:cn|clsx|classNames|cx|twMerge|twJoin|classcat)\(/g)) {
|
|
583
|
+
const open = (m.index ?? 0) + m[0].length - 1;
|
|
584
|
+
const end = endOf(source, open);
|
|
585
|
+
if (end === -1)
|
|
586
|
+
continue;
|
|
587
|
+
const call = source.slice(open + 1, end - 1);
|
|
588
|
+
if (!use.test(call))
|
|
589
|
+
continue;
|
|
590
|
+
const statics = unconditionalClasses(call);
|
|
591
|
+
return statics.length > 0 ? statics.join(" ") : null;
|
|
592
|
+
}
|
|
593
|
+
return null;
|
|
594
|
+
}
|
|
595
|
+
/**
|
|
596
|
+
* THE CLASSES THE ELEMENT ALWAYS HAS, which is the only half that identifies it.
|
|
597
|
+
*
|
|
598
|
+
* Every string literal in the call is not the answer: their Card writes
|
|
599
|
+
* `borderColor === "vibrant" && variant === "full-border" && "dark:after:…"`, so a
|
|
600
|
+
* blanket read picks up the mask gradient AND the two comparison operands, and the
|
|
601
|
+
* result matches no element in the sketch. The sketch stores the unconditional
|
|
602
|
+
* literals, so this has to read the same thing or the lookup cannot land.
|
|
603
|
+
*
|
|
604
|
+
* An argument is unconditional when it is a string and nothing else - one literal,
|
|
605
|
+
* no `&&`, no `?:`, no identifier.
|
|
606
|
+
*/
|
|
607
|
+
function unconditionalClasses(call) {
|
|
608
|
+
const out = [];
|
|
609
|
+
let depth = 0;
|
|
610
|
+
let quote = "";
|
|
611
|
+
let start = 0;
|
|
612
|
+
const args = [];
|
|
613
|
+
for (let i = 0; i < call.length; i++) {
|
|
614
|
+
const c = call[i];
|
|
615
|
+
if (quote) {
|
|
616
|
+
if (c === "\\")
|
|
617
|
+
i++;
|
|
618
|
+
else if (c === quote)
|
|
619
|
+
quote = "";
|
|
620
|
+
continue;
|
|
621
|
+
}
|
|
622
|
+
if (c === '"' || c === "'" || c === "`")
|
|
623
|
+
quote = c;
|
|
624
|
+
else if ("([{".includes(c))
|
|
625
|
+
depth++;
|
|
626
|
+
else if (")]}".includes(c))
|
|
627
|
+
depth--;
|
|
628
|
+
else if (c === "," && depth === 0) {
|
|
629
|
+
args.push(call.slice(start, i));
|
|
630
|
+
start = i + 1;
|
|
631
|
+
}
|
|
632
|
+
}
|
|
633
|
+
args.push(call.slice(start));
|
|
634
|
+
for (const arg of args) {
|
|
635
|
+
const trimmed = arg.trim();
|
|
636
|
+
if (!/^(["'`])/.test(trimmed))
|
|
637
|
+
continue;
|
|
638
|
+
// A literal followed by anything else is a condition, not a constant.
|
|
639
|
+
const closed = endOfString(trimmed);
|
|
640
|
+
if (closed === -1 || trimmed.slice(closed).trim())
|
|
641
|
+
continue;
|
|
642
|
+
out.push(...literalClasses(trimmed));
|
|
643
|
+
}
|
|
644
|
+
return out;
|
|
645
|
+
}
|
|
646
|
+
/** The index just past the closing quote of the string that starts at 0, or -1. */
|
|
647
|
+
function endOfString(text) {
|
|
648
|
+
const quote = text[0];
|
|
649
|
+
for (let i = 1; i < text.length; i++) {
|
|
650
|
+
if (text[i] === "\\")
|
|
651
|
+
i++;
|
|
652
|
+
else if (text[i] === quote)
|
|
653
|
+
return i + 1;
|
|
654
|
+
}
|
|
655
|
+
return -1;
|
|
656
|
+
}
|
|
551
657
|
export function readLookupRecords(source,
|
|
552
658
|
/** Filled with axis → options, because the axis NAMES have to travel too: a first pass
|
|
553
659
|
* sent the styles and not the names, so a Tag with eight colours reported no axis at
|
|
554
660
|
* all (probe, 01/08). */
|
|
555
|
-
axesOut
|
|
661
|
+
axesOut,
|
|
662
|
+
/** Fields of a per-part table that no `className` call claims - said out loud
|
|
663
|
+
* rather than pinned to the root. See `ReadLayer.onClasses`. */
|
|
664
|
+
unattributed) {
|
|
556
665
|
const clean = stripComments(source);
|
|
557
666
|
// Which prop indexes which record. Read first, because a record nobody indexes is a
|
|
558
667
|
// constant rather than a variant axis.
|
|
@@ -584,10 +693,49 @@ axesOut) {
|
|
|
584
693
|
axesOut[axis] = [...new Set([...(axesOut[axis] ?? []), ...names])];
|
|
585
694
|
}
|
|
586
695
|
for (const option of options) {
|
|
587
|
-
|
|
588
|
-
|
|
589
|
-
|
|
590
|
-
|
|
696
|
+
/**
|
|
697
|
+
* A VALUE THAT IS AN OBJECT IS A PER-PART TABLE, and merging its fields is
|
|
698
|
+
* what painted a card with its status dot's grey. Read field by field, and
|
|
699
|
+
* attribute each one to the element that consumes it.
|
|
700
|
+
*/
|
|
701
|
+
const fields = objectFields(option.value);
|
|
702
|
+
if (fields.length === 0) {
|
|
703
|
+
/**
|
|
704
|
+
* A STRING-VALUED RECORD IS STILL SOMEBODY'S ELEMENT, and it is the bigger
|
|
705
|
+
* half of the form: their HorizontalProgress writes `variantColors[variant]`
|
|
706
|
+
* on the FILL, and six colours landed on the outer wrapper - the whole
|
|
707
|
+
* component painted ocean-500 instead of the bar inside it.
|
|
708
|
+
*
|
|
709
|
+
* Unattributed here means the call site could not be found, and that is the
|
|
710
|
+
* old behaviour: the layer stays on the component, which is where a record
|
|
711
|
+
* consumed at the root belongs anyway.
|
|
712
|
+
*/
|
|
713
|
+
const on = callSiteClasses(clean, symbol);
|
|
714
|
+
const per = splitByCondition(literalClasses(option.value), {
|
|
715
|
+
[axis]: option.key,
|
|
716
|
+
});
|
|
717
|
+
for (const layer of per.layers)
|
|
718
|
+
layers.push(on ? { ...layer, onClasses: on } : layer);
|
|
719
|
+
continue;
|
|
720
|
+
}
|
|
721
|
+
for (const field of fields) {
|
|
722
|
+
const classes = literalClasses(field.value);
|
|
723
|
+
if (classes.length === 0)
|
|
724
|
+
continue;
|
|
725
|
+
const on = callSiteClasses(clean, symbol, field.key);
|
|
726
|
+
if (!on) {
|
|
727
|
+
/**
|
|
728
|
+
* NOWHERE TO PUT IT IS NOT THE ROOT. A field no `className` claims is a
|
|
729
|
+
* decision we cannot place, and placing it on the component would paint
|
|
730
|
+
* the wrong element - which is the whole bug this reads around.
|
|
731
|
+
*/
|
|
732
|
+
unattributed?.push(`\`${symbol}.${field.key}\` is a field of a per-element table that no \`className\` call consumes, so there is no element to put it on`);
|
|
733
|
+
continue;
|
|
734
|
+
}
|
|
735
|
+
const per = splitByCondition(classes, { [axis]: option.key });
|
|
736
|
+
for (const layer of per.layers)
|
|
737
|
+
layers.push({ ...layer, onClasses: on });
|
|
738
|
+
}
|
|
591
739
|
}
|
|
592
740
|
}
|
|
593
741
|
return layers;
|
|
@@ -645,17 +793,17 @@ export function readClassTernaries(source) {
|
|
|
645
793
|
export function transcribeVariants(source, declared) {
|
|
646
794
|
const reads = readVariants(source);
|
|
647
795
|
const fromRecords = {};
|
|
796
|
+
const unslotted = [];
|
|
648
797
|
const inline = [
|
|
649
798
|
...readInlineConditions(source),
|
|
650
799
|
// The two shapes the enumeration found to be five times more common than `cva`.
|
|
651
|
-
...readLookupRecords(source, fromRecords),
|
|
800
|
+
...readLookupRecords(source, fromRecords, unslotted),
|
|
652
801
|
...readClassTernaries(source),
|
|
653
802
|
];
|
|
654
803
|
const axes = {};
|
|
655
804
|
const defaults = {};
|
|
656
805
|
const allLayers = [];
|
|
657
806
|
const baseClasses = [];
|
|
658
|
-
const unslotted = [];
|
|
659
807
|
for (const read of reads) {
|
|
660
808
|
baseClasses.push(...read.base);
|
|
661
809
|
allLayers.push(...read.layers);
|
|
@@ -674,6 +822,51 @@ export function transcribeVariants(source, declared) {
|
|
|
674
822
|
if (options.length >= 2)
|
|
675
823
|
axes[axis] = options;
|
|
676
824
|
}
|
|
825
|
+
/**
|
|
826
|
+
* AN AXIS A LAYER GATES ON MUST BE DECLARED - or the default cannot be emitted.
|
|
827
|
+
*
|
|
828
|
+
* The same pass emitted a layer gated on `variant` and did not register `variant`
|
|
829
|
+
* as an axis: measured on a real library, 30 of 148 root layers name an axis
|
|
830
|
+
* `declaredAxes` never lists, and four of those are REAL option axes
|
|
831
|
+
* (`Input.variant`, `Textarea.variant`, `SelectionCard.size`, `RadioCard.status`).
|
|
832
|
+
* Undeclared means `defaults` cannot name it, so the compiler never emits the
|
|
833
|
+
* resting option at the bare selector - the same mechanism that made a button
|
|
834
|
+
* arrive with no fill (dono, 03/08).
|
|
835
|
+
*
|
|
836
|
+
* A gate whose values are only `true`/`false` is a BOOLEAN condition, not a design
|
|
837
|
+
* axis: `isLoading`, `isUser`, `darkMode`, and even an `onInfoClick` handler read
|
|
838
|
+
* that way. Those keep their layer - their code really does change look - and are
|
|
839
|
+
* named in `unslotted`, because a consumer has to set the attribute for it to fire
|
|
840
|
+
* and silence would make it dead CSS nobody knows about.
|
|
841
|
+
*/
|
|
842
|
+
const BOOLEANISH = new Set(["true", "false"]);
|
|
843
|
+
const gated = {};
|
|
844
|
+
for (const layer of allLayers) {
|
|
845
|
+
for (const [axis, option] of Object.entries(layer.when?.variant ?? {})) {
|
|
846
|
+
const seen = gated[axis] ?? new Set();
|
|
847
|
+
seen.add(option);
|
|
848
|
+
gated[axis] = seen;
|
|
849
|
+
}
|
|
850
|
+
}
|
|
851
|
+
for (const [axis, options] of Object.entries(gated)) {
|
|
852
|
+
if (axes[axis])
|
|
853
|
+
continue;
|
|
854
|
+
const named = [...options].filter((o) => !BOOLEANISH.has(o));
|
|
855
|
+
/**
|
|
856
|
+
* TWO OPTIONS IS WHAT MAKES AN AXIS, and that rule is older than this one: one
|
|
857
|
+
* option is not a closed set somebody chose from, it is how the component is
|
|
858
|
+
* always used - and declaring it would turn every other value into drift on the
|
|
859
|
+
* strength of a single sighting.
|
|
860
|
+
*/
|
|
861
|
+
if (named.length >= 2) {
|
|
862
|
+
axes[axis] = named;
|
|
863
|
+
continue;
|
|
864
|
+
}
|
|
865
|
+
const attr = axis.replace(/([a-z0-9])([A-Z])/g, "$1-$2").toLowerCase();
|
|
866
|
+
unslotted.push(named.length === 0
|
|
867
|
+
? `\`${axis}\` gates a look and is a boolean rather than an axis - the layer is kept, and \`data-${attr}\` has to be set for it to apply`
|
|
868
|
+
: `\`${axis}\` gates a look on one option (\`${named[0]}\`) - one option is not an axis, so the layer is kept and \`data-${attr}="${named[0]}"\` has to be set for it to apply`);
|
|
869
|
+
}
|
|
677
870
|
const layers = allLayers
|
|
678
871
|
.map((layer) => {
|
|
679
872
|
/**
|
|
@@ -690,6 +883,7 @@ export function transcribeVariants(source, declared) {
|
|
|
690
883
|
return {
|
|
691
884
|
...(layer.when ? { when: layer.when } : {}),
|
|
692
885
|
...(layer.at ? { at: layer.at } : {}),
|
|
886
|
+
...(layer.onClasses ? { onClasses: layer.onClasses } : {}),
|
|
693
887
|
style,
|
|
694
888
|
};
|
|
695
889
|
})
|
package/dist/skill-import.js
CHANGED
|
@@ -388,9 +388,15 @@ Open the project and answer the questions below. Then add a \`reading\` object t
|
|
|
388
388
|
"name": "body",
|
|
389
389
|
"at": 2,
|
|
390
390
|
"children": [
|
|
391
|
-
{ "as": "
|
|
391
|
+
{ "as": "component", "ref": "Text", "at": 3 },
|
|
392
392
|
{ "as": "heading", "name": "value", "at": 4 }
|
|
393
393
|
]
|
|
394
|
+
},
|
|
395
|
+
{
|
|
396
|
+
"as": "component",
|
|
397
|
+
"ref": "Tooltip",
|
|
398
|
+
"at": 5,
|
|
399
|
+
"children": [{ "as": "icon", "name": "info", "at": 6 }]
|
|
394
400
|
}
|
|
395
401
|
]
|
|
396
402
|
},
|
|
@@ -399,7 +405,7 @@ Open the project and answer the questions below. Then add a \`reading\` object t
|
|
|
399
405
|
"anatomy": [
|
|
400
406
|
{ "as": "image", "name": "cover", "at": 1 },
|
|
401
407
|
{ "as": "heading", "name": "title", "at": 2 },
|
|
402
|
-
{ "as": "component", "ref": "TextEditor" },
|
|
408
|
+
{ "as": "component", "ref": "TextEditor", "at": 5 },
|
|
403
409
|
{
|
|
404
410
|
"as": "row",
|
|
405
411
|
"name": "footer",
|
|
@@ -702,6 +708,21 @@ because a button's content model is *phrasing* and an icon is phrasing. So an \`
|
|
|
702
708
|
Everything else is a leaf, and a leaf that holds elements has them HOISTED OUT to become
|
|
703
709
|
its siblings - which destroys the nesting you read. If something arranges, say \`row\`/\`stack\`.
|
|
704
710
|
|
|
711
|
+
**A frontier keeps its own \`at\`, so it keeps its own classes.** This is the one that gets
|
|
712
|
+
talked out of: \`<Text className="text-lightgray-500 font-medium">\` is one of THEIR
|
|
713
|
+
components, so it is a \`component\` node - and pointing \`at\` its sketch index carries the
|
|
714
|
+
typography onto it exactly as a part would. Reading it as a \`text\` part instead is a
|
|
715
|
+
downgrade with a real cost: \`composes\` is built from \`component\` nodes, so a library whose
|
|
716
|
+
most-used atom is composed 363 times reported composing it ZERO times, and its strongest
|
|
717
|
+
composition law became invisible (not-expressed.md, dono, 03/08).
|
|
718
|
+
|
|
719
|
+
<Tooltip><Info/></Tooltip> frontier with a child, both survive
|
|
720
|
+
<Text className="…">Label</Text> frontier with "at", the type survives
|
|
721
|
+
|
|
722
|
+
If you find yourself writing "the contract cannot hold this, so I sent something else",
|
|
723
|
+
check it against \`recipe_vocabulary\` before you write the workaround - and if the check
|
|
724
|
+
disagrees with this file, the check is right and this file is old.
|
|
725
|
+
|
|
705
726
|
### \`{children}\` is a node, not an absence
|
|
706
727
|
|
|
707
728
|
Four things decide what a component looks like on screen, and only the first is in its own source:
|
|
@@ -847,6 +868,18 @@ a recipe before these tools existed, and four separate readings were lost in sil
|
|
|
847
868
|
\`dark:\` inside a variant, a state the compiler cannot spell, a part name with a dot in it,
|
|
848
869
|
a layer with an empty style. Each of those was correct work that arrived as nothing.
|
|
849
870
|
|
|
871
|
+
**A FIELD OF THE READING IS NOT A FIELD OF THE RECIPE.** \`looks[X].dark\` is how the census
|
|
872
|
+
reports a dark half it measured; a recipe has one way to say the same thing, and it is a
|
|
873
|
+
layer:
|
|
874
|
+
|
|
875
|
+
\`\`\`json
|
|
876
|
+
{ "when": { "scheme": "dark" }, "style": { "backgroundColor": "{color.darkgray.300}" } }
|
|
877
|
+
\`\`\`
|
|
878
|
+
|
|
879
|
+
Copying \`dark\` in at the top level of a recipe costs you that block, silently, on every
|
|
880
|
+
component that has one - and \`import\` already converts it for the recipes IT writes, so the
|
|
881
|
+
only place this can be lost is a recipe you hand-built (not-expressed.md, dono, 03/08).
|
|
882
|
+
|
|
850
883
|
**In ONE call.** Build every recipe in memory, then send the whole reading to
|
|
851
884
|
\`validate_recipes\` - one round trip for 35 components instead of 35. \`validate_recipe\`
|
|
852
885
|
(singular) is for the one recipe you rewrote after the batch named it; reaching for it in a
|
package/package.json
CHANGED