@vectoriox/iox-ui 5.15.0 → 5.17.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/index.d.ts CHANGED
@@ -184,9 +184,11 @@ declare class BuilderImageComponent {
184
184
  alt: string;
185
185
  nodeId: string;
186
186
  /**
187
- * What a MARKED image shows while it has no source (design-system DS3) — set by each renderer from
188
- * the shared `emptyImageSrc` (`@vectoriox/iox-ui/design-core`): the "Add image" label on the canvas,
189
- * the blank on the live site, `null` for an unmarked image, which therefore renders as before.
187
+ * What a MARKED image shows WHENEVER it has no source (design-system DS3) — set by each renderer
188
+ * from the shared `emptyImagePlaceholder` (`@vectoriox/iox-ui/design-core`): the "Add image" label
189
+ * on the canvas, the blank on the live site, `null` for an unmarked image, which therefore renders
190
+ * as before. It does not depend on `src`, so clearing the source (a binding emptied, a context slot
191
+ * not yet filled) shows it with no re-render; a renderer re-sets it only when the MARKER changes.
190
192
  */
191
193
  emptySrc: string | null;
192
194
  /**
@@ -200,8 +202,16 @@ declare class BuilderImageComponent {
200
202
  * filled when the visitor interacts (see element-context-state-plan.md).
201
203
  */
202
204
  get srcAttr(): string | null;
205
+ private get showsPlaceholder();
203
206
  /** `data-iox-empty` while the placeholder stands in — the hook the shared fill rule keys on. */
204
207
  get emptyAttr(): '' | null;
208
+ /**
209
+ * The placeholder is not the author's picture, so it never carries the author's alt: it is
210
+ * decorative (`alt=""`) and hidden from assistive technology — on the live site, in Preview and on
211
+ * the canvas alike (the canvas label is painted inside the placeholder, not announced as content).
212
+ */
213
+ get altAttr(): string;
214
+ get ariaHiddenAttr(): 'true' | null;
205
215
  static ɵfac: i0.ɵɵFactoryDeclaration<BuilderImageComponent, never>;
206
216
  static ɵcmp: i0.ɵɵComponentDeclaration<BuilderImageComponent, "img[iox-img]", never, { "src": { "alias": "src"; "required": false; }; "alt": { "alias": "alt"; "required": false; }; "nodeId": { "alias": "nodeId"; "required": false; }; "emptySrc": { "alias": "emptySrc"; "required": false; }; }, {}, never, never, false, never>;
207
217
  }
@@ -247,6 +257,11 @@ declare class BuilderIconComponent {
247
257
  /**
248
258
  * Directive-host link: the `<a>` IS the host — no wrapper. Content is the label; styles come from
249
259
  * `.iox-node` (the legacy `style` input is kept for builder compat but no longer inline-applied).
260
+ *
261
+ * Outside a preview (`IS_PREVIEW` unprovided — the editing canvas, the design sampler) the link keeps
262
+ * its real `href` so it LOOKS like the live site's (the UA link styles key on it) and stays clickable
263
+ * so the canvas can select it, but no click navigates: plain, modified (cmd/ctrl/shift) and middle
264
+ * clicks all have their default cancelled here.
250
265
  */
251
266
  declare class BuilderLinkComponent {
252
267
  isPreview: boolean;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@vectoriox/iox-ui",
3
- "version": "5.15.0",
3
+ "version": "5.17.0",
4
4
  "peerDependencies": {
5
5
  "@angular/common": ">=20.0.0",
6
6
  "@angular/core": ">=20.0.0",
@@ -70,6 +70,29 @@ export function missingDesignRows(existing: ReadonlyArray<{ name: string }>, cho
70
70
  return designTokenRows(choices).filter(r => !have.has(r.name));
71
71
  }
72
72
 
73
+ /**
74
+ * The builder-load REPAIR (DS4.1): rows the design owns and nobody touched — stamped by the current
75
+ * pack, light value exactly the pack's — whose dark override differs from the pack's are rewritten.
76
+ * iox-cms dropped `values.dark` on every write from F0 to DS4 (`values` was an implicit relation),
77
+ * and a revert could leave the previous apply's dark behind; both leave a row whose light half is the
78
+ * design's and whose dark half is not. No author edit can produce that shape — the Tokens panel edits
79
+ * the light value only — so rewriting it heals existing orgs without the author doing anything.
80
+ */
81
+ export function designTokenRepairs(existing: ReadonlyArray<StoredDesignTokenRow>, choices: DesignChoices): FlavorSeedPlan['update'] {
82
+ const pack = flavorPackById(choices.flavorId);
83
+ if (!pack) return [];
84
+ const stamp = flavorSourceStamp(pack.id);
85
+ const desired = new Map(designTokenRows(choices).map(r => [r.name, r]));
86
+ const out: FlavorSeedPlan['update'] = [];
87
+ for (const row of existing ?? []) {
88
+ const want = desired.get(row.name);
89
+ if (!want || !row._id || row.source !== stamp || want.source !== stamp || row.value !== want.value) continue;
90
+ if ((row.values?.dark ?? '') === (want.values?.dark ?? '')) continue;
91
+ out.push({ id: row._id, data: { value: want.value, values: want.values ?? {}, source: stamp, ...(want.group ? { group: want.group } : {}), ...(want.tier ? { tier: want.tier } : {}) } });
92
+ }
93
+ return out;
94
+ }
95
+
73
96
  export interface SwissMigration {
74
97
  /** The design the org now wears: Crisp, carrying the stored brand seed when it is a colour. */
75
98
  choices: DesignChoices;
@@ -87,9 +110,11 @@ const LEGACY_ALIASES: Readonly<Record<string, string>> = SPEC_LEGACY_ALIASES;
87
110
  *
88
111
  * - The look: `planDesignApply` from Swiss to Crisp — Swiss-stamped rows untouched since Swiss seeded
89
112
  * them (incl. F2's `surface` / `line` / `accent`) become Crisp's; edited ones are kept.
90
- * - The aliases: `radius-control` / `radius-surface` / `space-control` / `space-stack` are rewritten to
91
- * their alias reference ONLY while still machine-owned and untouched, so a node saved since F1 that
92
- * reads `var(--iox-radius-control)` keeps resolving — now to Crisp's button radius.
113
+ * - The aliases (`SPEC_LEGACY_ALIASES`: `radius-control` / `radius-surface` / `space-stack`) are rewritten
114
+ * to their alias reference ONLY while still machine-owned and untouched, so a node saved since F1 that
115
+ * reads `var(--iox-radius-control)` keeps resolving — now to Crisp's button radius. `space-control` is
116
+ * NOT an alias: it is a v2 semantic token (`button-pad` reads it), and aliasing it closed a `var()`
117
+ * cycle; it migrates like any other Swiss row.
93
118
  */
94
119
  export function planSwissMigration(existing: ReadonlyArray<StoredDesignTokenRow>, stored: StoredDesign): SwissMigration | null {
95
120
  const swissAliasRows = (existing ?? []).filter(r => r.name in LEGACY_ALIASES && SEED_STAMP.exec(r.source ?? '')?.[1] === SWISS_FLAVOR.id);
@@ -130,6 +130,17 @@ export function packDesignChoices(flavorId: string): DesignChoices {
130
130
  return resolveDesignChoices({ flavorId });
131
131
  }
132
132
 
133
+ /**
134
+ * What picking a pack card means, given the design the studio wears: the applied pack → the applied
135
+ * choices; another pack → that pack's defaults, CARRYING the author's chosen fonts (§5b — a font the
136
+ * author chose survives a pack switch unless the author changes it). Both picker homes call this.
137
+ */
138
+ export function choicesForPack(flavorId: string, applied: DesignChoices | null | undefined): DesignChoices {
139
+ const pack = flavorPackById(flavorId) ?? defaultPack();
140
+ if (applied && resolveDesignChoices(applied).flavorId === pack.id) return resolveDesignChoices(applied);
141
+ return resolveDesignChoices({ flavorId: pack.id, fonts: applied?.fonts });
142
+ }
143
+
133
144
  /** A stored `settings.design` config → the resolved design. No row (or no config) → Crisp's defaults. */
134
145
  export function readDesignConfig(config: ReadonlyArray<DesignConfigEntry> | null | undefined, hasRow = !!config): StoredDesign {
135
146
  const map = configMap(config);
@@ -41,12 +41,21 @@ export function emptyImageLabelSrc(label: string): string {
41
41
  }
42
42
 
43
43
  /**
44
- * The `src` a node's Image renders when its own source is empty: the canvas label (when `label` is
45
- * given) or the live blank for a MARKED node; `null` — render exactly as before — for an unmarked
46
- * node or one that has a source.
44
+ * What a node's Image shows WHENEVER its source is empty: the canvas label (when `label` is given) or
45
+ * the live blank for a MARKED node; `null` — render exactly as before — for an unmarked node. It does
46
+ * not look at the source, so a renderer computes it once per marker/label and the component falls
47
+ * back to it the moment its source is cleared (a binding emptied, a context slot not yet filled).
47
48
  */
48
- export function emptyImageSrc(node: { elementStyle?: string | null } | null | undefined, src: unknown, label?: string | null): string | null {
49
- if (src != null && src !== '') return null;
49
+ export function emptyImagePlaceholder(node: { elementStyle?: string | null } | null | undefined, label?: string | null): string | null {
50
50
  if (!hasElementStyleMarker(node)) return null;
51
51
  return label ? emptyImageLabelSrc(label) : EMPTY_IMAGE_SRC;
52
52
  }
53
+
54
+ /**
55
+ * The `src` a node's Image renders for a given source: its placeholder while the source is empty,
56
+ * `null` once it has one (the source itself renders) or when the node is unmarked.
57
+ */
58
+ export function emptyImageSrc(node: { elementStyle?: string | null } | null | undefined, src: unknown, label?: string | null): string | null {
59
+ if (src != null && src !== '') return null;
60
+ return emptyImagePlaceholder(node, label);
61
+ }
@@ -66,14 +66,16 @@ export function planFlavorRevert(
66
66
  snapNames.add(snap.name);
67
67
  const live = byName.get(snap.name);
68
68
  if (!live) {
69
- plan.create.push({ name: snap.name, type: snap.type, value: snap.value, values: snap.values, source: snap.source, ...classification(snap) });
69
+ plan.create.push({ name: snap.name, type: snap.type, value: snap.value, ...(snap.values?.dark ? { values: snap.values } : {}), source: snap.source, ...classification(snap) });
70
70
  continue;
71
71
  }
72
72
  const differs = live.value !== snap.value
73
73
  || (live.values?.dark ?? null) !== (snap.values?.dark ?? null)
74
74
  || (live.source ?? null) !== (snap.source ?? null);
75
+ // `values` is ALWAYS sent: an absent key is dropped by JSON and a `$set` then keeps the
76
+ // apply's dark override — a snapshot row with no dark must clear it, so it sends `{}`.
75
77
  if (differs && live._id) {
76
- plan.update.push({ id: live._id, data: { value: snap.value, values: snap.values, source: snap.source } });
78
+ plan.update.push({ id: live._id, data: { value: snap.value, values: snap.values ?? {}, source: snap.source } });
77
79
  }
78
80
  }
79
81
  for (const row of current ?? []) {
@@ -110,7 +112,9 @@ const sameValue = (row: { value: string; values?: { dark?: string } }, e: SeedEx
110
112
  * write is never touched: no `flavor:` stamp, unless it is a `user` row (a font the author CHOSE in
111
113
  * the design) whose value is exactly what the previous design wrote — that row is the design's own
112
114
  * choice, so a new choice may replace it. `group`/`tier` ride every write; a desired row's own
113
- * `source` (a chosen font's `user`) wins over the pack stamp.
115
+ * `source` (a chosen font's `user`) wins over the pack stamp. A machine-owned row that already holds
116
+ * the desired value but another pack's stamp is RE-STAMPED (§10 C2 provenance: the stamp names the
117
+ * design that owns the row, so the next switch judges it against the right baseline).
114
118
  */
115
119
  export function planFlavorSeed(
116
120
  desired: ReadonlyArray<FlavorTokenDef & { source?: string }>,
@@ -140,7 +144,6 @@ export function planFlavorSeed(
140
144
  if (!designOwned) { plan.kept.push(def.name); continue; }
141
145
  const sameAsDesired = row.value === def.value && (row.values?.dark ?? '') === (def.values?.dark ?? '');
142
146
  if (sameAsDesired && (row.source ?? '') === source) continue; // already exactly this
143
- if (sameAsDesired && isSeeded(row) && source !== USER_TOKEN_SOURCE) continue; // same value, another pack's stamp — nothing to write
144
147
  const untouched = !!old && sameValue(row, old);
145
148
  if ((untouched || sameAsDesired) && row._id) {
146
149
  plan.update.push({ id: row._id, data: { value: def.value, values: def.values ?? {}, source, ...classification(def) } });
@@ -1419,7 +1419,7 @@ export const SPEC_ELEMENT_STYLES = [
1419
1419
  "fontSize": "var(--iox-size-lead)",
1420
1420
  "fontWeight": "var(--iox-weight-body)",
1421
1421
  "lineHeight": "var(--iox-leading-body)",
1422
- "color": "var(--iox-text)"
1422
+ "color": "inherit"
1423
1423
  }
1424
1424
  },
1425
1425
  {
@@ -1434,7 +1434,7 @@ export const SPEC_ELEMENT_STYLES = [
1434
1434
  "fontSize": "var(--iox-size-body)",
1435
1435
  "fontWeight": "var(--iox-weight-body)",
1436
1436
  "lineHeight": "var(--iox-leading-body)",
1437
- "color": "var(--iox-text)"
1437
+ "color": "inherit"
1438
1438
  }
1439
1439
  },
1440
1440
  {
@@ -32,22 +32,64 @@ export function unknownTokenReferences(css: string, alsoAllowed: Iterable<string
32
32
  }
33
33
 
34
34
  const LEGACY_SCOPE = /:not\(\s*\.iox-es\s*\)/;
35
- const RULE = /([^{}]+)\{([^{}]*)\}/g;
35
+
36
+ /** Comments out: block comments, and SCSS `//` line comments (never the `//` of a `url(https://…)`). */
37
+ const stripComments = (css: string) => String(css ?? '')
38
+ .replace(/\/\*[\s\S]*?\*\//g, '')
39
+ .replace(/(^|[^:\\])\/\/[^\n]*/g, '$1');
40
+
41
+ /** A nested SCSS selector against its parent's: `&` is the parent; no `&` means a descendant. */
42
+ function resolveNested(parent: string, selector: string): string {
43
+ if (!parent) return selector;
44
+ return selector.split(',').map(part => {
45
+ const own = part.trim();
46
+ return own.includes('&') ? own.replace(/&/g, parent) : `${parent} ${own}`;
47
+ }).join(', ');
48
+ }
36
49
 
37
50
  /**
38
51
  * Tokens read by a LEGACY rule — any rule whose selector is scoped `:not(.iox-es)` (the pre-DS2 look
39
52
  * of a node that wears no element style, design-system §3b-bis). Such a rule must read NO token at
40
53
  * all: a token it reads repaints a pre-DS2 page the moment the org's rows are seeded or a flavor is
41
- * applied (D5). Returns `selector → names` for each offending rule; comments are ignored.
54
+ * applied (D5). Returns `selector → names` for each offending rule; comments are ignored. SCSS
55
+ * nesting is resolved (DS4.1): `.x:where(:not(.iox-es)) { &:hover { … } }` is the legacy rule
56
+ * `.x:where(:not(.iox-es)):hover`, and an at-rule (`@media`) between them does not break the chain.
42
57
  */
43
58
  export function legacyRuleTokenReferences(css: string): Array<{ selector: string; tokens: string[] }> {
44
- const text = String(css ?? '').replace(/\/\*[\s\S]*?\*\//g, '');
59
+ const text = stripComments(css);
45
60
  const out: Array<{ selector: string; tokens: string[] }> = [];
46
- for (const m of text.matchAll(RULE)) {
47
- const selector = m[1].trim().replace(/\s+/g, ' ');
48
- if (!LEGACY_SCOPE.test(selector)) continue;
49
- const tokens = tokenReferences(m[2]);
50
- if (tokens.length) out.push({ selector, tokens });
61
+ const stack: Array<{ selector: string; body: string }> = [];
62
+ const report = (frame: { selector: string; body: string }) => {
63
+ if (!LEGACY_SCOPE.test(frame.selector)) return;
64
+ const tokens = tokenReferences(frame.body);
65
+ if (tokens.length) out.push({ selector: frame.selector, tokens });
66
+ };
67
+ let buffer = '';
68
+ for (const ch of text) {
69
+ if (ch === '{') {
70
+ const head = buffer.trim().replace(/\s+/g, ' ');
71
+ buffer = '';
72
+ // Declarations before a nested block belong to the parent (`color: x; &:hover {`).
73
+ const cut = head.lastIndexOf(';');
74
+ const prelude = cut >= 0 ? head.slice(cut + 1).trim() : head;
75
+ const parent = stack[stack.length - 1];
76
+ if (parent && cut >= 0) parent.body += head.slice(0, cut + 1);
77
+ const inherited = parent?.selector ?? '';
78
+ const selector = prelude.startsWith('@') ? inherited : resolveNested(inherited, prelude);
79
+ stack.push({ selector, body: '' });
80
+ } else if (ch === '}') {
81
+ const frame = stack.pop();
82
+ if (frame) {
83
+ frame.body += buffer;
84
+ report(frame);
85
+ }
86
+ buffer = '';
87
+ } else if (ch === ';' && stack.length) {
88
+ stack[stack.length - 1].body += buffer + ch;
89
+ buffer = '';
90
+ } else {
91
+ buffer += ch;
92
+ }
51
93
  }
52
94
  return out;
53
95
  }