@vectoriox/iox-ui 5.16.0 → 5.18.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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@vectoriox/iox-ui",
3
- "version": "5.16.0",
3
+ "version": "5.18.0",
4
4
  "peerDependencies": {
5
5
  "@angular/common": ">=20.0.0",
6
6
  "@angular/core": ">=20.0.0",
@@ -4,6 +4,7 @@ import { SWISS_FLAVOR } from './flavor';
4
4
  import { buildFlavorTokens } from './flavor-tokens';
5
5
  import { FlavorSeedPlan, planFlavorSeed, SeedBaseline, SeedExpectation } from './flavor-seed';
6
6
  import { DesignChoices, DesignTokenRow, flavorSourceStamp, StoredDesignTokenRow } from './design-token.model';
7
+ import { fontFamilyKey, FontFamilyToken, fontRoleReferences, parseFontFamilies } from './fonts';
7
8
  import { flavorPackById, isRetiredFlavorId, packDesignChoices, StoredDesign } from './design-settings';
8
9
 
9
10
  /**
@@ -70,6 +71,29 @@ export function missingDesignRows(existing: ReadonlyArray<{ name: string }>, cho
70
71
  return designTokenRows(choices).filter(r => !have.has(r.name));
71
72
  }
72
73
 
74
+ /**
75
+ * The builder-load REPAIR (DS4.1): rows the design owns and nobody touched — stamped by the current
76
+ * pack, light value exactly the pack's — whose dark override differs from the pack's are rewritten.
77
+ * iox-cms dropped `values.dark` on every write from F0 to DS4 (`values` was an implicit relation),
78
+ * and a revert could leave the previous apply's dark behind; both leave a row whose light half is the
79
+ * design's and whose dark half is not. No author edit can produce that shape — the Tokens panel edits
80
+ * the light value only — so rewriting it heals existing orgs without the author doing anything.
81
+ */
82
+ export function designTokenRepairs(existing: ReadonlyArray<StoredDesignTokenRow>, choices: DesignChoices): FlavorSeedPlan['update'] {
83
+ const pack = flavorPackById(choices.flavorId);
84
+ if (!pack) return [];
85
+ const stamp = flavorSourceStamp(pack.id);
86
+ const desired = new Map(designTokenRows(choices).map(r => [r.name, r]));
87
+ const out: FlavorSeedPlan['update'] = [];
88
+ for (const row of existing ?? []) {
89
+ const want = desired.get(row.name);
90
+ if (!want || !row._id || row.source !== stamp || want.source !== stamp || row.value !== want.value) continue;
91
+ if ((row.values?.dark ?? '') === (want.values?.dark ?? '')) continue;
92
+ out.push({ id: row._id, data: { value: want.value, values: want.values ?? {}, source: stamp, ...(want.group ? { group: want.group } : {}), ...(want.tier ? { tier: want.tier } : {}) } });
93
+ }
94
+ return out;
95
+ }
96
+
73
97
  export interface SwissMigration {
74
98
  /** The design the org now wears: Crisp, carrying the stored brand seed when it is a colour. */
75
99
  choices: DesignChoices;
@@ -87,9 +111,11 @@ const LEGACY_ALIASES: Readonly<Record<string, string>> = SPEC_LEGACY_ALIASES;
87
111
  *
88
112
  * - The look: `planDesignApply` from Swiss to Crisp — Swiss-stamped rows untouched since Swiss seeded
89
113
  * 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.
114
+ * - The aliases (`SPEC_LEGACY_ALIASES`: `radius-control` / `radius-surface` / `space-stack`) are rewritten
115
+ * to their alias reference ONLY while still machine-owned and untouched, so a node saved since F1 that
116
+ * reads `var(--iox-radius-control)` keeps resolving — now to Crisp's button radius. `space-control` is
117
+ * NOT an alias: it is a v2 semantic token (`button-pad` reads it), and aliasing it closed a `var()`
118
+ * cycle; it migrates like any other Swiss row.
93
119
  */
94
120
  export function planSwissMigration(existing: ReadonlyArray<StoredDesignTokenRow>, stored: StoredDesign): SwissMigration | null {
95
121
  const swissAliasRows = (existing ?? []).filter(r => r.name in LEGACY_ALIASES && SEED_STAMP.exec(r.source ?? '')?.[1] === SWISS_FLAVOR.id);
@@ -114,3 +140,45 @@ export function planSwissMigration(existing: ReadonlyArray<StoredDesignTokenRow>
114
140
  }
115
141
  return { choices, plan };
116
142
  }
143
+
144
+ /** What deleting an uploaded family does to the look (§5b delete guard). */
145
+ export interface FontFamilyRemoval {
146
+ /** The font tokens that name the family — empty means nothing references it and nothing changes. */
147
+ roles: FontFamilyToken[];
148
+ /** The design without the family: every font role it held goes back to the pack's pairing. */
149
+ choices: DesignChoices;
150
+ /** The token writes: the look's own merge, plus any OTHER row still naming the family (one typed
151
+ * in the Tokens panel), rewritten to the pack's value too — a deleted face must not stay named. */
152
+ plan: FlavorSeedPlan;
153
+ /** Per role, the family it falls back to — what the guard tells the author before they confirm. */
154
+ fallbacks: Array<{ role: FontFamilyToken; family: string }>;
155
+ }
156
+
157
+ /**
158
+ * Plan the removal of an uploaded family from the look: the roles naming it fall back to the pack's
159
+ * own font (the design's pairing), as the guard promises — "Nothing else changes".
160
+ */
161
+ export function planFontFamilyRemoval(
162
+ existing: ReadonlyArray<StoredDesignTokenRow>,
163
+ previous: StoredDesign,
164
+ family: string,
165
+ ): FontFamilyRemoval {
166
+ const roles = fontRoleReferences(existing, family);
167
+ const fonts = Object.fromEntries(Object.entries(previous.choices.fonts ?? {})
168
+ .filter(([, chosen]) => fontFamilyKey(chosen) !== fontFamilyKey(family)));
169
+ const choices: DesignChoices = { ...previous.choices };
170
+ if (Object.keys(fonts).length) choices.fonts = fonts; else delete choices.fonts;
171
+ if (!roles.length) return { roles, choices: previous.choices, plan: { create: [], update: [], kept: [] }, fallbacks: [] };
172
+
173
+ const plan = planDesignApply(existing, previous, choices);
174
+ const desired = new Map(designTokenRows(choices).map(r => [r.name, r]));
175
+ const planned = new Set(plan.update.map(u => u.id));
176
+ for (const row of existing ?? []) {
177
+ const want = desired.get(row.name);
178
+ if (!row._id || !want || planned.has(row._id) || !(roles as string[]).includes(row.name)) continue;
179
+ plan.update.push({ id: row._id, data: { value: want.value, values: want.values ?? {}, source: want.source } });
180
+ plan.kept = plan.kept.filter(name => name !== row.name);
181
+ }
182
+ const fallbacks = roles.map(role => ({ role, family: parseFontFamilies(desired.get(role)?.value ?? '')[0] ?? '' }));
183
+ return { roles, choices, plan, fallbacks };
184
+ }
@@ -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);
@@ -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) } });
@@ -1,5 +1,5 @@
1
1
  import { SPEC_FONTS } from './flavor-spec.generated';
2
- import { DesignTokenLike } from './token-css';
2
+ import { DesignTokenLike, resolveTokenValues } from './token-css';
3
3
 
4
4
  /**
5
5
  * Fonts (design-system plan §5b, D7): ONE Google catalog for both renderers, and ONE face builder
@@ -45,23 +45,90 @@ export function cssString(value: string): string {
45
45
  return `'${body}'`;
46
46
  }
47
47
 
48
- const clampWeight = (w: unknown): number => {
48
+ /** The file extensions a studio may upload as a font, and the descriptor format each one IS (§5b). */
49
+ export const FONT_FILE_FORMATS: Readonly<Record<string, FontFormat>> = Object.freeze({
50
+ woff2: 'woff2', woff: 'woff', ttf: 'truetype', otf: 'opentype',
51
+ });
52
+ export const FONT_FILE_EXTENSIONS: ReadonlyArray<string> = Object.keys(FONT_FILE_FORMATS);
53
+ /** For a file input's `accept` — the same list the server validates against. */
54
+ export const FONT_FILE_ACCEPT = FONT_FILE_EXTENSIONS.map(ext => `.${ext}`).join(',');
55
+ /** The CSS weights a face can be declared at, for an editor that offers them. */
56
+ export const FONT_WEIGHTS: ReadonlyArray<number> = [100, 200, 300, 400, 500, 600, 700, 800, 900];
57
+ export const FONT_STYLES: ReadonlyArray<FontStyle> = ['normal', 'italic'];
58
+
59
+ /** When two files declare the same face, the browser should try the smallest, best-supported first. */
60
+ const FORMAT_PREFERENCE: ReadonlyArray<FontFormat> = ['woff2', 'woff', 'opentype', 'truetype'];
61
+ const UPLOAD_SUFFIX = /-\d+$/;
62
+
63
+ /** A stored key's original file name: the folder prefix and the `-<timestamp>` upload suffix removed. */
64
+ export function fontFileName(key: string | null | undefined): string {
65
+ const name = String(key ?? '');
66
+ return name.substring(name.lastIndexOf('/') + 1).replace(UPLOAD_SUFFIX, '');
67
+ }
68
+
69
+ /** The descriptor format of a font file, from its extension — null for anything we do not serve. */
70
+ export function fontFormatOf(fileNameOrKey: string | null | undefined): FontFormat | null {
71
+ const ext = /\.([a-z0-9]+)$/i.exec(fontFileName(fileNameOrKey))?.[1]?.toLowerCase() ?? '';
72
+ return FONT_FILE_FORMATS[ext] ?? null;
73
+ }
74
+
75
+ /** The family a file had before DS5 — its name without the extension. Legacy files with no parsed
76
+ * name table keep it, so a page saved with `font-family: 'Rubik-Regular'` keeps its face (D5). */
77
+ export function fontFileStem(key: string | null | undefined): string {
78
+ return fontFileName(key).replace(/\.[^.]+$/, '');
79
+ }
80
+
81
+ /** A weight as CSS can use it: 100–900, a bad value → 400. */
82
+ export function normalizeFontWeight(w: unknown): number {
49
83
  const n = Math.round(Number(w));
50
84
  return Number.isFinite(n) && n > 0 ? Math.min(MAX_WEIGHT, Math.max(MIN_WEIGHT, n)) : DEFAULT_WEIGHT;
85
+ }
86
+
87
+ export function normalizeFontStyle(style: unknown): FontStyle {
88
+ return style === 'italic' ? 'italic' : 'normal';
89
+ }
90
+
91
+ /** CSS family names match case-insensitively; this is the one comparison key. */
92
+ export function fontFamilyKey(family: string | null | undefined): string {
93
+ return String(family ?? '').trim().toLowerCase();
94
+ }
95
+
96
+ const usableDescriptor = (d: FontDescriptor | null | undefined): d is FontDescriptor =>
97
+ !!d && typeof d.family === 'string' && d.family.trim() !== '' && typeof d.url === 'string' && d.url !== '';
98
+
99
+ const preference = (format: FontFormat) => {
100
+ const i = FORMAT_PREFERENCE.indexOf(format);
101
+ return i < 0 ? FORMAT_PREFERENCE.length : i;
51
102
  };
52
103
 
53
- /** One `@font-face` per file, carrying its real weight and style — so bold is never synthesised. */
104
+ /**
105
+ * One `@font-face` per FACE (family × weight × style), carrying its real weight and style — so bold is
106
+ * never synthesised. Files that declare the same face (a woff2 and a ttf of one cut) share one rule
107
+ * whose `src` lists them best format first, which is what the two retired forks did per file stem.
108
+ */
54
109
  export function buildFontFaceCss(descriptors: ReadonlyArray<FontDescriptor> | null | undefined): string {
55
- return (descriptors ?? [])
56
- .filter(d => !!d && typeof d.family === 'string' && d.family.trim() !== '' && typeof d.url === 'string' && d.url !== '')
57
- .map(d => {
58
- const format = FONT_FORMATS.includes(d.format) ? ` format(${cssString(d.format)})` : '';
110
+ const faces = new Map<string, { family: string; weight: number; style: FontStyle; files: FontDescriptor[] }>();
111
+ for (const d of (descriptors ?? []).filter(usableDescriptor)) {
112
+ const family = d.family.trim();
113
+ const weight = normalizeFontWeight(d.weight);
114
+ const style = normalizeFontStyle(d.style);
115
+ const id = `${fontFamilyKey(family)}|${weight}|${style}`;
116
+ const face = faces.get(id) ?? { family, weight, style, files: [] };
117
+ face.files.push(d);
118
+ faces.set(id, face);
119
+ }
120
+ return [...faces.values()]
121
+ .map(face => {
122
+ const src = [...face.files]
123
+ .sort((a, b) => preference(a.format) - preference(b.format))
124
+ .map(d => `url(${cssString(d.url)})${FONT_FORMATS.includes(d.format) ? ` format(${cssString(d.format)})` : ''}`)
125
+ .join(', ');
59
126
  return [
60
127
  '@font-face {',
61
- ` font-family: ${cssString(d.family.trim())};`,
62
- ` src: url(${cssString(d.url)})${format};`,
63
- ` font-weight: ${clampWeight(d.weight)};`,
64
- ` font-style: ${d.style === 'italic' ? 'italic' : 'normal'};`,
128
+ ` font-family: ${cssString(face.family)};`,
129
+ ` src: ${src};`,
130
+ ` font-weight: ${face.weight};`,
131
+ ` font-style: ${face.style};`,
65
132
  ' font-display: swap;',
66
133
  '}',
67
134
  ].join('\n');
@@ -69,6 +136,39 @@ export function buildFontFaceCss(descriptors: ReadonlyArray<FontDescriptor> | nu
69
136
  .join('\n');
70
137
  }
71
138
 
139
+ /** An uploaded family: every file sharing a family name (case-insensitively), with its real cuts. */
140
+ export interface FontFamilyGroup {
141
+ family: string;
142
+ /** Distinct weights, ascending. */
143
+ weights: number[];
144
+ styles: FontStyle[];
145
+ /** Every file of the family, lightest first. */
146
+ faces: FontDescriptor[];
147
+ }
148
+
149
+ /** Files → families (§5b "Group into families"): the Files tile, the typeface dropdown and Design ▸ Type. */
150
+ export function groupFontFamilies(descriptors: ReadonlyArray<FontDescriptor> | null | undefined): FontFamilyGroup[] {
151
+ const groups = new Map<string, FontFamilyGroup>();
152
+ for (const d of (descriptors ?? []).filter(usableDescriptor)) {
153
+ const key = fontFamilyKey(d.family);
154
+ const group = groups.get(key) ?? { family: d.family.trim(), weights: [], styles: [], faces: [] };
155
+ group.faces.push(d);
156
+ groups.set(key, group);
157
+ }
158
+ return [...groups.values()]
159
+ .map(g => {
160
+ const faces = [...g.faces].sort((a, b) => normalizeFontWeight(a.weight) - normalizeFontWeight(b.weight)
161
+ || normalizeFontStyle(a.style).localeCompare(normalizeFontStyle(b.style)));
162
+ return {
163
+ family: g.family,
164
+ weights: [...new Set(faces.map(f => normalizeFontWeight(f.weight)))].sort((a, b) => a - b),
165
+ styles: FONT_STYLES.filter(s => faces.some(f => normalizeFontStyle(f.style) === s)),
166
+ faces,
167
+ };
168
+ })
169
+ .sort((a, b) => a.family.localeCompare(b.family));
170
+ }
171
+
72
172
  /** The family names in a CSS `font-family` stack, unquoted, in order. */
73
173
  export function parseFontFamilies(stack: string): string[] {
74
174
  return String(stack ?? '')
@@ -133,3 +233,76 @@ export function fontsForTokens(
133
233
  }
134
234
  return ids;
135
235
  }
236
+
237
+ export type FontFamilyToken = (typeof FONT_FAMILY_TOKENS)[number];
238
+
239
+ /** True when `stack` (a CSS `font-family` value) names `family` anywhere in it. */
240
+ export function stackNamesFamily(stack: string | null | undefined, family: string | null | undefined): boolean {
241
+ const key = fontFamilyKey(family);
242
+ return key !== '' && parseFontFamilies(stack ?? '').some(f => fontFamilyKey(f) === key);
243
+ }
244
+
245
+ /**
246
+ * The font tokens whose value names `family` — what the delete guard asks before an uploaded family's
247
+ * last file goes (§5b): deleting it would silently drop those roles to their fallback.
248
+ */
249
+ export function fontRoleReferences(
250
+ tokens: ReadonlyArray<DesignTokenLike> | null | undefined,
251
+ family: string | null | undefined,
252
+ ): FontFamilyToken[] {
253
+ const rows = (tokens ?? []).filter(t => !!t && typeof t.name === 'string');
254
+ return FONT_FAMILY_TOKENS.filter(role => rows.some(t => t.name === role && stackNamesFamily(t.value, family)));
255
+ }
256
+
257
+ /**
258
+ * The face CSS font matching picks for `weight` from `faces` (CSS Fonts §5.2): 400 tries 500 next,
259
+ * 500 tries 400; lighter weights look down first, heavier ones up. So an `h1` at 600 over a family
260
+ * uploaded at 400 + 700 renders the real 700 cut — the one worth preloading.
261
+ */
262
+ export function matchFontWeight<T extends { weight: number }>(faces: ReadonlyArray<T>, weight: number): T | null {
263
+ if (!faces?.length) return null;
264
+ const want = normalizeFontWeight(weight);
265
+ const exact = faces.find(f => normalizeFontWeight(f.weight) === want);
266
+ if (exact) return exact;
267
+ const w = (f: T) => normalizeFontWeight(f.weight);
268
+ const below = (max: number) => [...faces].filter(f => w(f) < max).sort((a, b) => w(b) - w(a))[0] ?? null;
269
+ const above = (min: number) => [...faces].filter(f => w(f) > min).sort((a, b) => w(a) - w(b))[0] ?? null;
270
+ if (want >= 400 && want <= 500) {
271
+ const within = [...faces].filter(f => w(f) > want && w(f) <= 500).sort((a, b) => w(a) - w(b))[0];
272
+ return within ?? below(want) ?? above(500);
273
+ }
274
+ return want < 400 ? (below(want) ?? above(want)) : (above(want) ?? below(want));
275
+ }
276
+
277
+ /** The roles a preload serves, and the weight tokens whose cuts render above the fold. */
278
+ const PRELOAD_ROLES: ReadonlyArray<{ role: FontFamilyToken; weights: ReadonlyArray<string>; fallback: number }> = [
279
+ { role: 'font-display', weights: ['weight-display', 'weight-heading'], fallback: 700 },
280
+ { role: 'font-body', weights: ['weight-body', 'weight-strong'], fallback: DEFAULT_WEIGHT },
281
+ ];
282
+ const PRELOAD_FORMAT: FontFormat = 'woff2';
283
+
284
+ /**
285
+ * The uploaded files the SSR head preloads (§5b — no flash of the fallback face): for the display and
286
+ * body roles, the family each token names (its first uploaded family), the woff2 normal-style cut CSS
287
+ * matching would pick for that role's weights. One file per weight that renders, never the family.
288
+ */
289
+ export function fontPreloads(
290
+ descriptors: ReadonlyArray<FontDescriptor> | null | undefined,
291
+ tokens: ReadonlyArray<DesignTokenLike> | null | undefined,
292
+ ): FontDescriptor[] {
293
+ const families = groupFontFamilies(descriptors);
294
+ if (!families.length) return [];
295
+ const resolved = resolveTokenValues(tokens);
296
+ const out = new Map<string, FontDescriptor>();
297
+ for (const { role, weights, fallback } of PRELOAD_ROLES) {
298
+ const named = parseFontFamilies(resolved[role] ?? '').map(fontFamilyKey);
299
+ const family = named.map(key => families.find(f => fontFamilyKey(f.family) === key)).find(Boolean);
300
+ const faces = family?.faces.filter(f => f.format === PRELOAD_FORMAT && normalizeFontStyle(f.style) === 'normal') ?? [];
301
+ for (const token of weights) {
302
+ const weight = Number.parseInt(resolved[token] ?? '', 10);
303
+ const face = matchFontWeight(faces, Number.isFinite(weight) ? weight : fallback);
304
+ if (face) out.set(face.url, face);
305
+ }
306
+ }
307
+ return [...out.values()];
308
+ }
@@ -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
  }