@yahoo/uds-create-config 4.28.0 → 4.28.2

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.
@@ -1,5 +1,5 @@
1
1
  import { isRef, pathOf } from "../framework/utils/refs.js";
2
- import { authoredValue } from "../framework/projections.js";
2
+ import { LAYER_PROPS, authoredValue } from "../framework/projections.js";
3
3
  import { intoName } from "../framework/prop-surface.js";
4
4
  import { Component } from "../entities/system/Component.js";
5
5
  import { elementFromCaptured, isCapturedElement } from "./captured-tree.js";
@@ -205,7 +205,7 @@ var Converter = class {
205
205
  const node = layer.childNodes[i];
206
206
  if (!isElement(node)) continue;
207
207
  if (node.getAttribute("data-uds-component") === path && node.getAttribute("data-uds-layer") !== "root") continue;
208
- if (node.tagName.toLowerCase() === "svg" || node.hasAttribute("data-uds-component") && node.getAttribute("data-uds-layer") === "root") out.push(...this.convert(node));
208
+ if (node.tagName.toLowerCase() === "svg" || isImage(node) || node.hasAttribute("data-uds-component") && node.getAttribute("data-uds-layer") === "root") out.push(...this.convert(node));
209
209
  else if (visible(node) && framesItself(node)) out.push(...this.frame(node));
210
210
  else if (visible(node)) out.push(...this.pinFolded(node, this.convertOutsideLayers(node, path)));
211
211
  }
@@ -219,6 +219,7 @@ var Converter = class {
219
219
  return key ? [key] : [];
220
220
  }
221
221
  if (el.tagName.toLowerCase() === "svg") return this.icon(el);
222
+ if (isImage(el)) return this.image(el);
222
223
  if (isField(el)) return this.field(el);
223
224
  if (framesItself(el)) return this.frame(el);
224
225
  return this.pinFolded(el, this.convertChildren(el));
@@ -294,20 +295,37 @@ var Converter = class {
294
295
  * What `el`'s render placed outside its stamped slot layers and claimed layers, walking down to
295
296
  * the first thing on each branch that is a piece of its own: a nested component, a glyph (unless
296
297
  * `el` draws its own), a field it does not read, or an unmarked element with an inline style.
298
+ *
299
+ * With `takesText`, a run of text between them is placed through the text role, so a text
300
+ * component that mixes its own words with nested components keeps both, in order. Text with no
301
+ * component beside it is the component's own `children` value instead, and places nothing here.
297
302
  */
298
- looseContent({ el, claimed, takesGlyphs, takesFields }) {
303
+ looseContent({ el, claimed, takesGlyphs, takesFields, takesText }) {
299
304
  const out = [];
305
+ const runs = /* @__PURE__ */ new Set();
300
306
  const walk = (node) => {
301
307
  for (let i = 0; i < node.childNodes.length; i += 1) {
302
308
  if (this.childCap > 0 && out.length >= this.childCap) return;
303
309
  const child = node.childNodes[i];
304
- if (!isElement(child) || !visible(child) || claimed.has(child)) continue;
310
+ if (!isElement(child)) {
311
+ const text = (child.textContent ?? "").replace(/\s+/g, " ");
312
+ if (takesText && text.trim()) for (const key of this.textRun(text)) {
313
+ runs.add(key);
314
+ out.push(key);
315
+ }
316
+ continue;
317
+ }
318
+ if (!visible(child) || claimed.has(child)) continue;
305
319
  if (child.hasAttribute("data-uds-slot")) continue;
306
320
  const tag = child.tagName.toLowerCase();
307
- if (child.hasAttribute("data-uds-component") && child.getAttribute("data-uds-layer") === "root") out.push(...this.convert(child));
308
- else if (tag === "svg") {
321
+ if (child.hasAttribute("data-uds-component") && child.getAttribute("data-uds-layer") === "root") {
322
+ const keys = this.convert(child);
323
+ if (takesText) for (const key of keys) this.flowInline(key, child);
324
+ out.push(...keys);
325
+ } else if (tag === "svg") {
309
326
  if (takesGlyphs) out.push(...this.convert(child));
310
- } else if (isField(child)) {
327
+ } else if (isImage(child)) out.push(...this.image(child));
328
+ else if (isField(child)) {
311
329
  if (takesFields) out.push(...this.field(child));
312
330
  } else if (framesItself(child)) out.push(...this.frame(child));
313
331
  else {
@@ -319,8 +337,78 @@ var Converter = class {
319
337
  }
320
338
  };
321
339
  walk(el);
340
+ if (out.every((key) => runs.has(key))) {
341
+ for (const key of runs) delete this.elements[key];
342
+ return [];
343
+ }
322
344
  return out;
323
345
  }
346
+ /**
347
+ * A nested component in a text run keeps the page's inline flow. The page laid it out as an inline
348
+ * element (a breadcrumb's last crumb is a `Text as="span"`), and `as` is not a prop a spec carries,
349
+ * so the canvas would draw the component's block element and break the line. A component whose own
350
+ * root style declares a display keeps it.
351
+ */
352
+ flowInline(key, el) {
353
+ if (!INLINE_TAGS.has(el.tagName.toLowerCase())) return;
354
+ const element = this.elements[key];
355
+ if (!element || element.props.display !== void 0) return;
356
+ const path = pathOf(element.type);
357
+ const grammar = this.grammar.component(path);
358
+ if (!grammar || rootDeclaresDisplay(this.options.config, path)) return;
359
+ this.propsFromDeclarations([["display", "inline"]], grammar, element.props);
360
+ }
361
+ /**
362
+ * A run of a text component's own text, as the text role's component laid out inline so it flows
363
+ * with the components beside it and takes its type from the text around it.
364
+ */
365
+ textRun(text) {
366
+ const role = canvasRole(this.options.config, "text");
367
+ if (!role) return [];
368
+ const props = {};
369
+ const grammar = this.grammar.component(role.component);
370
+ if (grammar) this.propsFromDeclarations([["display", "inline"]], grammar, props);
371
+ const key = this.key(role.component);
372
+ this.elements[key] = {
373
+ type: `component:${role.component}`,
374
+ props: {
375
+ ...props,
376
+ children: text
377
+ }
378
+ };
379
+ return [key];
380
+ }
381
+ /**
382
+ * An unmarked `<img>` placed through the canvas's image role, with the source and the box it
383
+ * painted. One with no source shows nothing and places nothing.
384
+ */
385
+ image(img) {
386
+ const src = img.getAttribute("src");
387
+ const role = canvasRole(this.options.config, "image");
388
+ if (!src || !role) return [];
389
+ const props = { ...role.defaultProps };
390
+ const grammar = this.grammar.component(role.component);
391
+ if (grammar) {
392
+ const width = numberAttr(img, "width");
393
+ const height = numberAttr(img, "height");
394
+ const sized = [];
395
+ if (width !== void 0) sized.push(["width", `${width}px`]);
396
+ if (height !== void 0) sized.push(["height", `${height}px`]);
397
+ this.propsFromDeclarations(sized, grammar, props);
398
+ this.propsFromInlineStyle(img, grammar, props);
399
+ }
400
+ const alt = img.getAttribute("alt");
401
+ const key = this.key(role.component);
402
+ this.elements[key] = {
403
+ type: `component:${role.component}`,
404
+ props: {
405
+ ...props,
406
+ src,
407
+ ...alt ? { alt } : {}
408
+ }
409
+ };
410
+ return [key];
411
+ }
324
412
  /** A form field no component reads, placed as the text it shows through the canvas's text role. */
325
413
  field(field) {
326
414
  const text = fieldText(field);
@@ -342,6 +430,11 @@ var Converter = class {
342
430
  };
343
431
  return [key];
344
432
  }
433
+ /** The layers a component declares, which a stamped layer override may name. */
434
+ layerNames(path) {
435
+ const body = this.options.config.resolve("component", path);
436
+ return new Set(Object.keys(body?.layers ?? {}));
437
+ }
345
438
  /** A bare `<svg>` placed as the component its library renders it with. */
346
439
  icon(svg) {
347
440
  const answer = this.options.icons?.resolve(svgOf(svg));
@@ -367,6 +460,11 @@ var Converter = class {
367
460
  if (grammar) this.propsFromClasses(el, grammar, props);
368
461
  Object.assign(props, stamped);
369
462
  if (grammar) this.propsFromInlineStyle(el, grammar, props);
463
+ const layerProps = stampedLayerProps({
464
+ el,
465
+ layers: this.layerNames(path)
466
+ });
467
+ if (layerProps) props[LAYER_PROPS] = layerProps;
370
468
  if (props.position === "fixed") props.position = "absolute";
371
469
  const renders = this.options.icons?.renderers.has(path) ?? false;
372
470
  if (renders) {
@@ -438,11 +536,14 @@ var Converter = class {
438
536
  const field = fieldValue(el, declared.value);
439
537
  if (field !== void 0) props.value = field;
440
538
  }
539
+ const textSlot = declared.children;
540
+ const holdsText = textSlot?.type === "slot" && textSlot.text === true;
441
541
  const loose = this.looseContent({
442
542
  el,
443
543
  claimed,
444
544
  takesGlyphs: !renders,
445
- takesFields: !("value" in declared)
545
+ takesFields: !("value" in declared),
546
+ takesText: holdsText && props.children === void 0
446
547
  });
447
548
  const filled = new Set(Object.keys(slots));
448
549
  for (const nested of loose) {
@@ -459,8 +560,7 @@ var Converter = class {
459
560
  if ("tooltip" in declared && props.tooltip === void 0) props.tooltip = aria;
460
561
  props["aria-label"] = aria;
461
562
  }
462
- const textSlot = declared.children;
463
- if (textSlot?.type === "slot" && textSlot.text && props.children === void 0 && !slots.children) {
563
+ if (holdsText && props.children === void 0 && !slots.children) {
464
564
  const text = collapse(ownText(el, claimed));
465
565
  if (text) props.children = text;
466
566
  }
@@ -471,8 +571,9 @@ var Converter = class {
471
571
  if ("checked" in declared) props.checked = true;
472
572
  else if ("selected" in declared) props.selected = true;
473
573
  }
474
- if (grammar) {
475
- for (const prop of Object.keys(props)) if (!(prop in declared) && prop !== "aria-label") delete props[prop];
574
+ if (grammar) for (const prop of Object.keys(props)) {
575
+ if (prop in declared || prop === "aria-label" || prop === "layerProps") continue;
576
+ delete props[prop];
476
577
  }
477
578
  this.elements[key] = element;
478
579
  return key;
@@ -517,32 +618,82 @@ var Converter = class {
517
618
  }
518
619
  /** {@link propsFromInlineStyle} over declarations the caller supplies. */
519
620
  propsFromDeclarations(declarations, grammar, props) {
520
- for (const [property, raw] of declarations) {
621
+ for (const [property, raw] of declarations.flatMap(gridAreaLines)) {
521
622
  let value = normalizeCssValue(raw);
522
623
  if (!value || SKIP_INLINE_PROPERTY.has(property) || SKIP_INLINE_VALUE.has(value)) continue;
523
624
  if (property === "position" && value === "fixed") value = "absolute";
524
625
  if (VIEWPORT_VALUE.test(value) && !WHOLE_VIEWPORT.test(value)) value = frameUnits(value, this.options.width, this.options.height);
525
- for (const option of this.grammar.stylePropsFor(property)) {
526
- const prop = grammar.propForStyleProperty.get(option.path);
527
- if (!prop) continue;
528
- const css = option.negates ? negated(value) : value;
529
- if (css === void 0) continue;
530
- let next;
531
- const leaf = equivalents({
532
- property,
533
- value: css
534
- }).map((spelling) => option.leafFor(spelling)).find((found) => found !== void 0);
535
- if (WHOLE_VIEWPORT.test(css)) next = option.fullLeaf;
536
- else if (leaf !== void 0) next = leaf === "" ? true : leaf;
537
- else if (option.arbitrary) next = `[${css.replaceAll(" ", "_")}]`;
538
- if (next === void 0) continue;
539
- props[prop] = next;
540
- break;
541
- }
626
+ if (this.propFromDeclaration({
627
+ property,
628
+ value,
629
+ grammar,
630
+ props
631
+ })) continue;
632
+ for (const [longhand, part] of gridLonghands({
633
+ property,
634
+ value
635
+ })) this.propFromDeclaration({
636
+ property: longhand,
637
+ value: part,
638
+ grammar,
639
+ props
640
+ });
641
+ }
642
+ }
643
+ /** Writes the prop one declaration becomes, answering whether any prop took it. */
644
+ propFromDeclaration({ property, value, grammar, props }) {
645
+ for (const option of this.grammar.stylePropsFor(property)) {
646
+ const prop = grammar.propForStyleProperty.get(option.path);
647
+ if (!prop) continue;
648
+ const css = option.negates ? negated(value) : value;
649
+ if (css === void 0) continue;
650
+ let next;
651
+ const leaf = equivalents({
652
+ property,
653
+ value: css
654
+ }).map((spelling) => option.leafFor(spelling)).find((found) => found !== void 0);
655
+ if (WHOLE_VIEWPORT.test(css)) next = option.fullLeaf;
656
+ else if (leaf !== void 0) next = leaf === "" ? true : leaf;
657
+ else if (option.arbitrary) next = `[${css.replaceAll(" ", "_")}]`;
658
+ if (next === void 0) continue;
659
+ props[prop] = next;
660
+ return true;
542
661
  }
662
+ return false;
543
663
  }
544
664
  };
545
665
  /**
666
+ * A `grid-area` as the `grid-row` and `grid-column` it sets: `row-start / column-start / row-end /
667
+ * column-end`, where an omitted or `auto` end leaves that end to the start. A table that places each
668
+ * cell by area otherwise auto-places them, and a row's full-width backdrop then takes the first
669
+ * column and pushes every cell one to the right. Any other declaration passes through.
670
+ */
671
+ function gridAreaLines([property, value]) {
672
+ if (property !== "grid-area") return [[property, value]];
673
+ const [rowStart, columnStart, rowEnd, columnEnd] = value.split("/").map((line) => line.trim());
674
+ const lines = (start, end) => end && end !== "auto" ? `${start} / ${end}` : start;
675
+ const row = lines(rowStart, rowEnd);
676
+ const column = lines(columnStart, columnEnd);
677
+ const out = [];
678
+ if (row && row !== "auto") out.push(["grid-row", row]);
679
+ if (column && column !== "auto") out.push(["grid-column", column]);
680
+ return out;
681
+ }
682
+ /**
683
+ * A `grid-row` or `grid-column` shorthand as its start and end lines (`2 / 4` is start `2`, end `4`,
684
+ * and a lone `2` is the start), for a system that declares the lines rather than the shorthand. A
685
+ * page's rows often number past what a system declares, and a cell that keeps only its column still
686
+ * lands in its own row: auto-placement starts a new row when a cell's column comes before the last.
687
+ */
688
+ function gridLonghands({ property, value }) {
689
+ if (property !== "grid-row" && property !== "grid-column") return [];
690
+ const [start, end] = value.split("/").map((line) => line.trim());
691
+ const out = [];
692
+ if (start) out.push([`${property}-start`, start]);
693
+ if (end) out.push([`${property}-end`, end]);
694
+ return out;
695
+ }
696
+ /**
546
697
  * The prop a nested component the render placed outside every stamped layer belongs to: `children`
547
698
  * first, then the other slots in declaration order, taking the first that is not already filled and
548
699
  * accepts the component's type. `children` also holds it when it forwards into a layer's children,
@@ -577,6 +728,9 @@ const ARTBOARD = [
577
728
  ];
578
729
  /** How a single-line input lays out its text: centred across its height. */
579
730
  const INPUT_LINE = [["display", "flex"], ["align-items", "center"]];
731
+ function isImage(el) {
732
+ return el.tagName.toLowerCase() === "img" && !el.hasAttribute("data-uds-component");
733
+ }
580
734
  function isField(el) {
581
735
  const tag = el.tagName.toLowerCase();
582
736
  return tag === "input" || tag === "textarea";
@@ -589,6 +743,29 @@ function fieldText(field) {
589
743
  if (NON_TEXT_INPUT.has(type)) return "";
590
744
  return collapse(field.getAttribute("value") ?? "") || placeholder;
591
745
  }
746
+ /** Elements the page lays out inline. */
747
+ const INLINE_TAGS = new Set([
748
+ "a",
749
+ "abbr",
750
+ "b",
751
+ "code",
752
+ "em",
753
+ "i",
754
+ "kbd",
755
+ "mark",
756
+ "s",
757
+ "small",
758
+ "span",
759
+ "strong",
760
+ "sub",
761
+ "sup",
762
+ "u"
763
+ ]);
764
+ /** Whether the component's own unconditional style gives its root a display. */
765
+ function rootDeclaresDisplay(config, path) {
766
+ const styles = Component.get(config, path)?.styles ?? {};
767
+ return Object.values(styles).some((rule) => Object.keys(rule?.when ?? {}).length === 0 && rule.layers?.root?.display !== void 0);
768
+ }
592
769
  /** The component a canvas role places with (`text`, `frame`), and the props it places it with. */
593
770
  function canvasRole(config, name) {
594
771
  const entries = (config.resolve("canvas-role", name)?.toJSON())?.entries ?? [];
@@ -650,6 +827,27 @@ function equivalents({ property, value }) {
650
827
  if ((property === "grid-column" || property === "grid-row") && /^span \d+$/.test(value)) return [value, `${value} / ${value}`];
651
828
  return [value];
652
829
  }
830
+ /**
831
+ * The `layerProps` a call site passed, as the component stamped them in `data-uds-layer-props`: each
832
+ * layer the component declares, with its bag of props. Nothing when there is no stamp.
833
+ */
834
+ function stampedLayerProps({ el, layers }) {
835
+ const raw = el.getAttribute("data-uds-layer-props");
836
+ if (!raw) return void 0;
837
+ let parsed;
838
+ try {
839
+ parsed = JSON.parse(raw);
840
+ } catch {
841
+ return;
842
+ }
843
+ if (!parsed || typeof parsed !== "object") return void 0;
844
+ const out = {};
845
+ for (const [layer, bag] of Object.entries(parsed)) {
846
+ if (!layers.has(layer) || !bag || typeof bag !== "object") continue;
847
+ out[layer] = bag;
848
+ }
849
+ return Object.keys(out).length > 0 ? out : void 0;
850
+ }
653
851
  function stampedProps(el) {
654
852
  const raw = el.getAttribute("data-uds-props");
655
853
  if (!raw) return {};
@@ -25,12 +25,38 @@ function iconDomainInvariants(config) {
25
25
  * always one of these. Without the check the ref reaches the build as a package export the package
26
26
  * row never lists, and fails there with the wrong cause.
27
27
  */
28
- const unboundGlyphs = createSliceMemo({
29
- kinds: ["component", "icon"],
28
+ const unboundGlyphs = (config) => {
29
+ const held = heldGlyphsOf(config);
30
+ if (held.length === 0) return [];
31
+ const problems = [];
32
+ const librariesByModule = librariesByModuleOf(config);
33
+ const leafSetOf = leafSetsOf(config);
34
+ for (const { path, key, glyph } of held) {
35
+ const holders = librariesByModule.get(glyph.specifier) ?? [];
36
+ const at = `Component "${path}" key "${key}"`;
37
+ if (holders.length === 0) {
38
+ problems.push(`${at} holds the glyph \`${glyph.name}\` of \`${glyph.specifier}\`, and no icon library draws from that package; declare the library, or hand its exports to the one that does (\`Icon.group({ glyphs })\`).`);
39
+ continue;
40
+ }
41
+ const listing = holders.filter((library) => leafSetOf(`icon:${library}`).has(glyph.name));
42
+ if (listing.length === 0) {
43
+ const empty = holders.every((library) => leafSetOf(`icon:${library}`).size === 0);
44
+ problems.push(`${at} holds \`${glyph.name}\`, which is not a member of ${spellLibraries(holders)}${empty ? "; the library has no members, so the package may not be installed" : ""}.`);
45
+ continue;
46
+ }
47
+ problems.push(`${at} holds \`${glyph.name}\`, a glyph of ${spellLibraries(listing)}, which that key does not draw from; a glyph belongs on a prop whose domain names its library, or a forward onto one.`);
48
+ }
49
+ return problems;
50
+ };
51
+ /**
52
+ * Every `package:` export left in a component's bags, in component, bag and key order. Keyed on the
53
+ * component slice alone, so an icon write (the build bakes metadata into each of a library's members
54
+ * one patch at a time) re-reads none of the components.
55
+ */
56
+ const heldGlyphsOf = createSliceMemo({
57
+ kinds: ["component"],
30
58
  compute: (config) => {
31
- const problems = [];
32
- const librariesByModule = librariesByModuleOf(config);
33
- const leafSetOf = leafSetsOf(config);
59
+ const held = [];
34
60
  for (const item of config.list("component")) {
35
61
  const body = config.resolve("component", item.path)?.toJSON();
36
62
  for (const site of glyphBagSites({
@@ -39,23 +65,14 @@ const unboundGlyphs = createSliceMemo({
39
65
  body
40
66
  })) for (const [key, value] of Object.entries(site.bag)) {
41
67
  const glyph = packageExportOf(value);
42
- if (!glyph) continue;
43
- const holders = librariesByModule.get(glyph.specifier) ?? [];
44
- const at = `Component "${item.path}" key "${key}"`;
45
- if (holders.length === 0) {
46
- problems.push(`${at} holds the glyph \`${glyph.name}\` of \`${glyph.specifier}\`, and no icon library draws from that package; declare the library, or hand its exports to the one that does (\`Icon.group({ glyphs })\`).`);
47
- continue;
48
- }
49
- const listing = holders.filter((library) => leafSetOf(`icon:${library}`).has(glyph.name));
50
- if (listing.length === 0) {
51
- const empty = holders.every((library) => leafSetOf(`icon:${library}`).size === 0);
52
- problems.push(`${at} holds \`${glyph.name}\`, which is not a member of ${spellLibraries(holders)}${empty ? "; the library has no members, so the package may not be installed" : ""}.`);
53
- continue;
54
- }
55
- problems.push(`${at} holds \`${glyph.name}\`, a glyph of ${spellLibraries(listing)}, which that key does not draw from; a glyph belongs on a prop whose domain names its library, or a forward onto one.`);
68
+ if (glyph) held.push({
69
+ path: item.path,
70
+ key,
71
+ glyph
72
+ });
56
73
  }
57
74
  }
58
- return problems;
75
+ return held;
59
76
  }
60
77
  });
61
78
  /**
@@ -105,39 +122,52 @@ function spellLibraries(libraries) {
105
122
  return libraries.length === 1 ? `the library ${named[0]}` : `the libraries ${named.join(", ")}`;
106
123
  }
107
124
  /**
108
- * Keyed on the two slices the check reads, so the second kind's run within one registration is a
109
- * hit, and a write to any other kind never pays for it. A library's leaf set is keyed on the icon
110
- * slice alone: a component edit re-walks the list-valued props against sets that are already built.
125
+ * The component half is keyed on the component slice and the leaf sets on the icon slice, so an icon
126
+ * write re-walks no component. Only a prop listing two or more libraries can collide, and when none
127
+ * does no leaf set is built at all.
111
128
  */
112
- const collisions = createSliceMemo({
113
- kinds: ["component", "icon"],
129
+ const collisions = (config) => {
130
+ const listing = multiLibraryPropsOf(config);
131
+ if (listing.length === 0) return [];
132
+ const problems = [];
133
+ const leafSetOf = leafSetsOf(config);
134
+ for (const { path, prop, refs } of listing) {
135
+ const heldBy = /* @__PURE__ */ new Map();
136
+ const shared = /* @__PURE__ */ new Map();
137
+ for (const target of refs) for (const leaf of leafSetOf(target)) {
138
+ const other = heldBy.get(leaf);
139
+ if (other === void 0) {
140
+ heldBy.set(leaf, target);
141
+ continue;
142
+ }
143
+ if (other === target) continue;
144
+ const pair = `${other} + ${target}`;
145
+ shared.set(pair, [...shared.get(pair) ?? [], leaf]);
146
+ }
147
+ for (const [pair, leaves] of shared) {
148
+ const [first, second] = pair.split(" + ");
149
+ problems.push(`Component "${path}" prop "${prop}" binds icon libraries "${first}" and "${second}", which both ship ${spellLeaves(leaves)}; a leaf has to name one glyph.`);
150
+ }
151
+ }
152
+ return problems;
153
+ };
154
+ /** Each prop whose domain lists two or more distinct icon libraries, in component and prop order. */
155
+ const multiLibraryPropsOf = createSliceMemo({
156
+ kinds: ["component"],
114
157
  compute: (config) => {
115
- const problems = [];
116
- const leafSetOf = leafSetsOf(config);
158
+ const listing = [];
117
159
  for (const item of config.list("component")) {
118
160
  const body = config.resolve("component", item.path)?.toJSON();
119
161
  for (const [prop, decl] of Object.entries(body?.props ?? {})) {
120
162
  const refs = listedLibraryRefsOf(decl);
121
- if (!refs) continue;
122
- const heldBy = /* @__PURE__ */ new Map();
123
- const shared = /* @__PURE__ */ new Map();
124
- for (const target of refs) for (const leaf of leafSetOf(target)) {
125
- const other = heldBy.get(leaf);
126
- if (other === void 0) {
127
- heldBy.set(leaf, target);
128
- continue;
129
- }
130
- if (other === target) continue;
131
- const pair = `${other} + ${target}`;
132
- shared.set(pair, [...shared.get(pair) ?? [], leaf]);
133
- }
134
- for (const [pair, leaves] of shared) {
135
- const [first, second] = pair.split(" + ");
136
- problems.push(`Component "${item.path}" prop "${prop}" binds icon libraries "${first}" and "${second}", which both ship ${spellLeaves(leaves)}; a leaf has to name one glyph.`);
137
- }
163
+ if (refs && new Set(refs).size > 1) listing.push({
164
+ path: item.path,
165
+ prop,
166
+ refs
167
+ });
138
168
  }
139
169
  }
140
- return problems;
170
+ return listing;
141
171
  }
142
172
  });
143
173
  /** One leaf set per referenced library, filled on demand and kept while the icon slice stands. */
@@ -1042,14 +1042,38 @@ function forwardIntoName(into, own) {
1042
1042
  const member = memberOf(into) ?? into.__ref;
1043
1043
  return member.slice(member.indexOf("/") + 1);
1044
1044
  }
1045
+ /** The instance prop that carries per-layer overrides, spread onto each named layer at render. */
1046
+ const LAYER_PROPS = "layerProps";
1047
+ /**
1048
+ * What is wrong with an instance's `layerProps`: it is an object keyed by the component's layers,
1049
+ * each holding an object of props for that layer. Empty when it is absent or sound.
1050
+ */
1051
+ function layerPropsErrors({ config, target, value, where }) {
1052
+ if (value === void 0) return [];
1053
+ if (!isBag(value)) return [`${where} layerProps must be an object of layers`];
1054
+ const layers = config.resolve("component", target)?.layers ?? {};
1055
+ const errors = [];
1056
+ for (const [layer, bag] of Object.entries(value)) if (!(layer in layers)) errors.push(`${where} has no layer "${layer}"`);
1057
+ else if (!isBag(bag)) errors.push(`${where} layerProps.${layer} must be an object of props`);
1058
+ return errors;
1059
+ }
1060
+ function isBag(value) {
1061
+ return typeof value === "object" && value !== null && !Array.isArray(value);
1062
+ }
1045
1063
  /** Validate a prop bag against a component's declared props — unknown props + per-value domain
1046
1064
  * checks. Generic: it resolves each prop's domain via {@link propValueDomain} and checks membership;
1047
1065
  * no per-prop-type logic here. Returns the error strings (empty = valid). Lives in config so the
1048
1066
  * `component/validate` op's truth is config-defined and the pipeline only renders it. */
1049
1067
  function validateComponentProps(config, componentPath, props) {
1050
1068
  const declared = resolveComponentProps(config, componentPath);
1051
- const errors = [];
1069
+ const errors = layerPropsErrors({
1070
+ config,
1071
+ target: componentPath,
1072
+ value: props[LAYER_PROPS],
1073
+ where: componentPath
1074
+ });
1052
1075
  for (const [name, value] of Object.entries(props)) {
1076
+ if (name === "layerProps") continue;
1053
1077
  const decl = declared[name];
1054
1078
  if (!decl) {
1055
1079
  errors.push(`unknown prop \`${name}\``);
@@ -1248,4 +1272,4 @@ function declaredPropDefaults(props) {
1248
1272
  return out;
1249
1273
  }
1250
1274
  //#endregion
1251
- export { authoredBag, authoredValue, className, cssPropValue, cssVar, cssVarRef, declaredPropDefaults, declaredPropRouting, glyphComponentOf, glyphLibrariesOf, glyphPropsOf, glyphPropsOfBody, holdingPreviewConditions, iconToolAxisOptions, iconToolOf, keyedPreviewConditions, memberFromLeaf, memberLeaves, negatedCssValue, opacityPercentage, previewConditionalProps, previewConditions, previewDefaults, previewMatrix, propOwner, propValueDomain, propValueFromAxis, refListOf, renderStyleValue, resolveComponentProps, resolveFieldValue, resolveTokenValue, resolveTokenValueUnder, routedBag, sourceVarPrefix, styleAliasesOf, styleDeclarations, stylePropEntries, stylePropLeafForToken, stylePropNegates, stylePropTokenGroup, stylePropTokenPath, stylePropTokenValues, stylePropValueFromLeaf, stylePropValueLeaves, stylePropValues, stylePropertiesWriting, stylePropertyAccepts, stylePropertyFor, stylePropertyPathFor, takenByForward, validateComponentProps };
1275
+ export { LAYER_PROPS, authoredBag, authoredValue, className, cssPropValue, cssVar, cssVarRef, declaredPropDefaults, declaredPropRouting, glyphComponentOf, glyphLibrariesOf, glyphPropsOf, glyphPropsOfBody, holdingPreviewConditions, iconToolAxisOptions, iconToolOf, keyedPreviewConditions, layerPropsErrors, memberFromLeaf, memberLeaves, negatedCssValue, opacityPercentage, previewConditionalProps, previewConditions, previewDefaults, previewMatrix, propOwner, propValueDomain, propValueFromAxis, refListOf, renderStyleValue, resolveComponentProps, resolveFieldValue, resolveTokenValue, resolveTokenValueUnder, routedBag, sourceVarPrefix, styleAliasesOf, styleDeclarations, stylePropEntries, stylePropLeafForToken, stylePropNegates, stylePropTokenGroup, stylePropTokenPath, stylePropTokenValues, stylePropValueFromLeaf, stylePropValueLeaves, stylePropValues, stylePropertiesWriting, stylePropertyAccepts, stylePropertyFor, stylePropertyPathFor, takenByForward, validateComponentProps };
@@ -1,6 +1,6 @@
1
1
  import { kindOf, memberOf, pathOf, splitRef } from "./utils/refs.js";
2
2
  import { childrenPolicy } from "../entities/system/element.js";
3
- import { resolveComponentProps } from "./projections.js";
3
+ import { LAYER_PROPS, layerPropsErrors, resolveComponentProps } from "./projections.js";
4
4
  import { slotOf } from "../spec/slot.js";
5
5
  import { refResolves } from "./ref-integrity.js";
6
6
  //#region src/framework/validate-spec.ts
@@ -28,7 +28,16 @@ function walk(config, node, path, errors) {
28
28
  errors.push(`${path}: unknown ${kind} "${named}"`);
29
29
  } else if (kind === "component") {
30
30
  const props = resolveComponentProps(config, target);
31
- for (const name of Object.keys(node.props ?? {})) if (!(name in props)) errors.push(`${path}: unknown prop "${name}" on "${node.type}"`);
31
+ for (const name of Object.keys(node.props ?? {})) {
32
+ if (name === "layerProps") continue;
33
+ if (!(name in props)) errors.push(`${path}: unknown prop "${name}" on "${node.type}"`);
34
+ }
35
+ errors.push(...layerPropsErrors({
36
+ config,
37
+ target,
38
+ value: node.props?.[LAYER_PROPS],
39
+ where: `${path}: "${node.type}"`
40
+ }));
32
41
  if (node.children?.length && childrenPolicy(config, target) === "none") errors.push(`${path}: "${node.type}" takes no children`);
33
42
  for (const [name, fills] of Object.entries(node.slots ?? {})) {
34
43
  const slot = slotOf({