@vectoriox/iox-ui 5.22.0 → 5.24.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.22.0",
3
+ "version": "5.24.0",
4
4
  "peerDependencies": {
5
5
  "@angular/common": ">=20.0.0",
6
6
  "@angular/core": ">=20.0.0",
@@ -2,7 +2,9 @@ import { deriveBrandTokens } from './brand';
2
2
  import { SPEC_LEGACY_ALIASES } from './flavor-spec.generated';
3
3
  import { SWISS_FLAVOR } from './flavor';
4
4
  import { buildFlavorTokens } from './flavor-tokens';
5
- import { FlavorSeedPlan, planFlavorSeed, SeedBaseline, SeedExpectation } from './flavor-seed';
5
+ import {
6
+ FlavorRevertPlan, FlavorSeedOptions, FlavorSeedPlan, planFlavorRevert, planFlavorSeed, SeedBaseline, SeedExpectation, untouchedSeedRow,
7
+ } from './flavor-seed';
6
8
  import { DesignChoices, DesignTokenRow, flavorSourceStamp, StoredDesignTokenRow } from './design-token.model';
7
9
  import { fontFamilyKey, FontFamilyToken, fontRoleReferences, parseFontFamilies } from './fonts';
8
10
  import { flavorPackById, isRetiredFlavorId, packDesignChoices, StoredDesign } from './design-settings';
@@ -37,32 +39,77 @@ function swissExpectations(seed: string | null): Map<string, SeedExpectation> {
37
39
  return expectations(rows);
38
40
  }
39
41
 
42
+ /** What a v2 pack writes at its OWN defaults, by pack id — computed once per id per caller. */
43
+ function packDefaultsLookup(): (packId: string) => Map<string, SeedExpectation> | undefined {
44
+ const cache = new Map<string, Map<string, SeedExpectation> | undefined>();
45
+ return packId => {
46
+ if (!cache.has(packId)) {
47
+ const pack = isRetiredFlavorId(packId) ? undefined : flavorPackById(packId);
48
+ cache.set(packId, pack && pack.id === packId ? expectations(designTokenRows(packDesignChoices(pack.id))) : undefined);
49
+ }
50
+ return cache.get(packId);
51
+ };
52
+ }
53
+
40
54
  /**
41
55
  * What the PREVIOUS design wrote, as a baseline keyed by the row's own stamp: a row Swiss stamped is
42
56
  * judged against what Swiss wrote, every other row against `buildFlavorTokens` of the stored choices
43
57
  * (for an org with no design row, Crisp's defaults — exactly what DS2's interim seed wrote). In a
44
58
  * Swiss org the only v2 rows are that interim seed's, so they are judged against Crisp's DEFAULTS,
45
59
  * never against the brand-seeded Crisp the migration is about to write.
60
+ *
61
+ * A row a v2 pack stamped is ALSO untouched while it holds what that pack writes at its own defaults —
62
+ * what DS2's interim seed, a first run and every pack card at its defaults wrote. Without it, one row
63
+ * kept for any reason (a dark half iox-cms dropped, `untouchedSeedRow`) was judged against a design that
64
+ * had moved on, apply after apply, and stayed on the old value for good: the studio's brand colour
65
+ * reached the design row and never its accent.
46
66
  */
47
67
  export function designBaseline(previous: StoredDesign): SeedBaseline {
48
68
  const retired = isRetiredFlavorId(previous.storedFlavorId);
49
69
  const current = expectations(designTokenRows(retired ? packDesignChoices(previous.choices.flavorId) : previous.choices));
50
70
  const swiss = swissExpectations(retired ? previous.storedSeed : null);
71
+ const packDefaults = packDefaultsLookup();
51
72
  return (row: StoredDesignTokenRow) => {
52
73
  const stampedBy = SEED_STAMP.exec(row.source ?? '')?.[1];
53
- return stampedBy === SWISS_FLAVOR.id ? swiss.get(row.name) : current.get(row.name);
74
+ if (stampedBy === SWISS_FLAVOR.id) return swiss.get(row.name);
75
+ const ownDefault = stampedBy ? packDefaults(stampedBy)?.get(row.name) : undefined;
76
+ return [current.get(row.name), ownDefault].filter((e): e is SeedExpectation => !!e);
54
77
  };
55
78
  }
56
79
 
57
- /** Apply `next` over the org's rows, judged against `previous` — the plan FlavorApplyService runs. */
80
+ /** Options of an apply. */
81
+ export interface DesignApplyOptions {
82
+ /** The kept rows the author asked to take the look's values too (`FlavorSeedOptions.reclaim`). */
83
+ reclaim?: FlavorSeedOptions['reclaim'];
84
+ }
85
+
86
+ /**
87
+ * Apply `next` over the org's rows, judged against `previous` — the plan FlavorApplyService runs. The
88
+ * plan's `kept` names the author's rows the look leaves alone; the Design card lists them before Apply,
89
+ * and `options.reclaim` writes the look's value into the ones the author hands back.
90
+ */
58
91
  export function planDesignApply(
59
92
  existing: ReadonlyArray<StoredDesignTokenRow>,
60
93
  previous: StoredDesign,
61
94
  next: DesignChoices,
95
+ options: DesignApplyOptions = {},
62
96
  ): FlavorSeedPlan {
63
97
  const pack = flavorPackById(next.flavorId);
64
98
  if (!pack) return { create: [], update: [], kept: [] };
65
- return planFlavorSeed(designTokenRows(next), existing, flavorSourceStamp(pack.id), designBaseline(previous));
99
+ return planFlavorSeed(designTokenRows(next), existing, flavorSourceStamp(pack.id), designBaseline(previous), { reclaim: options.reclaim });
100
+ }
101
+
102
+ /**
103
+ * "Revert to previous look" over the org's rows — the ONE plan the Revert executes and its hint counts
104
+ * (`kept`: the author's edits since the apply, left as they are). The apply's record of what it wrote
105
+ * decides what an edit since is; a snapshot stored before that record existed (DS8.1) is judged against
106
+ * the rows the applied design writes — the look's own author rows (a chosen font) still go back.
107
+ * `null` when the design holds no snapshot.
108
+ */
109
+ export function planDesignRevert(current: ReadonlyArray<StoredDesignTokenRow>, design: StoredDesign): FlavorRevertPlan | null {
110
+ const snapshot = design?.revert;
111
+ if (!snapshot) return null;
112
+ return planFlavorRevert(current, snapshot.tokens, snapshot.wrote ?? designTokenRows(design.choices));
66
113
  }
67
114
 
68
115
  /** The rows a design is missing by name — the builder-load top-up (never touches an existing row). */
@@ -72,23 +119,34 @@ export function missingDesignRows(existing: ReadonlyArray<{ name: string }>, cho
72
119
  }
73
120
 
74
121
  /**
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.
122
+ * The builder-load REPAIR (DS4.1): rows the design writes that are the machine's and nobody touched,
123
+ * yet hold something other than the design's value, are rewritten — so an org heals on its next load
124
+ * with no author action. Two shapes:
125
+ * - stamped by the current pack, light value exactly the pack's, dark half not: iox-cms dropped
126
+ * `values.dark` on every write from F0 to DS4 (`values` was an implicit relation), and a revert could
127
+ * leave the previous apply's dark behind;
128
+ * - stamped by a v2 pack and still holding what THAT pack writes at its own defaults (its dark half
129
+ * possibly lost the same way) while the stored design says otherwise: an apply that judged the row
130
+ * edited (before 5.24.0, a lost dark half did) left it on the default — the six brand rows of a studio
131
+ * whose brand colour never reached its accent.
132
+ * No author edit produces either shape — the Tokens panel edited the light value only, and since DS8 an
133
+ * edit makes the row `source: user`, which this never touches.
81
134
  */
82
135
  export function designTokenRepairs(existing: ReadonlyArray<StoredDesignTokenRow>, choices: DesignChoices): FlavorSeedPlan['update'] {
83
136
  const pack = flavorPackById(choices.flavorId);
84
137
  if (!pack) return [];
85
138
  const stamp = flavorSourceStamp(pack.id);
86
139
  const desired = new Map(designTokenRows(choices).map(r => [r.name, r]));
140
+ const packDefaults = packDefaultsLookup();
87
141
  const out: FlavorSeedPlan['update'] = [];
88
142
  for (const row of existing ?? []) {
89
143
  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;
144
+ if (!want || !row._id || want.source !== stamp) continue;
145
+ if (row.value === want.value && (row.values?.dark ?? '') === (want.values?.dark ?? '') && row.source === stamp) continue;
146
+ const stampedBy = SEED_STAMP.exec(row.source ?? '')?.[1];
147
+ const darkOnly = row.source === stamp && row.value === want.value;
148
+ const ownDefault = stampedBy ? packDefaults(stampedBy)?.get(row.name) : undefined;
149
+ if (!darkOnly && !(ownDefault && untouchedSeedRow(row, [ownDefault]))) continue;
92
150
  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
151
  }
94
152
  return out;
@@ -25,13 +25,21 @@ export interface FlavorRevertSnapshot {
25
25
  /** The design row's identity BEFORE the apply, restored with the tokens. */
26
26
  from: { flavorId: string | null; flavorVersion: string | null; brandSeed: string | null };
27
27
  tokens: Array<Pick<DesignTokenRow, 'name' | 'type' | 'value' | 'values' | 'source' | 'group' | 'tier'>>;
28
+ /** What the apply left in every row it wrote (`flavorSeedWrites`). A row holding something else
29
+ * since, as the author's (`source: user`), was edited AFTER the apply — the revert keeps it. */
30
+ wrote?: FlavorRevertWrite[];
28
31
  }
29
32
 
33
+ /** One row as an apply left it — the value and dark half the revert compares a live row against. */
34
+ export type FlavorRevertWrite = Pick<DesignTokenRow, 'name' | 'value' | 'values'>;
35
+
30
36
  export interface FlavorRevertPlan {
31
37
  create: DesignTokenRow[];
32
38
  update: Array<{ id: string; data: Partial<DesignTokenRow> }>;
33
39
  /** Rows the apply created (flavor-stamped, absent from the snapshot) — removed on revert. */
34
40
  remove: string[];
41
+ /** The author's edits made after the apply, left as they are (names) — what the Revert hint counts. */
42
+ kept: string[];
35
43
  }
36
44
 
37
45
  export const REVERT_WINDOW_MS = 7 * 24 * 60 * 60 * 1000;
@@ -49,19 +57,30 @@ export function revertAvailable(appliedAt: number | null | undefined, now: numbe
49
57
  }
50
58
 
51
59
  /**
52
- * Restore the snapshot EXACTLY: snapshot rows come back to their stored value/dark/source
53
- * (update when present, recreate when deleted); rows the apply CREATED (flavor-stamped and
54
- * absent from the snapshot) are removed. Hand-made rows born after the apply are not ours
55
- * to touch — a revert undoes the FLAVOR, not the author's week.
60
+ * Restore the snapshot: snapshot rows come back to their stored value/dark/source (update when
61
+ * present, recreate when deleted); rows the apply CREATED (flavor-stamped and absent from the
62
+ * snapshot) are removed. A revert undoes the FLAVOR, not the author's week: hand-made rows born after
63
+ * the apply are not touched, and a row the author EDITED after the apply is kept (DS8: an edit makes
64
+ * the row the author's, and a pack switch keeps it — so does going back). Edited-after = `source: user`,
65
+ * differing from the snapshot, and holding something other than what the apply wrote (`wrote` — the
66
+ * apply's own author rows, a chosen font or an Element-styles edit, still go back with it). With no
67
+ * `wrote` at all, every author row that differs from the snapshot is judged edited after.
56
68
  */
57
69
  export function planFlavorRevert(
58
70
  current: ReadonlyArray<StoredDesignTokenRow>,
59
71
  snapshot: FlavorRevertSnapshot['tokens'],
72
+ wrote?: ReadonlyArray<FlavorRevertWrite> | null,
60
73
  ): FlavorRevertPlan {
61
74
  const byName = new Map<string, StoredDesignTokenRow>();
62
75
  for (const row of current ?? []) byName.set(row.name, row);
76
+ const written = new Map((wrote ?? []).map(w => [w.name, w]));
77
+ const editedAfter = (live: StoredDesignTokenRow) => {
78
+ if (live.source !== USER_TOKEN_SOURCE) return false;
79
+ const w = written.get(live.name);
80
+ return !w || !sameValue(live, { value: w.value, dark: w.values?.dark });
81
+ };
63
82
  const snapNames = new Set<string>();
64
- const plan: FlavorRevertPlan = { create: [], update: [], remove: [] };
83
+ const plan: FlavorRevertPlan = { create: [], update: [], remove: [], kept: [] };
65
84
  for (const snap of snapshot ?? []) {
66
85
  snapNames.add(snap.name);
67
86
  const live = byName.get(snap.name);
@@ -72,18 +91,33 @@ export function planFlavorRevert(
72
91
  const differs = live.value !== snap.value
73
92
  || (live.values?.dark ?? null) !== (snap.values?.dark ?? null)
74
93
  || (live.source ?? null) !== (snap.source ?? null);
94
+ if (!differs || !live._id) continue;
95
+ if (editedAfter(live)) { plan.kept.push(live.name); continue; }
75
96
  // `values` is ALWAYS sent: an absent key is dropped by JSON and a `$set` then keeps the
76
97
  // apply's dark override — a snapshot row with no dark must clear it, so it sends `{}`.
77
- if (differs && live._id) {
78
- plan.update.push({ id: live._id, data: { value: snap.value, values: snap.values ?? {}, source: snap.source } });
79
- }
98
+ plan.update.push({ id: live._id, data: { value: snap.value, values: snap.values ?? {}, source: snap.source } });
80
99
  }
81
100
  for (const row of current ?? []) {
82
- if (!snapNames.has(row.name) && row._id && isSeeded(row)) plan.remove.push(row._id);
101
+ if (snapNames.has(row.name) || !row._id) continue;
102
+ if (isSeeded(row)) plan.remove.push(row._id);
103
+ else if (written.has(row.name) && editedAfter(row)) plan.kept.push(row.name);
83
104
  }
84
105
  return plan;
85
106
  }
86
107
 
108
+ /** What a seed plan leaves in each row it writes, by name — the snapshot's `wrote`. */
109
+ export function flavorSeedWrites(plan: FlavorSeedPlan, existing: ReadonlyArray<StoredDesignTokenRow>): FlavorRevertWrite[] {
110
+ const byId = new Map((existing ?? []).filter(r => r._id).map(r => [r._id as string, r]));
111
+ const out: FlavorRevertWrite[] = (plan?.create ?? []).map(r => ({ name: r.name, value: r.value, ...(r.values ? { values: r.values } : {}) }));
112
+ for (const u of plan?.update ?? []) {
113
+ const row = byId.get(u.id);
114
+ if (!row) continue;
115
+ const values = u.data.values ?? row.values;
116
+ out.push({ name: row.name, value: u.data.value ?? row.value, ...(values ? { values } : {}) });
117
+ }
118
+ return out;
119
+ }
120
+
87
121
  /** What a previous seed wrote for one row — the "untouched" comparison base. */
88
122
  export interface SeedExpectation {
89
123
  value: string;
@@ -91,19 +125,44 @@ export interface SeedExpectation {
91
125
  }
92
126
 
93
127
  /**
94
- * What the PREVIOUS seed wrote, per row: a map by name, or a function of the stored row (a baseline
95
- * that depends on WHICH seed stamped the row — the Swiss → Crisp migration's case).
128
+ * What a previous seed wrote, per row: a map by name, or a function of the stored row (a baseline that
129
+ * depends on WHICH seed stamped the row — the Swiss → Crisp migration's case). The function may name
130
+ * several values: a machine row is untouched when it holds ANY of them (`designBaseline`: what the
131
+ * previous design wrote, and what the row's own pack writes at its defaults).
96
132
  */
97
133
  export type SeedBaseline =
98
134
  | ReadonlyMap<string, SeedExpectation>
99
- | ((row: StoredDesignTokenRow) => SeedExpectation | undefined);
135
+ | ((row: StoredDesignTokenRow) => SeedExpectation | ReadonlyArray<SeedExpectation> | undefined);
100
136
 
101
- const expectationFor = (baseline: SeedBaseline | null | undefined, row: StoredDesignTokenRow): SeedExpectation | undefined =>
102
- !baseline ? undefined : typeof baseline === 'function' ? baseline(row) : baseline.get(row.name);
137
+ /** Options of a seed pass. */
138
+ export interface FlavorSeedOptions {
139
+ /** Rows the author asked to take the look's values too ("Use the look's values for these too"):
140
+ * written like an untouched row instead of being kept. */
141
+ reclaim?: Iterable<string> | null;
142
+ }
143
+
144
+ function expectationsFor(baseline: SeedBaseline | null | undefined, row: StoredDesignTokenRow): SeedExpectation[] {
145
+ if (!baseline) return [];
146
+ const found = typeof baseline === 'function' ? baseline(row) : baseline.get(row.name);
147
+ if (!found) return [];
148
+ return (Array.isArray(found) ? found : [found]).filter(Boolean) as SeedExpectation[];
149
+ }
103
150
 
104
151
  const sameValue = (row: { value: string; values?: { dark?: string } }, e: SeedExpectation) =>
105
152
  row.value === e.value && (row.values?.dark ?? '') === (e.dark ?? '');
106
153
 
154
+ /**
155
+ * A machine-stamped row is untouched when it holds what a seed wrote — or that seed's LIGHT value with no
156
+ * dark half at all: iox-cms dropped every `values.dark` from F0 to DS4.1, and no author edit produces that
157
+ * shape (the Tokens panel edited the light value only, and since DS8 an edit makes the row `source: user`).
158
+ * Judging it edited kept the six brand rows of every studio seeded before DS4.1 on the default blue for
159
+ * good — a later brand never reached them.
160
+ */
161
+ export function untouchedSeedRow(row: StoredDesignTokenRow, expected: ReadonlyArray<SeedExpectation>): boolean {
162
+ const darkLost = isSeeded(row) && !row.values?.dark;
163
+ return expected.some(e => sameValue(row, e) || (darkLost && row.value === e.value));
164
+ }
165
+
107
166
  /**
108
167
  * The seeding MERGE: desired tokens vs existing rows, deciding create / update / keep. The §10 C2
109
168
  * rule — "an upgrade only rewrites untouched rows" — is a comparison against what the PREVIOUS
@@ -114,15 +173,18 @@ const sameValue = (row: { value: string; values?: { dark?: string } }, e: SeedEx
114
173
  * choice, so a new choice may replace it. `group`/`tier` ride every write; a desired row's own
115
174
  * `source` (a chosen font's `user`) wins over the pack stamp. A machine-owned row that already holds
116
175
  * 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).
176
+ * design that owns the row, so the next switch judges it against the right baseline). A row named in
177
+ * `options.reclaim` is written whatever it holds — the author chose the look's value over their own.
118
178
  */
119
179
  export function planFlavorSeed(
120
180
  desired: ReadonlyArray<FlavorTokenDef & { source?: string }>,
121
181
  existing: ReadonlyArray<StoredDesignTokenRow>,
122
182
  stamp: string,
123
183
  baseline: SeedBaseline | null,
184
+ options: FlavorSeedOptions = {},
124
185
  ): FlavorSeedPlan {
125
186
  const byName = new Map((existing ?? []).map(r => [r.name, r]));
187
+ const reclaim = new Set(options.reclaim ?? []);
126
188
  const plan: FlavorSeedPlan = { create: [], update: [], kept: [] };
127
189
  for (const def of desired ?? []) {
128
190
  if (!def?.name) continue;
@@ -139,13 +201,14 @@ export function planFlavorSeed(
139
201
  });
140
202
  continue;
141
203
  }
142
- const old = expectationFor(baseline, row);
143
- const designOwned = isSeeded(row) || (row.source === USER_TOKEN_SOURCE && !!old && sameValue(row, old));
144
- if (!designOwned) { plan.kept.push(def.name); continue; }
204
+ const olds = expectationsFor(baseline, row);
205
+ const reclaimed = reclaim.has(def.name);
206
+ const designOwned = isSeeded(row) || (row.source === USER_TOKEN_SOURCE && olds.some(o => sameValue(row, o)));
207
+ if (!designOwned && !reclaimed) { plan.kept.push(def.name); continue; }
145
208
  const sameAsDesired = row.value === def.value && (row.values?.dark ?? '') === (def.values?.dark ?? '');
146
209
  if (sameAsDesired && (row.source ?? '') === source) continue; // already exactly this
147
- const untouched = !!old && sameValue(row, old);
148
- if ((untouched || sameAsDesired) && row._id) {
210
+ const untouched = untouchedSeedRow(row, olds);
211
+ if ((untouched || sameAsDesired || reclaimed) && row._id) {
149
212
  plan.update.push({ id: row._id, data: { value: def.value, values: def.values ?? {}, source, ...classification(def) } });
150
213
  } else {
151
214
  plan.kept.push(def.name); // edited since seeding (or unverifiable) — the author's hand wins