@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
|
@@ -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 {
|
|
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
|
-
|
|
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
|
-
/**
|
|
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
|
|
76
|
-
*
|
|
77
|
-
*
|
|
78
|
-
*
|
|
79
|
-
*
|
|
80
|
-
* the
|
|
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 ||
|
|
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
|
|
53
|
-
*
|
|
54
|
-
*
|
|
55
|
-
*
|
|
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
|
-
|
|
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 (
|
|
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
|
|
95
|
-
*
|
|
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
|
-
|
|
102
|
-
|
|
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
|
|
143
|
-
const
|
|
144
|
-
|
|
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 =
|
|
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
|