@yahoo/uds-create-config 4.27.1 → 4.28.1

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 {};
@@ -41,6 +41,7 @@ import { transitionTokenValueMigration } from "../migrations/20260923144958_tran
41
41
  import { renderTokenRefsMigration } from "../migrations/20260923160425_render_token_refs.js";
42
42
  import { globalStyleDerivedColorsMigration } from "../migrations/20260923163604_global_style_derived_colors.js";
43
43
  import { slotCodeFormsMigration } from "../migrations/20260924044539_slot_code_forms.js";
44
+ import { canvasRoleSlotMigration } from "../migrations/20260925143319_canvas_role_slot.js";
44
45
  //#region src/configs/system.ts
45
46
  /**
46
47
  * `System` — the design-system config TYPE. Declares the exact kinds a design system owns (tokens,
@@ -102,5 +103,6 @@ if (!registeredSchemaMigrations().has(20260923144958)) registerSchemaMigrations(
102
103
  if (!registeredSchemaMigrations().has(20260923160425)) registerSchemaMigrations(renderTokenRefsMigration);
103
104
  if (!registeredSchemaMigrations().has(20260923163604)) registerSchemaMigrations(globalStyleDerivedColorsMigration);
104
105
  if (!registeredSchemaMigrations().has(20260924044539)) registerSchemaMigrations(slotCodeFormsMigration);
106
+ if (!registeredSchemaMigrations().has(20260925143319)) registerSchemaMigrations(canvasRoleSlotMigration);
105
107
  //#endregion
106
108
  export { System };
@@ -3,19 +3,14 @@ import { z } from "zod";
3
3
 
4
4
  //#region src/entities/system/CanvasRole.d.ts
5
5
  /**
6
- * The roles a canvas has a tool for — the vocabulary a design system fills in.
6
+ * The roles a canvas has a tool for, the vocabulary a design system fills in. A role a config leaves
7
+ * unregistered has no entries, and its tool is disabled rather than falling back to a guess; what
8
+ * fills a role is whichever component the config points at, never a name the editor chose.
7
9
  *
8
- * This is the set of tools an editor offers, and each one is a SLOT: a role a config leaves
9
- * unregistered has no entries, and its tool is disabled rather than falling back to a guess. So
10
- * naming a role here offers the slot without asserting anything fills it, and what fills it is
11
- * never a name the editor chose — it is whichever component the config pointed at.
12
- *
13
- * The names live here, with the entity, because the entity is what they are names OF. They were
14
- * previously kept in `@yahoo/uds-create-platform-types` as a hand-maintained copy, annotated as
15
- * one ("mirrors `@yahoo/uds-create-config`'s `CanvasRole` union without importing it") — a second
16
- * source of truth wearing a comment admitting it.
10
+ * `slot` is the one role no toolbar draws with. Its entries are the host layers a component's new
11
+ * slot property can start as, and an entry's `defaultProps` are the props that layer starts with.
17
12
  */
18
- declare const CANVAS_ROLES: readonly ["frame", "text", "image", "icon"];
13
+ declare const CANVAS_ROLES: readonly ["frame", "text", "image", "icon", "slot"];
19
14
  type CanvasRoleName = (typeof CANVAS_ROLES)[number];
20
15
  declare const CanvasRole: EntityClass<z.ZodObject<{
21
16
  entries: z.ZodArray<z.ZodObject<{
@@ -15,23 +15,19 @@ import { z } from "zod";
15
15
  * });
16
16
  */
17
17
  /**
18
- * The roles a canvas has a tool for — the vocabulary a design system fills in.
18
+ * The roles a canvas has a tool for, the vocabulary a design system fills in. A role a config leaves
19
+ * unregistered has no entries, and its tool is disabled rather than falling back to a guess; what
20
+ * fills a role is whichever component the config points at, never a name the editor chose.
19
21
  *
20
- * This is the set of tools an editor offers, and each one is a SLOT: a role a config leaves
21
- * unregistered has no entries, and its tool is disabled rather than falling back to a guess. So
22
- * naming a role here offers the slot without asserting anything fills it, and what fills it is
23
- * never a name the editor chose — it is whichever component the config pointed at.
24
- *
25
- * The names live here, with the entity, because the entity is what they are names OF. They were
26
- * previously kept in `@yahoo/uds-create-platform-types` as a hand-maintained copy, annotated as
27
- * one ("mirrors `@yahoo/uds-create-config`'s `CanvasRole` union without importing it") — a second
28
- * source of truth wearing a comment admitting it.
22
+ * `slot` is the one role no toolbar draws with. Its entries are the host layers a component's new
23
+ * slot property can start as, and an entry's `defaultProps` are the props that layer starts with.
29
24
  */
30
25
  const CANVAS_ROLES = [
31
26
  "frame",
32
27
  "text",
33
28
  "image",
34
- "icon"
29
+ "icon",
30
+ "slot"
35
31
  ];
36
32
  const CanvasRole = defineEntity({
37
33
  kind: "canvas-role",
@@ -72,7 +72,7 @@ interface RecordedSource {
72
72
  * The dense-counter `2` a short-lived scheme stamped before the chain existed reads as the mint
73
73
  * that replaced it (see `detectedWireVersion`).
74
74
  */
75
- declare const SERIALIZED_CONFIG_VERSION = 20260924044539;
75
+ declare const SERIALIZED_CONFIG_VERSION = 20260925143319;
76
76
  interface SerializedConfig {
77
77
  /** See {@link SERIALIZED_CONFIG_VERSION}. Always written; a stored artifact may lack it. */
78
78
  readonly version: number;
@@ -4883,7 +4883,7 @@ var Config = class Config {
4883
4883
  * links from the provided sources. A declared `linkedKind` with no provided source throws. */
4884
4884
  hydrateFrom(stored, options) {
4885
4885
  const claimed = schemaVersionOf(stored);
4886
- if (claimed > 20260924044539) throw new SchemaVersionTooNew(claimed);
4886
+ if (claimed > 20260925143319) throw new SchemaVersionTooNew(claimed);
4887
4887
  const wire = upgradeSerializedConfig(stored);
4888
4888
  if (!Array.isArray(wire.ownedKinds) || typeof wire.items !== "object" || wire.items === null) throw new ConfigFormatError(`Config "${stored.name ?? "system-config"}" cannot be hydrated: this is not a serialized config-v2 config (no "ownedKinds"/"items"). A pre-cutover build artifact converts through the registered cutover migration — import the config type's module (e.g. \`configs/system\`) before hydrating, or port the repo once with \`uds migrate\`.`);
4889
4889
  const leftover = wire.options;
@@ -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 };
@@ -15,7 +15,7 @@ import { Patch, SerializedConfig } from "./Config.js";
15
15
  * sets this constant to its own older timestamp; the merge ref then carries a registered migration
16
16
  * newer than the constant, and the assertion fails until the author re-mints.
17
17
  */
18
- declare const CURRENT_SCHEMA_VERSION = 20260924044539;
18
+ declare const CURRENT_SCHEMA_VERSION = 20260925143319;
19
19
  /** What the walkers need to see of an envelope — loose on purpose, so a typed `SerializedConfig`
20
20
  * and a raw old-shape record both flow in without casts. */
21
21
  interface VersionStamped {
@@ -13,7 +13,7 @@ import { ConfigFormatError } from "./rejection.js";
13
13
  * sets this constant to its own older timestamp; the merge ref then carries a registered migration
14
14
  * newer than the constant, and the assertion fails until the author re-mints.
15
15
  */
16
- const CURRENT_SCHEMA_VERSION = 20260924044539;
16
+ const CURRENT_SCHEMA_VERSION = 20260925143319;
17
17
  /** The mint that moved `options` into the `settings` kind — restated here, rather than imported
18
18
  * from the migration that owns it, because that migration imports this module. */
19
19
  const SETTINGS_AS_KIND_VERSION = 20260908171448;
@@ -45,7 +45,7 @@ function registerSchemaMigrations(...migrations) {
45
45
  for (const migration of migrations) {
46
46
  if (migration.version !== 1 && !isMintTimestamp(migration.version)) throw new Error(`Schema migration version ${migration.version} is not a mint timestamp — mint one with \`date +%Y%m%d%H%M%S\`, like a supabase migration.`);
47
47
  if (REGISTERED.has(migration.version)) throw new Error(`A schema migration at version ${migration.version} is already registered.`);
48
- if (migration.version > 20260924044539) throw new Error(`Schema migration ${migration.version} is newer than CURRENT_SCHEMA_VERSION (${CURRENT_SCHEMA_VERSION}) — a stale branch merged, or the mint forgot to bump the constant. Re-mint: set CURRENT_SCHEMA_VERSION to the newest migration version.`);
48
+ if (migration.version > 20260925143319) throw new Error(`Schema migration ${migration.version} is newer than CURRENT_SCHEMA_VERSION (${CURRENT_SCHEMA_VERSION}) — a stale branch merged, or the mint forgot to bump the constant. Re-mint: set CURRENT_SCHEMA_VERSION to the newest migration version.`);
49
49
  REGISTERED.set(migration.version, migration);
50
50
  }
51
51
  }
@@ -102,7 +102,7 @@ function pendingFor(version, target, options) {
102
102
  * Already-current input passes through untouched — same reference, so hot paths pay nothing.
103
103
  */
104
104
  function upgradeSerializedConfig(json, options) {
105
- const target = options?.target ?? 20260924044539;
105
+ const target = options?.target ?? 20260925143319;
106
106
  const version = detectedWireVersion(json);
107
107
  if (version > target) throw new SchemaVersionTooNew(version, target);
108
108
  const raw = json;
@@ -160,7 +160,7 @@ function upgradedSources(json, options) {
160
160
  * meaning under the current schema and replay skips it — reported by the caller, not swallowed here.
161
161
  */
162
162
  function upgradePatch(patch, writtenAt, options) {
163
- const target = options?.target ?? 20260924044539;
163
+ const target = options?.target ?? 20260925143319;
164
164
  if (writtenAt > target) throw new SchemaVersionTooNew(writtenAt, target);
165
165
  let current = patch;
166
166
  for (const migration of pendingFor(writtenAt, target, options)) {
@@ -179,7 +179,7 @@ function upgradePatch(patch, writtenAt, options) {
179
179
  * stamp would cause.
180
180
  */
181
181
  function upgradeDraftEntries(draft, options) {
182
- const target = options?.target ?? 20260924044539;
182
+ const target = options?.target ?? 20260925143319;
183
183
  const entries = [];
184
184
  const held = [];
185
185
  const retired = [];
@@ -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({
@@ -0,0 +1,12 @@
1
+ /**
2
+ * An additive fence: `slot` joins the canvas roles, naming the host layer a new slot property starts
3
+ * as. No stored byte moves.
4
+ *
5
+ * Identity, so a stamp below this version means "may lack these fields" and one at it means they
6
+ * are understood, which is what lets the pull gate keep an older client from stripping them.
7
+ *
8
+ * Deletable once no stored config artifact is below this version.
9
+ */
10
+ import type { SchemaMigration } from '../framework/schema-version';
11
+ export declare const CANVAS_ROLE_SLOT_VERSION = 20260925143319;
12
+ export declare const canvasRoleSlotMigration: SchemaMigration;
@@ -0,0 +1,8 @@
1
+ //#region src/migrations/20260925143319_canvas_role_slot.ts
2
+ const CANVAS_ROLE_SLOT_VERSION = 20260925143319;
3
+ const canvasRoleSlotMigration = {
4
+ version: CANVAS_ROLE_SLOT_VERSION,
5
+ up: (json) => json
6
+ };
7
+ //#endregion
8
+ export { CANVAS_ROLE_SLOT_VERSION, canvasRoleSlotMigration };