synthesisui 0.16.107 → 0.16.109

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.
@@ -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
  }
@@ -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";
@@ -518,6 +519,24 @@ export async function takeCensus(root, opts) {
518
519
  * the owner's rule, adopted verbatim (dono, 01/08).
519
520
  */
520
521
  const sketch = sketchOf(src, found[0].name);
522
+ /**
523
+ * A PER-PART LAYER FINDS ITS NODE, by the class string of the call site that
524
+ * consumes the field. See `ReadLayer.onClasses`: the reader knows a
525
+ * `statusConfig.dotClass` is written on the element whose `className` reads
526
+ * `cn("relative size-2 rounded-full", config.dotClass)`, and that literal is
527
+ * exactly what the sketch stored - so this is a lookup, not a second parse.
528
+ *
529
+ * An index rather than the string, because the anatomy already names nodes by
530
+ * index and `resolveReadParts` moves the layer onto the part that node became.
531
+ */
532
+ const placed = layers.map((layer) => {
533
+ const on = layer.onClasses;
534
+ if (!on)
535
+ return layer;
536
+ const { onClasses: _drop, ...rest } = layer;
537
+ const at = callSiteNode(sketch, on);
538
+ return at === null ? rest : { ...rest, on: at };
539
+ });
521
540
  if (size > 0 || tag) {
522
541
  looks[found[0].name] = {
523
542
  ...(sketch.length > 0 ? { sketch } : {}),
@@ -529,7 +548,10 @@ export async function takeCensus(root, opts) {
529
548
  ...(tree.length > 0 ? { tree } : {}),
530
549
  ...(tag ? { rootTag: tag } : {}),
531
550
  ...rootPackage(tag, sketch),
532
- ...(layers.length > 0 ? { layers } : {}),
551
+ ...(placed.length > 0 ? { layers: placed } : {}),
552
+ ...(v.unslotted.length > 0
553
+ ? { unplaced: [...new Set(v.unslotted)] }
554
+ : {}),
533
555
  ...(Object.keys(v.axes).length > 0 ? { declaredAxes: v.axes } : {}),
534
556
  ...(Object.keys(defaults).length > 0 ? { defaults } : {}),
535
557
  };
@@ -873,6 +895,31 @@ export async function takeCensus(root, opts) {
873
895
  console.log(body(line));
874
896
  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
897
  }
898
+ /**
899
+ * WHAT THE READER UNDERSTOOD AND REFUSED TO PLACE.
900
+ *
901
+ * The other half of the same honesty: a look with nowhere to go is not put on the
902
+ * component, and a component that quietly lost a decision looks like our bug. See
903
+ * `Transcription.unplaced` for what earns a line here.
904
+ */
905
+ const unplacedByComponent = Object.entries(looks)
906
+ .filter(([, look]) => (look.unplaced ?? []).length > 0)
907
+ .map(([component, look]) => ({
908
+ component,
909
+ lines: look.unplaced ?? [],
910
+ }));
911
+ if (unplacedByComponent.length > 0) {
912
+ const total = unplacedByComponent.reduce((n, u) => n + u.lines.length, 0);
913
+ console.log("");
914
+ console.log(section("Conditional looks that were read and not placed"));
915
+ 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:`));
916
+ for (const u of unplacedByComponent.slice(0, 8)) {
917
+ console.log(body(paint.faint(` ${u.component}: ${u.lines[0]}`)));
918
+ }
919
+ if (unplacedByComponent.length > 8) {
920
+ console.log(body(paint.faint(` and ${unplacedByComponent.length - 8} more components`)));
921
+ }
922
+ }
876
923
  /**
877
924
  * THE DROPS, SAID OUT LOUD. `p-spacing-sm` on a real menu popup read as nothing and
878
925
  * the popup shipped with no padding, invisibly - "if the compiler drops them, the drop
@@ -1762,13 +1809,46 @@ async function resolveReadParts(census, root) {
1762
1809
  .map((lib) => ({
1763
1810
  from: lib.from,
1764
1811
  ...(pinned[lib.from] ? { version: pinned[lib.from] } : {}),
1765
- ...(lib.kind === "headless" || lib.kind === "icon"
1766
- ? { kind: lib.kind }
1767
- : {}),
1812
+ /**
1813
+ * EVERY KIND, not the two the rule sentence happened to need.
1814
+ *
1815
+ * This stamped `headless` and `icon` only, so `motion/react` reached the
1816
+ * census with no kind and the screen labelled it a third party - a library
1817
+ * that renders no element of its own (dono, 03/08). The screen now derives
1818
+ * a missing kind from the package, so this is the census telling the whole
1819
+ * truth rather than the thing that makes the label work.
1820
+ */
1821
+ ...(lib.kind !== "own" ? { kind: lib.kind } : {}),
1768
1822
  })),
1769
1823
  ];
1824
+ /**
1825
+ * A PER-PART LAYER LANDS ON ITS PART.
1826
+ *
1827
+ * The reader attributed it to a sketch INDEX (see `Transcription.layers[].on`);
1828
+ * the anatomy is what turns an index into a part name. So this is the first
1829
+ * moment both halves exist - the same reason the reading is resolved here at all.
1830
+ *
1831
+ * A layer whose node no part claims stays OFF the component: pinning it to the
1832
+ * root is exactly the bug, and it is reported instead.
1833
+ */
1834
+ const partAt = resolved.partAt ?? {};
1835
+ const split = placeLayers((look?.layers ?? []), partAt, (look?.sketch ?? []));
1836
+ for (const line of split.unplaced)
1837
+ notes.add(line);
1838
+ /** The per-part layers, merged into the parts the resolution just named. */
1839
+ for (const [part, list] of split.perPart) {
1840
+ const target = resolved.parts[part];
1841
+ if (!target || !list)
1842
+ continue;
1843
+ target.layers = [
1844
+ ...(target.layers ?? []),
1845
+ ...list,
1846
+ ];
1847
+ }
1770
1848
  const carried = {
1771
1849
  ...(hasParts ? { parts: resolved.parts } : {}),
1850
+ /** The root keeps only what is the root's - see the note above. */
1851
+ ...(look?.layers ? { layers: split.root } : {}),
1772
1852
  ...(resolved.tree.length > 0 ? { tree: resolved.tree } : {}),
1773
1853
  ...(resolved.composes.length > 0 ? { composes: resolved.composes } : {}),
1774
1854
  ...(libraries.length > 0 ? { external: libraries } : {}),
@@ -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
- const per = splitByCondition(literalClasses(option.value), {
588
- [axis]: option.key,
589
- });
590
- layers.push(...per.layers);
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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "synthesisui",
3
- "version": "0.16.107",
3
+ "version": "0.16.109",
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": {