@vectoriox/iox-ui 5.14.0 → 5.15.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.14.0",
3
+ "version": "5.15.0",
4
4
  "peerDependencies": {
5
5
  "@angular/common": ">=20.0.0",
6
6
  "@angular/core": ">=20.0.0",
@@ -0,0 +1,116 @@
1
+ import { deriveBrandTokens } from './brand';
2
+ import { SPEC_LEGACY_ALIASES } from './flavor-spec.generated';
3
+ import { SWISS_FLAVOR } from './flavor';
4
+ import { buildFlavorTokens } from './flavor-tokens';
5
+ import { FlavorSeedPlan, planFlavorSeed, SeedBaseline, SeedExpectation } from './flavor-seed';
6
+ import { DesignChoices, DesignTokenRow, flavorSourceStamp, StoredDesignTokenRow } from './design-token.model';
7
+ import { flavorPackById, isRetiredFlavorId, packDesignChoices, StoredDesign } from './design-settings';
8
+
9
+ /**
10
+ * Applying a look (design-system DS4): the COMPLETE `buildFlavorTokens` set for the chosen
11
+ * `DesignChoices`, merged into the org's rows by the §10 C2 rule — rewrite only what the previous
12
+ * seed wrote and nobody touched since, keep the author's rows, report what was kept. And the one
13
+ * migration the contract names: an org still on Swiss becomes Crisp.
14
+ * Pure plans; each caller (builder, the server-side site build) applies them with its own transport.
15
+ */
16
+
17
+ const SEED_STAMP = /^flavor:([^@]+)@/;
18
+
19
+ const expectations = (rows: ReadonlyArray<Pick<DesignTokenRow, 'name' | 'value' | 'values'>>): Map<string, SeedExpectation> =>
20
+ new Map(rows.map(r => [r.name, { value: r.value, ...(r.values?.dark ? { dark: r.values.dark } : {}) }]));
21
+
22
+ /** Every row a design writes — `buildFlavorTokens` for its pack. Empty for an unknown pack. */
23
+ export function designTokenRows(choices: DesignChoices): DesignTokenRow[] {
24
+ const pack = flavorPackById(choices.flavorId);
25
+ return pack ? buildFlavorTokens(pack, { ...choices, flavorId: pack.id }) : [];
26
+ }
27
+
28
+ /** What the F1/F2 Swiss seeding wrote: the four Swiss rows + the brand set derived from its seed. */
29
+ function swissExpectations(seed: string | null): Map<string, SeedExpectation> {
30
+ const rows: Array<Pick<DesignTokenRow, 'name' | 'value' | 'values'>> =
31
+ SWISS_FLAVOR.tokens.map(t => ({ name: t.name, value: t.value, values: t.values }));
32
+ if (seed) {
33
+ try { rows.push(...deriveBrandTokens(seed, SWISS_FLAVOR).map(t => ({ name: t.name, value: t.value, values: t.values }))); }
34
+ catch { /* an unparseable stored seed proves nothing about the rows */ }
35
+ }
36
+ return expectations(rows);
37
+ }
38
+
39
+ /**
40
+ * What the PREVIOUS design wrote, as a baseline keyed by the row's own stamp: a row Swiss stamped is
41
+ * judged against what Swiss wrote, every other row against `buildFlavorTokens` of the stored choices
42
+ * (for an org with no design row, Crisp's defaults — exactly what DS2's interim seed wrote). In a
43
+ * Swiss org the only v2 rows are that interim seed's, so they are judged against Crisp's DEFAULTS,
44
+ * never against the brand-seeded Crisp the migration is about to write.
45
+ */
46
+ export function designBaseline(previous: StoredDesign): SeedBaseline {
47
+ const retired = isRetiredFlavorId(previous.storedFlavorId);
48
+ const current = expectations(designTokenRows(retired ? packDesignChoices(previous.choices.flavorId) : previous.choices));
49
+ const swiss = swissExpectations(retired ? previous.storedSeed : null);
50
+ return (row: StoredDesignTokenRow) => {
51
+ const stampedBy = SEED_STAMP.exec(row.source ?? '')?.[1];
52
+ return stampedBy === SWISS_FLAVOR.id ? swiss.get(row.name) : current.get(row.name);
53
+ };
54
+ }
55
+
56
+ /** Apply `next` over the org's rows, judged against `previous` — the plan FlavorApplyService runs. */
57
+ export function planDesignApply(
58
+ existing: ReadonlyArray<StoredDesignTokenRow>,
59
+ previous: StoredDesign,
60
+ next: DesignChoices,
61
+ ): FlavorSeedPlan {
62
+ const pack = flavorPackById(next.flavorId);
63
+ if (!pack) return { create: [], update: [], kept: [] };
64
+ return planFlavorSeed(designTokenRows(next), existing, flavorSourceStamp(pack.id), designBaseline(previous));
65
+ }
66
+
67
+ /** The rows a design is missing by name — the builder-load top-up (never touches an existing row). */
68
+ export function missingDesignRows(existing: ReadonlyArray<{ name: string }>, choices: DesignChoices): DesignTokenRow[] {
69
+ const have = new Set((existing ?? []).map(r => r.name));
70
+ return designTokenRows(choices).filter(r => !have.has(r.name));
71
+ }
72
+
73
+ export interface SwissMigration {
74
+ /** The design the org now wears: Crisp, carrying the stored brand seed when it is a colour. */
75
+ choices: DesignChoices;
76
+ /** The token merge, the four legacy alias rewrites included. */
77
+ plan: FlavorSeedPlan;
78
+ }
79
+
80
+ const LEGACY_ALIASES: Readonly<Record<string, string>> = SPEC_LEGACY_ALIASES;
81
+
82
+ /**
83
+ * Swiss → Crisp (spec `legacyAliases`; D1). Needed when the design row says a retired flavor, or when
84
+ * there is no design row but the org holds Swiss-stamped alias rows (F1 seeded those before the row
85
+ * existed). Idempotent: once the row says Crisp nothing is needed, and re-running the plan writes
86
+ * nothing.
87
+ *
88
+ * - The look: `planDesignApply` from Swiss to Crisp — Swiss-stamped rows untouched since Swiss seeded
89
+ * 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.
93
+ */
94
+ export function planSwissMigration(existing: ReadonlyArray<StoredDesignTokenRow>, stored: StoredDesign): SwissMigration | null {
95
+ const swissAliasRows = (existing ?? []).filter(r => r.name in LEGACY_ALIASES && SEED_STAMP.exec(r.source ?? '')?.[1] === SWISS_FLAVOR.id);
96
+ const needed = isRetiredFlavorId(stored.storedFlavorId) || (!stored.hasRow && swissAliasRows.length > 0);
97
+ if (!needed) return null;
98
+
99
+ const choices = stored.choices;
100
+ const previous: StoredDesign = isRetiredFlavorId(stored.storedFlavorId) ? stored : { ...stored, storedFlavorId: SWISS_FLAVOR.id };
101
+ const plan = planDesignApply(existing, previous, choices);
102
+ const stamp = flavorSourceStamp(choices.flavorId);
103
+ const swiss = swissExpectations(null);
104
+ for (const row of (existing ?? []).filter(r => r.name in LEGACY_ALIASES)) {
105
+ const alias = LEGACY_ALIASES[row.name];
106
+ if (row.value === alias) continue;
107
+ const was = swiss.get(row.name);
108
+ const untouched = SEED_STAMP.test(row.source ?? '') && !!was && row.value === was.value && !row.values?.dark;
109
+ if (untouched && row._id) {
110
+ plan.update.push({ id: row._id, data: { value: alias, values: {}, source: stamp } });
111
+ } else {
112
+ plan.kept.push(row.name);
113
+ }
114
+ }
115
+ return { choices, plan };
116
+ }
@@ -0,0 +1,194 @@
1
+ import { SPEC_AXES, SPEC_FONT_ROLES } from './flavor-spec.generated';
2
+ import { parseHexColor } from './brand';
3
+ import { DesignChoices, DesignMode, FlavorPackV2, FontRoleToken, SPEC_FLAVORS } from './design-token.model';
4
+ import type { FlavorRevertSnapshot } from './flavor-seed';
5
+
6
+ /**
7
+ * The `settings.design` row (design-system plan §3f), read and written by ONE pair of functions so
8
+ * the builder, the server's public page read (the engine's mode) and the site build agree on what a
9
+ * stored row MEANS. The row is the settings collection's generic `config: [{ key, value }]`; values
10
+ * are strings there (Boolean "true"/"false", Object as JSON), but a reader also accepts the typed
11
+ * value a client control may have sent.
12
+ */
13
+
14
+ /** The look a studio wears until it picks one (DS2's interim seed, and DS6's first-run selection). */
15
+ export const DEFAULT_FLAVOR_ID = 'crisp';
16
+
17
+ /** One `config` entry of a settings row. */
18
+ export interface DesignConfigEntry {
19
+ key: string;
20
+ value: unknown;
21
+ }
22
+
23
+ export enum DesignConfigKey {
24
+ FlavorId = 'flavorId',
25
+ FlavorVersion = 'flavorVersion',
26
+ BrandSeed = 'brandSeed',
27
+ PairId = 'pairId',
28
+ Shape = 'shape',
29
+ Density = 'density',
30
+ Motion = 'motion',
31
+ PillButtons = 'pillButtons',
32
+ Mode = 'mode',
33
+ Fonts = 'fonts',
34
+ Revert = 'revert',
35
+ }
36
+
37
+ /** A stored design, resolved: `choices` is ALWAYS complete and valid (pack defaults fill the gaps). */
38
+ export interface StoredDesign {
39
+ choices: DesignChoices;
40
+ /** The flavor id exactly as stored — `swiss` until the DS4 migration rewrites it; null when no row. */
41
+ storedFlavorId: string | null;
42
+ /** The seed exactly as stored (F2 orgs carry their brand colour here); null when absent. */
43
+ storedSeed: string | null;
44
+ flavorVersion: string | null;
45
+ revert: FlavorRevertSnapshot | null;
46
+ /** False when the org has never stored a design — DS6's first run. */
47
+ hasRow: boolean;
48
+ }
49
+
50
+ const MODES: ReadonlyArray<string> = SPEC_AXES.modes;
51
+ const FONT_ROLE_TOKENS: ReadonlyArray<string> = SPEC_FONT_ROLES.map(r => r.token);
52
+
53
+ /** The pack for an id; a retired id (Swiss) resolves to the pack that `replaces` it. */
54
+ export function flavorPackById(id: string | null | undefined): FlavorPackV2 | undefined {
55
+ if (!id) return undefined;
56
+ return SPEC_FLAVORS.find(f => f.id === id) ?? SPEC_FLAVORS.find(f => f.replaces === id);
57
+ }
58
+
59
+ /** True when `id` names a retired flavor that a v2 pack replaces (Swiss → Crisp). */
60
+ export function isRetiredFlavorId(id: string | null | undefined): boolean {
61
+ return !!id && !SPEC_FLAVORS.some(f => f.id === id) && SPEC_FLAVORS.some(f => f.replaces === id);
62
+ }
63
+
64
+ const defaultPack = (): FlavorPackV2 => flavorPackById(DEFAULT_FLAVOR_ID) as FlavorPackV2;
65
+
66
+ const asString = (v: unknown): string | null => (typeof v === 'string' && v.trim() !== '' ? v.trim() : null);
67
+
68
+ function asBoolean(v: unknown): boolean | null {
69
+ if (v === true || v === 'true') return true;
70
+ if (v === false || v === 'false') return false;
71
+ return null;
72
+ }
73
+
74
+ function asObject<T>(v: unknown): T | null {
75
+ if (v && typeof v === 'object') return v as T;
76
+ if (typeof v !== 'string' || v.trim() === '') return null;
77
+ try {
78
+ const parsed = JSON.parse(v);
79
+ return parsed && typeof parsed === 'object' ? parsed as T : null;
80
+ } catch {
81
+ return null;
82
+ }
83
+ }
84
+
85
+ function asFonts(v: unknown): DesignChoices['fonts'] | undefined {
86
+ const raw = asObject<Record<string, unknown>>(v);
87
+ if (!raw) return undefined;
88
+ const fonts: Partial<Record<FontRoleToken, string>> = {};
89
+ for (const role of FONT_ROLE_TOKENS) {
90
+ const family = raw[role];
91
+ if (typeof family === 'string' && family.trim() !== '') fonts[role as FontRoleToken] = family.trim();
92
+ }
93
+ return Object.keys(fonts).length ? fonts : undefined;
94
+ }
95
+
96
+ const keyOf = <K extends string>(table: Readonly<Record<K, unknown>>, v: string | null): K | null =>
97
+ v !== null && Object.prototype.hasOwnProperty.call(table, v) ? v as K : null;
98
+
99
+ /** A config array → the key/value map (last entry wins, as the settings form writes it). */
100
+ function configMap(config: ReadonlyArray<DesignConfigEntry> | null | undefined): Map<string, unknown> {
101
+ const map = new Map<string, unknown>();
102
+ for (const kv of config ?? []) if (kv && typeof kv.key === 'string') map.set(kv.key, kv.value);
103
+ return map;
104
+ }
105
+
106
+ /**
107
+ * A partial set of choices completed against its pack: every missing or invalid value falls back to
108
+ * the pack's own default (an unknown pair → the pack's first, a bad seed → the pack's seed).
109
+ */
110
+ export function resolveDesignChoices(partial: Partial<DesignChoices> | null | undefined): DesignChoices {
111
+ const pack = flavorPackById(partial?.flavorId) ?? defaultPack();
112
+ const seed = asString(partial?.seed);
113
+ const pairId = asString(partial?.pairId);
114
+ const fonts = asFonts(partial?.fonts);
115
+ return {
116
+ flavorId: pack.id,
117
+ seed: seed && parseHexColor(seed) ? seed : pack.seed,
118
+ pairId: pairId && pack.pairs.some(p => p.id === pairId) ? pairId : pack.pairs[0]?.id ?? '',
119
+ shape: keyOf(SPEC_AXES.shapes, asString(partial?.shape)) ?? pack.defaults.shape,
120
+ density: keyOf(SPEC_AXES.densities, asString(partial?.density)) ?? pack.defaults.density,
121
+ motion: keyOf(SPEC_AXES.motions, asString(partial?.motion)) ?? pack.defaults.motion,
122
+ pillButtons: asBoolean(partial?.pillButtons) ?? pack.defaults.pillButtons,
123
+ mode: (MODES.includes(asString(partial?.mode) ?? '') ? asString(partial?.mode) : pack.defaults.mode) as DesignMode,
124
+ ...(fonts ? { fonts } : {}),
125
+ };
126
+ }
127
+
128
+ /** The pack's own defaults for `flavorId` — what picking a card means before anything is tuned. */
129
+ export function packDesignChoices(flavorId: string): DesignChoices {
130
+ return resolveDesignChoices({ flavorId });
131
+ }
132
+
133
+ /** A stored `settings.design` config → the resolved design. No row (or no config) → Crisp's defaults. */
134
+ export function readDesignConfig(config: ReadonlyArray<DesignConfigEntry> | null | undefined, hasRow = !!config): StoredDesign {
135
+ const map = configMap(config);
136
+ const storedFlavorId = asString(map.get(DesignConfigKey.FlavorId));
137
+ const storedSeed = asString(map.get(DesignConfigKey.BrandSeed));
138
+ const choices = resolveDesignChoices({
139
+ flavorId: storedFlavorId ?? undefined,
140
+ seed: storedSeed ?? undefined,
141
+ pairId: asString(map.get(DesignConfigKey.PairId)) ?? undefined,
142
+ shape: map.get(DesignConfigKey.Shape) as DesignChoices['shape'],
143
+ density: map.get(DesignConfigKey.Density) as DesignChoices['density'],
144
+ motion: map.get(DesignConfigKey.Motion) as DesignChoices['motion'],
145
+ pillButtons: asBoolean(map.get(DesignConfigKey.PillButtons)) ?? undefined,
146
+ mode: map.get(DesignConfigKey.Mode) as DesignMode,
147
+ fonts: map.get(DesignConfigKey.Fonts) as DesignChoices['fonts'],
148
+ });
149
+ return {
150
+ choices,
151
+ storedFlavorId,
152
+ storedSeed,
153
+ flavorVersion: asString(map.get(DesignConfigKey.FlavorVersion)),
154
+ revert: asObject<FlavorRevertSnapshot>(map.get(DesignConfigKey.Revert)),
155
+ hasRow,
156
+ };
157
+ }
158
+
159
+ /** The colour mode a stored config compiles in — what the public page read hands the engine. */
160
+ export function designModeOf(config: ReadonlyArray<DesignConfigEntry> | null | undefined): DesignMode {
161
+ return readDesignConfig(config).choices.mode;
162
+ }
163
+
164
+ /** Resolved choices (+ the revert snapshot) → the config array a settings row stores. */
165
+ export function designConfigEntries(
166
+ choices: DesignChoices,
167
+ flavorVersion: string | number,
168
+ revert?: FlavorRevertSnapshot | null,
169
+ ): DesignConfigEntry[] {
170
+ const c = resolveDesignChoices(choices);
171
+ return [
172
+ { key: DesignConfigKey.FlavorId, value: c.flavorId },
173
+ { key: DesignConfigKey.FlavorVersion, value: String(flavorVersion) },
174
+ { key: DesignConfigKey.BrandSeed, value: c.seed },
175
+ { key: DesignConfigKey.PairId, value: c.pairId },
176
+ { key: DesignConfigKey.Shape, value: c.shape },
177
+ { key: DesignConfigKey.Density, value: c.density },
178
+ { key: DesignConfigKey.Motion, value: c.motion },
179
+ { key: DesignConfigKey.PillButtons, value: String(c.pillButtons) },
180
+ { key: DesignConfigKey.Mode, value: c.mode },
181
+ ...(c.fonts ? [{ key: DesignConfigKey.Fonts, value: JSON.stringify(c.fonts) }] : []),
182
+ ...(revert ? [{ key: DesignConfigKey.Revert, value: JSON.stringify(revert) }] : []),
183
+ ];
184
+ }
185
+
186
+ /** Two choice sets describe the same look (fonts compared by role). */
187
+ export function sameDesignChoices(a: DesignChoices, b: DesignChoices): boolean {
188
+ const x = resolveDesignChoices(a);
189
+ const y = resolveDesignChoices(b);
190
+ return x.flavorId === y.flavorId && x.seed.toLowerCase() === y.seed.toLowerCase() && x.pairId === y.pairId
191
+ && x.shape === y.shape && x.density === y.density && x.motion === y.motion
192
+ && x.pillButtons === y.pillButtons && x.mode === y.mode
193
+ && JSON.stringify(x.fonts ?? {}) === JSON.stringify(y.fonts ?? {});
194
+ }
@@ -1,5 +1,5 @@
1
1
  import { FlavorTokenDef } from './flavor';
2
- import { DesignTokenRow, StoredDesignTokenRow } from './design-token.model';
2
+ import { DesignTokenRow, StoredDesignTokenRow, USER_TOKEN_SOURCE } from './design-token.model';
3
3
 
4
4
  /**
5
5
  * Seeding and reverting a look, as pure plans (flavors F2, moved here from iox-cms-client in
@@ -82,42 +82,68 @@ export function planFlavorRevert(
82
82
  return plan;
83
83
  }
84
84
 
85
+ /** What a previous seed wrote for one row — the "untouched" comparison base. */
86
+ export interface SeedExpectation {
87
+ value: string;
88
+ dark?: string;
89
+ }
90
+
91
+ /**
92
+ * What the PREVIOUS seed wrote, per row: a map by name, or a function of the stored row (a baseline
93
+ * that depends on WHICH seed stamped the row — the Swiss → Crisp migration's case).
94
+ */
95
+ export type SeedBaseline =
96
+ | ReadonlyMap<string, SeedExpectation>
97
+ | ((row: StoredDesignTokenRow) => SeedExpectation | undefined);
98
+
99
+ const expectationFor = (baseline: SeedBaseline | null | undefined, row: StoredDesignTokenRow): SeedExpectation | undefined =>
100
+ !baseline ? undefined : typeof baseline === 'function' ? baseline(row) : baseline.get(row.name);
101
+
102
+ const sameValue = (row: { value: string; values?: { dark?: string } }, e: SeedExpectation) =>
103
+ row.value === e.value && (row.values?.dark ?? '') === (e.dark ?? '');
104
+
85
105
  /**
86
106
  * The seeding MERGE: desired tokens vs existing rows, deciding create / update / keep. The §10 C2
87
107
  * rule — "an upgrade only rewrites untouched rows" — is a comparison against what the PREVIOUS
88
- * seed wrote (`oldExpected`): a seeded row whose value still equals it is the machine's and may be
89
- * rewritten; any drift means the author took it over, and their hand wins. A row without a
90
- * `flavor:` stamp is never touched at all. `group`/`tier` ride every write.
108
+ * seed wrote (`baseline`): a seeded row whose value still equals it is the machine's and may be
109
+ * rewritten; any drift means the author took it over, and their hand wins. A row the design did not
110
+ * write is never touched: no `flavor:` stamp, unless it is a `user` row (a font the author CHOSE in
111
+ * the design) whose value is exactly what the previous design wrote — that row is the design's own
112
+ * 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.
91
114
  */
92
115
  export function planFlavorSeed(
93
- desired: ReadonlyArray<FlavorTokenDef>,
116
+ desired: ReadonlyArray<FlavorTokenDef & { source?: string }>,
94
117
  existing: ReadonlyArray<StoredDesignTokenRow>,
95
118
  stamp: string,
96
- oldExpected: Map<string, { value: string; dark?: string }> | null,
119
+ baseline: SeedBaseline | null,
97
120
  ): FlavorSeedPlan {
98
121
  const byName = new Map((existing ?? []).map(r => [r.name, r]));
99
122
  const plan: FlavorSeedPlan = { create: [], update: [], kept: [] };
100
123
  for (const def of desired ?? []) {
101
124
  if (!def?.name) continue;
102
125
  const row = byName.get(def.name);
126
+ const source = def.source ?? stamp;
103
127
  if (!row) {
104
128
  plan.create.push({
105
129
  name: def.name,
106
130
  type: def.type ?? DEFAULT_TOKEN_TYPE,
107
131
  value: def.value,
108
132
  ...(def.values ? { values: def.values } : {}),
109
- source: stamp,
133
+ source,
110
134
  ...classification(def),
111
135
  });
112
136
  continue;
113
137
  }
114
- if (!isSeeded(row)) { plan.kept.push(def.name); continue; }
138
+ const old = expectationFor(baseline, row);
139
+ const designOwned = isSeeded(row) || (row.source === USER_TOKEN_SOURCE && !!old && sameValue(row, old));
140
+ if (!designOwned) { plan.kept.push(def.name); continue; }
115
141
  const sameAsDesired = row.value === def.value && (row.values?.dark ?? '') === (def.values?.dark ?? '');
116
- if (sameAsDesired) continue; // already exactly this — nothing to write, nothing kept-against
117
- const old = oldExpected?.get(def.name);
118
- const untouched = !!old && row.value === old.value && (row.values?.dark ?? '') === (old.dark ?? '');
119
- if (untouched && row._id) {
120
- plan.update.push({ id: row._id, data: { value: def.value, values: def.values ?? {}, source: stamp, ...classification(def) } });
142
+ 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
+ const untouched = !!old && sameValue(row, old);
145
+ if ((untouched || sameAsDesired) && row._id) {
146
+ plan.update.push({ id: row._id, data: { value: def.value, values: def.values ?? {}, source, ...classification(def) } });
121
147
  } else {
122
148
  plan.kept.push(def.name); // edited since seeding (or unverifiable) — the author's hand wins
123
149
  }
@@ -1077,7 +1077,7 @@ export const SPEC_SPRINGS = {
1077
1077
  } as const;
1078
1078
 
1079
1079
  export const SPEC_FONTS = {
1080
- "Inter": "Inter:wght@400;500;600;700",
1080
+ "Inter": "Inter:wght@300;400;500;600;700",
1081
1081
  "Inter Tight": "Inter+Tight:wght@500;600;700",
1082
1082
  "Manrope": "Manrope:wght@400;500;600;700",
1083
1083
  "IBM Plex Sans": "IBM+Plex+Sans:wght@400;500;600;700",
@@ -1092,12 +1092,21 @@ export const SPEC_FONTS = {
1092
1092
  "Figtree": "Figtree:wght@400;500;600;700;800",
1093
1093
  "Google Sans Flex": "Google+Sans+Flex:wght@400;500;600;700",
1094
1094
  "Bricolage Grotesque": "Bricolage+Grotesque:opsz,wght@12..96,500;12..96,700",
1095
- "Nunito": "Nunito:wght@400;600;700;800",
1095
+ "Nunito": "Nunito:wght@300;400;600;700;800",
1096
1096
  "DM Sans": "DM+Sans:wght@400;500;700",
1097
1097
  "Archivo": "Archivo:wght@400;500;700",
1098
1098
  "Archivo Black": "Archivo+Black",
1099
1099
  "Syne": "Syne:wght@600;700;800",
1100
- "Space Mono": "Space+Mono:wght@400;700"
1100
+ "Space Mono": "Space+Mono:wght@400;700",
1101
+ "Poppins": "Poppins:ital,wght@0,300;0,400;0,500;0,600;0,700;1,400",
1102
+ "Roboto": "Roboto:ital,wght@0,300;0,400;0,500;0,700;1,400",
1103
+ "Lato": "Lato:ital,wght@0,300;0,400;0,700;1,400",
1104
+ "Open Sans": "Open+Sans:ital,wght@0,300;0,400;0,600;0,700;1,400",
1105
+ "Montserrat": "Montserrat:ital,wght@0,300;0,400;0,500;0,600;0,700;1,400",
1106
+ "Raleway": "Raleway:wght@300;400;500;600;700",
1107
+ "Playfair Display": "Playfair+Display:ital,wght@0,400;0,500;0,700;1,400",
1108
+ "Oswald": "Oswald:wght@300;400;500;600;700",
1109
+ "Ubuntu": "Ubuntu:ital,wght@0,300;0,400;0,500;0,700;1,400"
1101
1110
  } as const;
1102
1111
 
1103
1112
  export const SPEC_SCHEMES = {
@@ -1762,7 +1771,6 @@ export const SPEC_FIELD_CLASS_BASELINE = {
1762
1771
  export const SPEC_LEGACY_ALIASES = {
1763
1772
  "radius-control": "var(--iox-button-radius)",
1764
1773
  "radius-surface": "var(--iox-card-radius)",
1765
- "space-control": "var(--iox-button-pad)",
1766
1774
  "space-stack": "var(--iox-space-gap)"
1767
1775
  } as const;
1768
1776
 
@@ -4,8 +4,9 @@ import { DesignTokenGroup, DesignTokenTier, DesignTokenType } from './design-tok
4
4
  * Flavors v1 (flavors-and-theming-plan.md, F1–F2): a pack is its TOKENS plus the accent hue
5
5
  * opinion the brand derivation reads. The per-type `defaults` map and its resolvers are gone
6
6
  * (design-system plan §3e, DS2): a new element is born wearing an ELEMENT STYLE
7
- * (`element-styles.ts`), which references tokens and is flavor-independent. Swiss survives only
8
- * until DS4 migrates orgs to Crisp.
7
+ * (`element-styles.ts`), which references tokens and is flavor-independent. Swiss is retired (DS4
8
+ * migrates `flavorId: swiss` orgs to Crisp); its pack stays as the migration's BASELINE — what F1/F2
9
+ * wrote, so "untouched since seeding" stays a comparison (`design-apply.ts`).
9
10
  */
10
11
 
11
12
  export interface FlavorTokenDef {
@@ -40,9 +41,9 @@ export function validateFlavorPack(pack: FlavorPack): string[] {
40
41
  }
41
42
 
42
43
  /**
43
- * Swiss — the F1 pack: four tokens (control/surface radius, control/stack spacing) and the hue
44
- * opinion F2's brand step reads. Kept until DS4 migrates `flavorId: swiss` orgs to Crisp; its four
45
- * rows are the spec's `legacyAliases`.
44
+ * Swiss — the retired F1 pack: four tokens (control/surface radius, control/stack spacing) and the
45
+ * hue opinion F2's brand step read. Three of its rows become the spec's `legacyAliases` on migration;
46
+ * `space-control` is a v2 token in every pack and takes Crisp's value.
46
47
  */
47
48
  export const SWISS_FLAVOR: FlavorPack = {
48
49
  id: 'swiss',
@@ -77,19 +77,56 @@ export function parseFontFamilies(stack: string): string[] {
77
77
  .filter(Boolean);
78
78
  }
79
79
 
80
+ /** Where Google serves the stylesheets, and the origin the font FILES come from (preconnect). */
81
+ export const GOOGLE_FONTS_CSS_ORIGIN = 'https://fonts.googleapis.com';
82
+ export const GOOGLE_FONTS_FILE_ORIGIN = 'https://fonts.gstatic.com';
83
+
84
+ /** The ONE stylesheet URL for a catalog id — both renderers link exactly this (one per family, T7). */
85
+ export function googleFontHref(id: string): string {
86
+ return `${GOOGLE_FONTS_CSS_ORIGIN}/css2?family=${id}&display=swap`;
87
+ }
88
+
89
+ const CATALOG_BY_LOWER = new Map(Object.entries(FONT_CATALOG).map(([family, id]) => [family.toLowerCase(), id]));
90
+
91
+ /** The catalog id of a family name (case-insensitive), or null — a system or uploaded face has none. */
92
+ export function catalogFontId(family: string | null | undefined): string | null {
93
+ return CATALOG_BY_LOWER.get(String(family ?? '').trim().toLowerCase()) ?? null;
94
+ }
95
+
96
+ const MONO_ROLE = 'font-mono';
97
+ const ALWAYS_LOADED_ROLES = FONT_FAMILY_TOKENS.filter(role => role !== MONO_ROLE);
98
+ const MONO_REFERENCE = /var\(\s*--iox-font-mono\b/;
99
+
100
+ export interface FontsForTokensOptions {
101
+ /**
102
+ * The CSS that will actually render (node rules, element styles). `font-mono` is loaded only when
103
+ * something READS it — here or in another token's value — because no element style does: an org
104
+ * would otherwise download a monospace face for a site with no code on it.
105
+ */
106
+ usedCss?: string;
107
+ }
108
+
80
109
  /**
81
- * The Google css2 ids to load for a token set: every catalog family named by a font role token,
82
- * one id per family (T7 — one `<link>` per family, so one bad id cannot sink the others).
110
+ * The Google css2 ids to load for a token set: every catalog family named by the display / body /
111
+ * label roles, plus mono when referenced (above). One id per family (T7 — one `<link>` per family, so
112
+ * one bad id cannot sink the others), in role order.
83
113
  */
84
- export function fontsForTokens(tokens: ReadonlyArray<DesignTokenLike> | null | undefined): string[] {
114
+ export function fontsForTokens(
115
+ tokens: ReadonlyArray<DesignTokenLike> | null | undefined,
116
+ opts: FontsForTokensOptions = {},
117
+ ): string[] {
118
+ const rows = (tokens ?? []).filter(t => !!t && typeof t.name === 'string');
119
+ const monoRead = MONO_REFERENCE.test(opts.usedCss ?? '')
120
+ || rows.some(t => t.name !== MONO_ROLE && MONO_REFERENCE.test(`${t.value ?? ''} ${t.values?.dark ?? ''}`));
121
+ const roles = monoRead ? FONT_FAMILY_TOKENS : ALWAYS_LOADED_ROLES;
85
122
  const seen = new Set<string>();
86
123
  const ids: string[] = [];
87
- for (const role of FONT_FAMILY_TOKENS) {
88
- const row = (tokens ?? []).find(t => t?.name === role);
124
+ for (const role of roles) {
125
+ const row = rows.find(t => t.name === role);
89
126
  for (const family of parseFontFamilies(row?.value ?? '')) {
90
- const id = FONT_CATALOG[family];
91
- if (id && !seen.has(family)) {
92
- seen.add(family);
127
+ const id = catalogFontId(family);
128
+ if (id && !seen.has(id)) {
129
+ seen.add(id);
93
130
  ids.push(id);
94
131
  }
95
132
  }
@@ -22,3 +22,5 @@ export * from './flavor-seed';
22
22
  export * from './spring';
23
23
  export * from './fonts';
24
24
  export * from './token-names';
25
+ export * from './design-settings';
26
+ export * from './design-apply';
@@ -1,5 +1,6 @@
1
1
  import { DesignMode } from './design-token.model';
2
2
  import { SPEC_A11Y, SPEC_SCHEMES } from './flavor-spec.generated';
3
+ import { specTokenNames } from './token-names';
3
4
 
4
5
  /**
5
6
  * Design-token → CSS compilation, shared by BOTH renderers (flavors F0, design-system DS1).
@@ -14,6 +15,11 @@ import { SPEC_A11Y, SPEC_SCHEMES } from './flavor-spec.generated';
14
15
  * 4. reduced transparency `@media (prefers-reduced-transparency: reduce)`
15
16
  * 5. dark — LAST `@media (prefers-color-scheme: dark)`
16
17
  *
18
+ * MODE (design-system D6) is a compile choice, never a row change: `auto` is the visitor-following
19
+ * order above; `light` compiles the base from light values and emits no dark block; `dark` compiles
20
+ * the base from DARK values (schemes and placeholders resolved from them too) and emits no dark block —
21
+ * so a Midnight site is dark for a light-OS visitor.
22
+ *
17
23
  * ⚠️ The appended-last contract: the dark block REDEFINES custom properties, so every consumer
18
24
  * appends it AFTER all of its base rules — the engine pushes it after the page's css blocks, the
19
25
  * builder registry keeps it as its last token rule (responsive A3 lesson).
@@ -32,6 +38,16 @@ export interface DesignTokenLike {
32
38
  export interface TokenCssOptions {
33
39
  /** Selector that carries the variables instead of `:root` — a preview wrapper, a specimen. */
34
40
  scope?: string;
41
+ /** The studio's colour mode (`settings.design.mode`). Absent = `auto`. */
42
+ mode?: DesignMode;
43
+ /**
44
+ * Declare every SPEC token the rows lack as `initial` on the scope (the guaranteed-invalid value),
45
+ * so a `var()` inside the scope cannot inherit a same-named property from an ANCESTOR. The builder
46
+ * needs it: the admin's `:root` chrome tokens share names with org tokens (`accent`, `surface-2`…),
47
+ * and a missing org row would silently paint the canvas in admin chrome. With it, the canvas
48
+ * computes exactly what the live site computes for that row — unset.
49
+ */
50
+ isolate?: boolean;
35
51
  }
36
52
 
37
53
  export interface TokenCssBlocks {
@@ -114,6 +130,16 @@ const DARK: ResolvedMode = 'dark';
114
130
 
115
131
  const modeValue = (t: DesignTokenLike, mode: ResolvedMode): string => (mode === DARK ? darkOf(t) ?? t.value : t.value);
116
132
 
133
+ /** The rows as ONE mode: each row's value in that mode, no dark override left. `auto` (or none) →
134
+ * the rows unchanged. */
135
+ function rowsForMode(rows: ReadonlyArray<DesignTokenLike>, mode: DesignMode | undefined): DesignTokenLike[] {
136
+ if (mode !== LIGHT && mode !== DARK) return [...rows];
137
+ return rows.map(t => {
138
+ const { values: _dropped, ...rest } = t;
139
+ return { ...rest, value: modeValue(t, mode) };
140
+ });
141
+ }
142
+
117
143
  /** Every token's ROOT value in one mode, with `var(--iox-…)` references resolved against the same
118
144
  * set (recursively; a reference cycle or an unknown token is left as written). */
119
145
  export function resolveTokenValues(
@@ -223,10 +249,14 @@ export function compileTokenCssBlocks(
223
249
  tokens: ReadonlyArray<DesignTokenLike> | null | undefined,
224
250
  opts: TokenCssOptions = {},
225
251
  ): TokenCssBlocks {
226
- const rows = usableTokens(tokens);
252
+ const rows = rowsForMode(usableTokens(tokens), opts.mode);
227
253
  const root = opts.scope ?? ROOT;
254
+ const present = new Set(rows.map(t => t.name));
255
+ const unset: Array<[string, string]> = opts.isolate
256
+ ? [...specTokenNames()].filter(name => !present.has(name)).map((name): [string, string] => [name, 'initial'])
257
+ : [];
228
258
  const base = [
229
- rule(root, rows.map((t): [string, string] => [t.name, t.value])),
259
+ rule(root, [...unset, ...rows.map((t): [string, string] => [t.name, t.value])]),
230
260
  compileSchemeCss(rows, opts.scope),
231
261
  ...ACCESSIBILITY_MEDIA.map(({ query, overrides }) =>
232
262
  media(query, [rule(root, presentOverrides(rows, overrides, false), ' ')])),
@@ -30,3 +30,24 @@ export function unknownTokenReferences(css: string, alsoAllowed: Iterable<string
30
30
  const extra = new Set(alsoAllowed);
31
31
  return tokenReferences(css).filter(name => !spec.has(name) && !extra.has(name));
32
32
  }
33
+
34
+ const LEGACY_SCOPE = /:not\(\s*\.iox-es\s*\)/;
35
+ const RULE = /([^{}]+)\{([^{}]*)\}/g;
36
+
37
+ /**
38
+ * Tokens read by a LEGACY rule — any rule whose selector is scoped `:not(.iox-es)` (the pre-DS2 look
39
+ * of a node that wears no element style, design-system §3b-bis). Such a rule must read NO token at
40
+ * 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.
42
+ */
43
+ export function legacyRuleTokenReferences(css: string): Array<{ selector: string; tokens: string[] }> {
44
+ const text = String(css ?? '').replace(/\/\*[\s\S]*?\*\//g, '');
45
+ 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 });
51
+ }
52
+ return out;
53
+ }