synthesisui 0.16.110 → 0.16.112

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.
@@ -308,12 +308,30 @@ versionOf) {
308
308
  */
309
309
  const local = capitalised && !node.from;
310
310
  /**
311
- * A WRAPPER THAT RENDERS NOTHING passes its children up in its own place: a
312
- * portal teleports and a provider is context. Keeping it would draw a box
313
- * that does not exist in the client's output.
311
+ * A WRAPPER THAT RENDERS NOTHING DRAWS NOTHING AND IS STILL REQUIRED.
312
+ *
313
+ * A portal teleports and a provider is context, so keeping it as a box would draw
314
+ * something the client's output does not have. But `Collapsible.Panel`,
315
+ * `Dialog.Portal` and `Select.Positioner` are what make the component WORK, and the
316
+ * usage snippet was leaving them out: 27 of them across 8 of their components, so
317
+ * somebody pasting the snippet got a card that never opens (dono, 03/08).
318
+ *
319
+ * So it keeps its place in the tree with no `name` - no part, no styles, nothing for
320
+ * the Canvas to draw, and an element the snippet emits. A motion wrapper stays
321
+ * flattened: it is animation, and its element is genuinely absent from the output.
314
322
  */
315
- if ((kind === "motion" || kind === "headless") &&
316
- rendersNothing(tag, classes)) {
323
+ if (kind === "headless" && rendersNothing(tag, classes)) {
324
+ const held = children.flatMap((child) => build(child, depth + 1));
325
+ return [
326
+ {
327
+ as: held.length > 0 ? "stack" : "text",
328
+ from: String(node.from ?? tag),
329
+ refName: tag,
330
+ ...(held.length > 0 ? { children: held } : {}),
331
+ },
332
+ ];
333
+ }
334
+ if (kind === "motion" && rendersNothing(tag, classes)) {
317
335
  return children.flatMap((child) => build(child, depth));
318
336
  }
319
337
  // `<AnimatePresence>` and friends: no element of their own.
@@ -436,10 +454,27 @@ versionOf) {
436
454
  },
437
455
  ];
438
456
  }
457
+ /**
458
+ * A BOX OF OURS THAT IS SOMEBODY ELSE'S ELEMENT SAYS SO.
459
+ *
460
+ * A headless primitive is ours to draw - the classes on it are the client's - and
461
+ * that is why it becomes a PART rather than a frontier. But the element itself is
462
+ * `<Collapsible.Trigger>`, and the usage snippet was emitting a plain `<div>` with
463
+ * our class on it: a person following it gets the look and none of the behaviour, no
464
+ * `aria-expanded` and no keyboard (dono, 03/08). Measured on their library: 31 parts
465
+ * across 9 components are a library element, plus 11 roots.
466
+ *
467
+ * The package AND their own spelling, because the snippet needs both - the import
468
+ * line comes from the first and the tag from the second.
469
+ */
470
+ const fromLib = kind === "headless" && node.from
471
+ ? { from: String(node.from), refName: tag }
472
+ : {};
439
473
  const built = {
440
474
  as,
441
475
  ...(name ? { name } : {}),
442
476
  ...(classes ? { at: index } : {}),
477
+ ...fromLib,
443
478
  ...(node.text ? { text: node.text } : {}),
444
479
  };
445
480
  const inside = hasKids ? descend() : [];
@@ -265,6 +265,17 @@ sketch) {
265
265
  continue;
266
266
  }
267
267
  const node_ = { as };
268
+ /**
269
+ * A PART THAT IS A LIBRARY ELEMENT CARRIES BOTH FACTS - see `AnatomyRead.from`.
270
+ * Inert for the renderer, which dispatches on `as`; read by the usage snippet,
271
+ * which has to emit `<Collapsible.Trigger>` and the import line rather than a
272
+ * `<div>` that looks right and does nothing.
273
+ */
274
+ if (typeof node.from === "string" && node.from.trim()) {
275
+ node_.from = node.from.trim();
276
+ if (typeof node.refName === "string" && node.refName.trim())
277
+ node_.refName = node.refName.trim();
278
+ }
268
279
  // The NAME is what carries the styles, so it is claimed even when nothing
269
280
  // resolved: the tree triggers on presence, and structure is most of the
270
281
  // value. A node with no name is pure structure and that is legitimate.
@@ -384,7 +395,34 @@ sketch) {
384
395
  ? (frontierClasses(rootObj, sketch).classes ??
385
396
  "")
386
397
  : "";
387
- const paints = rootPainted ? { classes: rootPainted } : {};
398
+ /**
399
+ * THE OVERRIDE TRAVELS AS DECLARATIONS, AND ONLY WHEN IT IS AN OVERRIDE.
400
+ *
401
+ * Two different things were sharing one field, and both were broken by it.
402
+ *
403
+ * When the root is a FRONTIER of theirs, the class string at the call site is what
404
+ * the PARENT paints on it - `MetricCard` returns `<Card className="flex-1
405
+ * dark:bg-darkgray-300">` - and this is the whole reason the field exists. It was
406
+ * sent as the class NAMES, so the platform pasted `dark:bg-darkgray-300` onto its own
407
+ * element and the browser resolved it against OUR Tailwind, which has no such colour:
408
+ * the override did nothing, on 5 of 5 of their components (dono, 03/08). Transcribed,
409
+ * it is a declaration that applies anywhere.
410
+ *
411
+ * When the root is one we DRAW - a headless primitive, a plain element - the classes
412
+ * at that index are the component's OWN look and are already in `base`. Sending them
413
+ * again put their raw `bg-white` on our element, in a layer that outranks the compiled
414
+ * recipe, and two of their cards previewed white in dark mode over a recipe that said
415
+ * otherwise. So a drawn root sends nothing here at all.
416
+ */
417
+ const rootStyle = rootPainted
418
+ ? transcribe(rootPainted.split(/\s+/).filter(Boolean), declared)
419
+ : null;
420
+ const paints = rootStyle && Object.keys(rootStyle.base).length > 0
421
+ ? { style: rootStyle.base }
422
+ : {};
423
+ const paintsDark = rootStyle && Object.keys(rootStyle.dark).length > 0
424
+ ? { dark: rootStyle.dark }
425
+ : {};
388
426
  /**
389
427
  * A LIBRARY ROOT WHOSE NAME IS NOT DOTTED.
390
428
  *
@@ -416,7 +454,8 @@ sketch) {
416
454
  const looksLikeTag = sent !== "" && /^[A-Z]/.test(sent);
417
455
  const rootFrom = looksLikeTag ? (rootPackage ?? "") : sent;
418
456
  if (rootFrom) {
419
- rootOut = { from: rootFrom, ...paints };
457
+ // A root we draw carries no override: its classes are the component's own.
458
+ rootOut = { from: rootFrom };
420
459
  }
421
460
  else if (rootRaw && /^[A-Z]/.test(rootRaw)) {
422
461
  if (!rootRaw.includes(".")) {
@@ -428,10 +467,10 @@ sketch) {
428
467
  */
429
468
  const slug = resolve?.(rootRaw) ?? safePartName(rootRaw);
430
469
  if (slug)
431
- rootOut = { ref: slug, name: rootRaw, ...paints };
470
+ rootOut = { ref: slug, name: rootRaw, ...paints, ...paintsDark };
432
471
  }
433
472
  else if (rootPackage) {
434
- rootOut = { from: rootPackage, ...paints };
473
+ rootOut = { from: rootPackage };
435
474
  }
436
475
  }
437
476
  /**
@@ -24,6 +24,7 @@ import { sketchOf } from "../doctor/sketch.js";
24
24
  import { buildTable } from "../doctor/tokens.js";
25
25
  import { readInlineStyle, rootClasses, rootTag, transcribe, } from "../doctor/transcribe.js";
26
26
  import { transcribeVariants } from "../doctor/variant-read.js";
27
+ import { withLibraryStructure } from "../library-structure.js";
27
28
  import { body, paint, section } from "../output.js";
28
29
  import { phase, startProgress } from "../progress.js";
29
30
  import { detectStack, resolveDeps } from "../stack.js";
@@ -1793,8 +1794,22 @@ async function resolveReadParts(census, root) {
1793
1794
  for (const note of derived.notes)
1794
1795
  notes.add(note);
1795
1796
  }
1796
- const resolved = Array.isArray(anatomy) && anatomy.length > 0
1797
- ? resolveAnatomy(anatomy, declared, deps, resolveRef, entry?.root,
1797
+ /**
1798
+ * THE LIBRARY'S OWN FRAME, PUT BACK BEFORE ANYTHING IS RESOLVED.
1799
+ *
1800
+ * A reading names what carries appearance; a headless library's `Portal`,
1801
+ * `Positioner` and `Panel` carry none and are what make the component work. 27 of
1802
+ * them across 8 of their components were named by nobody, so the usage snippet
1803
+ * handed people a card that never opens (dono, 03/08). See `withLibraryStructure` -
1804
+ * it is a lookup in the sketch, not a guess, and it is idempotent.
1805
+ */
1806
+ const framed = Array.isArray(anatomy) && anatomy.length > 0
1807
+ ? withLibraryStructure(anatomy, looks[component]?.sketch, typeof entry?.root?.at === "number"
1808
+ ? (entry?.root).at
1809
+ : undefined)
1810
+ : anatomy;
1811
+ const resolved = Array.isArray(framed) && framed.length > 0
1812
+ ? resolveAnatomy(framed, declared, deps, resolveRef, entry?.root,
1798
1813
  // The component's own sketch, so a node naming itself by index reads
1799
1814
  // the exact class string the census measured (dono, 01/08).
1800
1815
  looks[component]?.sketch)
@@ -1851,20 +1866,34 @@ async function resolveReadParts(census, root) {
1851
1866
  const split = placeLayers((look?.layers ?? []), partAt, (look?.sketch ?? []));
1852
1867
  for (const line of split.unplaced)
1853
1868
  notes.add(line);
1854
- /** The per-part layers, merged into the parts the resolution just named. */
1869
+ /**
1870
+ * REPLACED, NOT APPENDED - because this runs more than once.
1871
+ *
1872
+ * A measuring pass places these onto the DERIVED parts (`box-2`); the send pass
1873
+ * places them onto the AUTHORED ones (`status-dot`). Appending would accumulate a
1874
+ * generation per run, and the pruning below drops the derived names anyway.
1875
+ */
1855
1876
  for (const [part, list] of split.perPart) {
1856
1877
  const target = resolved.parts[part];
1857
1878
  if (!target || !list)
1858
1879
  continue;
1859
- target.layers = [
1860
- ...(target.layers ?? []),
1861
- ...list,
1862
- ];
1880
+ target.layers = list;
1863
1881
  }
1864
1882
  const carried = {
1865
1883
  ...(hasParts ? { parts: resolved.parts } : {}),
1866
- /** The root keeps only what is the root's - see the note above. */
1867
- ...(look?.layers ? { layers: split.root } : {}),
1884
+ /**
1885
+ * THE FULL LIST STAYS ON THE LOOK, EACH LAYER KEEPING ITS `on`.
1886
+ *
1887
+ * Writing back only the root's was destructive, and this function runs twice: the
1888
+ * measuring pass moved 8 of their status layers onto `box-2` and wrote the
1889
+ * remaining 2 back, then the send pass re-read those 2, had nothing to place, and
1890
+ * the one-generation pruning dropped `box-2` - so the four status colours of their
1891
+ * dot and label reached zero recipes, on 36 of 36 components (dono, 03/08).
1892
+ *
1893
+ * Keeping the source of truth here makes the split idempotent: every pass
1894
+ * recomputes both halves from the same list. The RECIPE builder is what leaves an
1895
+ * attributed layer off the component - see `safeLayers`.
1896
+ */
1868
1897
  ...(resolved.tree.length > 0 ? { tree: resolved.tree } : {}),
1869
1898
  ...(resolved.composes.length > 0 ? { composes: resolved.composes } : {}),
1870
1899
  ...(libraries.length > 0 ? { external: libraries } : {}),
@@ -401,10 +401,44 @@ export function parseClass(cls) {
401
401
  * `declared` maps a token NAME to its value, so a resolved colour can be
402
402
  * reported in their vocabulary rather than as a hex we looked up.
403
403
  */
404
+ /** A hex, or a ref into their colour scale - the two shapes an alpha can wrap. */
405
+ const COLOUR_VALUE = /^(#[0-9a-f]{3,8}|\{color\.[^}]+\})$/i;
406
+ /**
407
+ * `#ffffff` + `50` → `color-mix(in srgb, #ffffff 50%, transparent)`.
408
+ *
409
+ * Left alone when the value is not a colour (`shadow-lg/50` is not one) or when mixing
410
+ * would say nothing (`transparent`, `currentColor`).
411
+ */
412
+ function withAlpha(d, alpha) {
413
+ if (!alpha || !/^\d{1,3}$/.test(alpha))
414
+ return d;
415
+ const pct = Number(alpha);
416
+ if (pct >= 100 || pct < 0)
417
+ return d;
418
+ if (!COLOUR_VALUE.test(d.value.trim()))
419
+ return d;
420
+ return { ...d, value: `color-mix(in srgb, ${d.value} ${pct}%, transparent)` };
421
+ }
404
422
  export function readUtility(utility, declared) {
405
- // An opacity modifier changes the value, not the property, and honouring it
406
- // would mean computing a colour they never wrote. The property still belongs
407
- // in the recipe, so the base colour travels and the alpha does not.
423
+ const read = readUtilityCore(utility, declared);
424
+ return read ? withAlpha(read, utility.split("/")[1]) : read;
425
+ }
426
+ function readUtilityCore(utility, declared) {
427
+ /**
428
+ * THE ALPHA TRAVELS, and it took `color-mix` to make that honest.
429
+ *
430
+ * This dropped the modifier on purpose: honouring `bg-white/50` used to mean computing
431
+ * a hex they never wrote, and inventing values is the one thing this reader may not
432
+ * do. But `color-mix(in srgb, X 50%, transparent)` computes nothing - the browser does,
433
+ * at paint time - and a token REF stays a ref inside it, so Token Discipline still sees
434
+ * the token and a re-theme still moves it.
435
+ *
436
+ * Dropping it was not neutral. Their loading veil is `bg-white/50 absolute inset-0`:
437
+ * as an opaque white it covered the whole card, which is the white box the owner kept
438
+ * asking about. Their Modal's backdrop is `bg-darkgray-900/40` - a scrim that became a
439
+ * wall. Measured on their library: 11 of 11 opacity modifiers lost their alpha
440
+ * (dono, 03/08).
441
+ */
408
442
  const [core] = utility.split("/");
409
443
  // A bare keyword carries a declaration with no scale behind it, so it is read
410
444
  // before the dash split that everything else needs.
@@ -0,0 +1,162 @@
1
+ /**
2
+ * THE ELEMENTS THAT MAKE IT WORK, PUT BACK.
3
+ *
4
+ * A reading names the elements that carry APPEARANCE, because that is what a design
5
+ * system is for. A headless library's most important elements carry none: a
6
+ * `Dialog.Portal` teleports, a `Select.Positioner` measures, a `Collapsible.Panel` shows
7
+ * and hides. They have no class list, so nothing of ours styles them - and a reading that
8
+ * skips them is not wrong about the styling, it is just missing the frame.
9
+ *
10
+ * The usage snippet is where that costs something. Measured on the owner's library
11
+ * against the reading his own skill authored (dono, 03/08):
12
+ *
13
+ * 27 library elements with no classes, across 8 components, named by nobody
14
+ * Modal BaseDialog.Root, BaseDialog.Portal
15
+ * Select BaseSelect.Root, BaseSelect.Portal
16
+ * Tooltip BaseTooltip.Provider, .Root, .Portal, .Positioner
17
+ * TextEditor 11 (four Popovers and a Dialog)
18
+ * Menu, ColorPicker, CollapsibleCard, Toggle the rest
19
+ *
20
+ * Somebody pasting that snippet gets a card that never opens. So this puts them back,
21
+ * and it is a LOOKUP rather than a guess: the sketch already recorded every element with
22
+ * its depth and its package, so the chain between two named nodes is a fact about their
23
+ * markup. Nothing is invented - a node re-inserted here carries no name, no part and no
24
+ * styles, only the element and where it came from.
25
+ *
26
+ * A library element that DOES carry classes is left alone: it belongs to whoever named
27
+ * it, and re-inserting it would be a second opinion about a styled element.
28
+ */
29
+ import { frontierKind } from "./frontier-kind.js";
30
+ /** Index → the index of its parent, or -1. Read off `depth`, which is the nesting. */
31
+ function parents(sketch) {
32
+ const out = [];
33
+ const open = [];
34
+ for (const [i, node] of sketch.entries()) {
35
+ while (open.length > 0 && sketch[open[open.length - 1]].depth >= node.depth)
36
+ open.pop();
37
+ out.push(open.length > 0 ? open[open.length - 1] : -1);
38
+ open.push(i);
39
+ }
40
+ return out;
41
+ }
42
+ /** Every `at` the reading already points at, at any depth. */
43
+ function claimed(read, into = new Set()) {
44
+ for (const node of read) {
45
+ if (typeof node.at === "number")
46
+ into.add(node.at);
47
+ if (node.children)
48
+ claimed(node.children, into);
49
+ }
50
+ return into;
51
+ }
52
+ /** Behaviour with no appearance: their element, no class list, nobody's part. */
53
+ function isBehaviour(node) {
54
+ if (!node)
55
+ return false;
56
+ if ((node.classes ?? "").trim())
57
+ return false;
58
+ return frontierKind(node.from) === "headless";
59
+ }
60
+ /**
61
+ * Put the missing library frame back around the nodes it wraps.
62
+ *
63
+ * `boundary` is the `at` of the nearest ancestor the reading DID name (or -1 at the top),
64
+ * so the chain walked is exactly what sits between one named element and the next.
65
+ */
66
+ function wrap(read, sketch, parentOf, named, boundary,
67
+ /**
68
+ * The library elements already standing on this path, by their own spelling.
69
+ *
70
+ * A restored wrapper carries no index - it is not a node of the reading - so the
71
+ * `named` set cannot see it, and without this a second run wrapped the same portal
72
+ * again. That matters because the derived anatomy already keeps these, and both
73
+ * anatomies go through here.
74
+ */
75
+ wrapped) {
76
+ /** The missing library ancestors of `at`, outermost first. */
77
+ const chain = (at) => {
78
+ const out = [];
79
+ for (let p = parentOf[at]; p >= 0 && p !== boundary; p = parentOf[p]) {
80
+ if (named.has(p) || wrapped.has(sketch[p].tag))
81
+ continue;
82
+ if (isBehaviour(sketch[p]))
83
+ out.unshift(p);
84
+ }
85
+ return out;
86
+ };
87
+ const deep = read.map((node) => {
88
+ if (!node.children || node.children.length === 0)
89
+ return node;
90
+ const inside = node.refName ? new Set([...wrapped, node.refName]) : wrapped;
91
+ const inner = typeof node.at === "number"
92
+ ? wrap(node.children, sketch, parentOf, named, node.at, inside)
93
+ : wrap(node.children, sketch, parentOf, named, boundary, inside);
94
+ return { ...node, children: inner };
95
+ });
96
+ /**
97
+ * SIBLINGS THAT SHARE A WRAPPER SHARE ONE. Their Select puts a trigger and a popup
98
+ * inside one `BaseSelect.Root`; two wrappers would say their code has two.
99
+ */
100
+ const out = [];
101
+ let run = null;
102
+ const flush = () => {
103
+ if (!run)
104
+ return;
105
+ let built = run.nodes;
106
+ for (const at of [...run.keys].reverse()) {
107
+ const node = sketch[at];
108
+ built = [
109
+ {
110
+ as: "stack",
111
+ from: String(node.from ?? node.tag),
112
+ refName: node.tag,
113
+ children: built,
114
+ },
115
+ ];
116
+ }
117
+ out.push(...built);
118
+ run = null;
119
+ };
120
+ for (const node of deep) {
121
+ const keys = typeof node.at === "number" ? chain(node.at) : [];
122
+ if (keys.length === 0) {
123
+ flush();
124
+ out.push(node);
125
+ continue;
126
+ }
127
+ if (run && run.keys.join(",") === keys.join(","))
128
+ run.nodes.push(node);
129
+ else {
130
+ flush();
131
+ run = { keys, nodes: [node] };
132
+ }
133
+ }
134
+ flush();
135
+ return out;
136
+ }
137
+ /**
138
+ * The reading with the library's own structure restored around it.
139
+ *
140
+ * Idempotent: an element the reading already names is never re-inserted, so running this
141
+ * over a derived anatomy - which keeps them already - changes nothing.
142
+ */
143
+ export function withLibraryStructure(read, sketch,
144
+ /** The `at` the reading gave for the element the component RETURNS, when it gave one. */
145
+ rootAt) {
146
+ if (!sketch || sketch.length === 0 || read.length === 0)
147
+ return read;
148
+ /**
149
+ * THE ELEMENT THE COMPONENT RETURNS IS THE BOUNDARY, and it is not always given.
150
+ *
151
+ * A reading may name its root by NAME alone (`"root": "BaseDialog.Root"`), and then
152
+ * the walk climbed past it and restored the root as a wrapper INSIDE the anatomy -
153
+ * so the snippet would open `<BaseDialog.Root>` twice. The sketch stamps the returned
154
+ * element, so the answer is measured either way.
155
+ */
156
+ const stamped = sketch.findIndex((node) => node.root);
157
+ const from = typeof rootAt === "number" ? rootAt : stamped >= 0 ? stamped : -1;
158
+ const named = claimed(read);
159
+ if (from >= 0)
160
+ named.add(from);
161
+ return wrap(read, sketch, parents(sketch), named, from, new Set());
162
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "synthesisui",
3
- "version": "0.16.110",
3
+ "version": "0.16.112",
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": {