@vectoriox/iox-ui 5.21.0 → 5.23.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/design-core/index.d.ts +155 -7
- package/fesm2022/vectoriox-iox-ui-design-core.mjs +267 -11
- package/fesm2022/vectoriox-iox-ui-design-core.mjs.map +1 -1
- package/package.json +1 -1
- package/src/design-core/adopt.ts +250 -0
- package/src/design-core/design-apply.ts +14 -1
- package/src/design-core/element-styles.ts +17 -0
- package/src/design-core/flavor-seed.ts +43 -9
- package/src/design-core/public-api.ts +1 -0
package/package.json
CHANGED
|
@@ -0,0 +1,250 @@
|
|
|
1
|
+
import {
|
|
2
|
+
BUTTON_VARIANT_TO_STYLE, ElementStyle, ElementStyleTarget, OwnedTrait, defaultElementStyleFor,
|
|
3
|
+
elementStyleById, ownedTraitsOf, readOwnedTrait, writeOwnedTrait,
|
|
4
|
+
} from './element-styles';
|
|
5
|
+
|
|
6
|
+
/**
|
|
7
|
+
* ADOPT (design-system DS7, decision D5): link the nodes of a page saved before element styles to
|
|
8
|
+
* the style a NEW node of their type is born wearing, so the page follows the look from then on.
|
|
9
|
+
* Opt-in per page, one click, one undo step — this module is only the pure tree pass; the builder
|
|
10
|
+
* owns the click, the history step and the save.
|
|
11
|
+
*
|
|
12
|
+
* The rule that keeps the author's work: a trait the style owns is rewritten to the style's
|
|
13
|
+
* reference ONLY when the node still holds what an untouched node of its type was saved with — the
|
|
14
|
+
* type's BASELINE (the unstyled constructor default the caller supplies), or a value a machine wrote
|
|
15
|
+
* into every new node before element styles existed (`PRE_DS2_FLAVOR_DEFAULTS`). Anything else is an
|
|
16
|
+
* author edit and is KEPT, and reported.
|
|
17
|
+
*
|
|
18
|
+
* A node that already wears a style is UPGRADED where it holds a value that style wrote in an OLDER
|
|
19
|
+
* contract (`SUPERSEDED_STYLE_VALUES`) — the same untouched-means-machine-written rule, one version on.
|
|
20
|
+
*
|
|
21
|
+
* Works on both node shapes element-styles.ts does: a builder node (values in `styleTraits`) or a
|
|
22
|
+
* serialized node (`styleProps`), so the server-side site build can run it too.
|
|
23
|
+
*/
|
|
24
|
+
|
|
25
|
+
/**
|
|
26
|
+
* What a NEW node holds before its element style is written (C1.1): running text inherits, so a
|
|
27
|
+
* literal colour on any container would override the section's text. The factory
|
|
28
|
+
* (`ComponentRegistryService.createConfig`) writes it; adopt releases a baseline colour to it, so an
|
|
29
|
+
* adopted node is exactly what a new node of its type is.
|
|
30
|
+
*/
|
|
31
|
+
export const NEW_NODE_STYLE_DEFAULTS: Readonly<Record<string, string>> = { color: 'inherit' };
|
|
32
|
+
|
|
33
|
+
/**
|
|
34
|
+
* Values a machine wrote into every NEW node of a type before element styles: the F1 Swiss pack's
|
|
35
|
+
* element defaults (`SWISS_FLAVOR.defaults`, iox-ui 5.3.0 `0a00761` + iox-builder 4.24.0 `85109eb`,
|
|
36
|
+
* `createConfig → applyFlavorDefaults`, resolved by `resolveFlavorDefaults` to
|
|
37
|
+
* `var(--iox-<token>, <pack value>)`; deleted in DS2). An untouched node created between F1 and DS2
|
|
38
|
+
* holds exactly these, so they count as untouched.
|
|
39
|
+
*/
|
|
40
|
+
export const PRE_DS2_FLAVOR_DEFAULTS: Readonly<Record<string, Readonly<Record<string, string>>>> = {
|
|
41
|
+
Button: { borderRadius: 'var(--iox-radius-control, 4px)', padding: 'var(--iox-space-control, 10px 18px)' },
|
|
42
|
+
ButtonBlock: { borderRadius: 'var(--iox-radius-control, 4px)', padding: 'var(--iox-space-control, 10px 18px)' },
|
|
43
|
+
Card: { borderRadius: 'var(--iox-radius-surface, 4px)' },
|
|
44
|
+
};
|
|
45
|
+
|
|
46
|
+
/** One owned trait whose value an element style CHANGED in the contract's history. */
|
|
47
|
+
export interface SupersededStyleValue extends OwnedTrait {
|
|
48
|
+
readonly style: string;
|
|
49
|
+
/** What the style wrote before. */
|
|
50
|
+
readonly values: ReadonlyArray<string>;
|
|
51
|
+
/** The style did not own the trait before, so an untouched node held its type's baseline. */
|
|
52
|
+
readonly wasUnowned?: boolean;
|
|
53
|
+
/** The iox-ai-guidance contract commit that changed it. */
|
|
54
|
+
readonly contract: string;
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
/**
|
|
58
|
+
* Every element-style value the contract has superseded since element styles shipped (a diff of
|
|
59
|
+
* `flavor-spec.v1.json`'s `elementStyles` across its history — nothing else changed a value):
|
|
60
|
+
* - `b86c54f` (C1): body / lead `color: var(--iox-text)` → `inherit`;
|
|
61
|
+
* - `602d96e` (C1.1): box / card / panel / nav gained `color: inherit` — before, they owned no colour.
|
|
62
|
+
* A spec pins this list against the generated contract. Add an entry with every contract change.
|
|
63
|
+
*/
|
|
64
|
+
export const SUPERSEDED_STYLE_VALUES: ReadonlyArray<SupersededStyleValue> = [
|
|
65
|
+
{ style: 'body', trait: 'color', values: ['var(--iox-text)'], contract: 'b86c54f' },
|
|
66
|
+
{ style: 'lead', trait: 'color', values: ['var(--iox-text)'], contract: 'b86c54f' },
|
|
67
|
+
{ style: 'box', trait: 'color', values: [], wasUnowned: true, contract: '602d96e' },
|
|
68
|
+
{ style: 'card', trait: 'color', values: [], wasUnowned: true, contract: '602d96e' },
|
|
69
|
+
{ style: 'panel', trait: 'color', values: [], wasUnowned: true, contract: '602d96e' },
|
|
70
|
+
{ style: 'nav', trait: 'color', values: [], wasUnowned: true, contract: '602d96e' },
|
|
71
|
+
];
|
|
72
|
+
|
|
73
|
+
export enum AdoptOutcome {
|
|
74
|
+
/** The node wore no style; it now wears its type's default (or its legacy Button variant's). */
|
|
75
|
+
Linked = 'linked',
|
|
76
|
+
/** The node wore a style; values that style wrote in an older contract were brought current. */
|
|
77
|
+
Upgraded = 'upgraded',
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
export enum AdoptSkipReason {
|
|
81
|
+
/** The type is born wearing no style (Gallery, SliderContainer, Spacer, …). */
|
|
82
|
+
NoStyle = 'no-style',
|
|
83
|
+
/** The node takes its look from a style preset — the author's own class. */
|
|
84
|
+
Preset = 'preset',
|
|
85
|
+
/** The node is (inside) a linked symbol instance: its look is the symbol's, shared across pages. */
|
|
86
|
+
Symbol = 'symbol',
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
export interface AdoptContext {
|
|
90
|
+
/** Per type, the style map an untouched SAVED node of it holds — its unstyled constructor
|
|
91
|
+
* defaults (the builder's `ComponentRegistryService.adoptionBaseline`). */
|
|
92
|
+
baseline(type: string): Readonly<Record<string, unknown>>;
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
/** The shapes this pass reads: an element-style target plus the fields that decide skips and styles. */
|
|
96
|
+
export interface AdoptableNode extends ElementStyleTarget {
|
|
97
|
+
id?: string;
|
|
98
|
+
props?: Readonly<Record<string, unknown>> | null;
|
|
99
|
+
traits?: ReadonlyArray<{ name: string; default?: unknown }> | null;
|
|
100
|
+
stylePresetId?: string | null;
|
|
101
|
+
symbolId?: string | null;
|
|
102
|
+
children?: AdoptableNode[] | null;
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
/** An owned trait the author had changed — kept, with the value kept. */
|
|
106
|
+
export interface KeptEdit extends OwnedTrait {
|
|
107
|
+
value: unknown;
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
export interface AdoptedNode {
|
|
111
|
+
id?: string;
|
|
112
|
+
type: string;
|
|
113
|
+
style: string;
|
|
114
|
+
outcome: AdoptOutcome;
|
|
115
|
+
/** Traits rewritten (the style's references, a current value, or a released colour). */
|
|
116
|
+
written: OwnedTrait[];
|
|
117
|
+
kept: KeptEdit[];
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
export interface SkippedNode {
|
|
121
|
+
id?: string;
|
|
122
|
+
type: string;
|
|
123
|
+
reason: AdoptSkipReason;
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
export interface AdoptReport {
|
|
127
|
+
adopted: AdoptedNode[];
|
|
128
|
+
skipped: SkippedNode[];
|
|
129
|
+
/** Author edits kept across every adopted node. */
|
|
130
|
+
kept: number;
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
const sameValue = (a: unknown, b: unknown): boolean =>
|
|
134
|
+
a === b || (a !== undefined && a !== null && b !== undefined && b !== null && String(a) === String(b));
|
|
135
|
+
|
|
136
|
+
function propOf(node: AdoptableNode, name: string): unknown {
|
|
137
|
+
const fromProps = node.props?.[name];
|
|
138
|
+
if (fromProps !== undefined) return fromProps;
|
|
139
|
+
return node.traits?.find(t => t?.name === name)?.default;
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
/** The style an unstyled node adopts: a legacy Button by its `variant` (spec `buttonVariantToStyle`),
|
|
143
|
+
* every other type by its default (a Heading by its level), or null. */
|
|
144
|
+
export function adoptionStyleFor(node: AdoptableNode): string | null {
|
|
145
|
+
if (node.type === 'Button') {
|
|
146
|
+
const byVariant = BUTTON_VARIANT_TO_STYLE[String(propOf(node, 'variant') ?? '')];
|
|
147
|
+
if (byVariant) return byVariant;
|
|
148
|
+
}
|
|
149
|
+
const traits: Record<string, unknown> = { ...(node.props ?? {}) };
|
|
150
|
+
for (const t of node.traits ?? []) if (t?.name && !(t.name in traits)) traits[t.name] = t.default;
|
|
151
|
+
return defaultElementStyleFor(node.type, traits);
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
const owns = (style: ElementStyle, trait: string) => trait in style.base;
|
|
155
|
+
|
|
156
|
+
/** Release each new-node default the style does not own, where the node still holds its baseline. */
|
|
157
|
+
function releaseNewNodeDefaults(node: AdoptableNode, style: ElementStyle | null, baseline: Readonly<Record<string, unknown>>, written: OwnedTrait[]): void {
|
|
158
|
+
for (const [trait, value] of Object.entries(NEW_NODE_STYLE_DEFAULTS)) {
|
|
159
|
+
if (style && owns(style, trait)) continue;
|
|
160
|
+
const ref = { trait };
|
|
161
|
+
const current = readOwnedTrait(node, ref);
|
|
162
|
+
if (current === undefined || sameValue(current, value) || !sameValue(current, baseline[trait])) continue;
|
|
163
|
+
if (writeOwnedTrait(node, ref, value)) written.push(ref);
|
|
164
|
+
}
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
function untouched(node: AdoptableNode, trait: string, value: unknown, baseline: Readonly<Record<string, unknown>>): boolean {
|
|
168
|
+
return value === undefined || sameValue(value, baseline[trait]) || sameValue(value, PRE_DS2_FLAVOR_DEFAULTS[node.type]?.[trait]);
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
function link(node: AdoptableNode, style: ElementStyle, baseline: Readonly<Record<string, unknown>>): AdoptedNode {
|
|
172
|
+
const written: OwnedTrait[] = [];
|
|
173
|
+
const kept: KeptEdit[] = [];
|
|
174
|
+
for (const { value, ...ref } of ownedTraitsOf(style)) {
|
|
175
|
+
const current = readOwnedTrait(node, ref);
|
|
176
|
+
if (sameValue(current, value)) continue;
|
|
177
|
+
// A pseudo-state map has no baseline: a state value is always the author's.
|
|
178
|
+
const free = ref.state ? current === undefined : untouched(node, ref.trait, current, baseline);
|
|
179
|
+
if (!free) { kept.push({ ...ref, value: current }); continue; }
|
|
180
|
+
if (writeOwnedTrait(node, ref, value)) written.push(ref);
|
|
181
|
+
}
|
|
182
|
+
releaseNewNodeDefaults(node, style, baseline, written);
|
|
183
|
+
node.elementStyle = style.id;
|
|
184
|
+
return { id: node.id, type: node.type, style: style.id, outcome: AdoptOutcome.Linked, written, kept };
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
function upgrade(node: AdoptableNode, style: ElementStyle, baseline: Readonly<Record<string, unknown>>): AdoptedNode | null {
|
|
188
|
+
const written: OwnedTrait[] = [];
|
|
189
|
+
for (const old of SUPERSEDED_STYLE_VALUES) {
|
|
190
|
+
if (old.style !== style.id) continue;
|
|
191
|
+
const ref: OwnedTrait = old.state ? { trait: old.trait, state: old.state } : { trait: old.trait };
|
|
192
|
+
const now = old.state ? style[old.state]?.[old.trait] : style.base[old.trait];
|
|
193
|
+
if (now === undefined) continue;
|
|
194
|
+
const current = readOwnedTrait(node, ref);
|
|
195
|
+
const before = old.wasUnowned ? [...old.values, baseline[old.trait]] : old.values;
|
|
196
|
+
if (!before.some(v => sameValue(current, v))) continue;
|
|
197
|
+
if (writeOwnedTrait(node, ref, now)) written.push(ref);
|
|
198
|
+
}
|
|
199
|
+
releaseNewNodeDefaults(node, style, baseline, written);
|
|
200
|
+
return written.length ? { id: node.id, type: node.type, style: style.id, outcome: AdoptOutcome.Upgraded, written, kept: [] } : null;
|
|
201
|
+
}
|
|
202
|
+
|
|
203
|
+
/**
|
|
204
|
+
* Adopt every node of `tree` in place (children included) and report what happened. Skipped, never
|
|
205
|
+
* touched: a node with a style preset, and every node of a linked symbol instance. A node whose type
|
|
206
|
+
* has no default style is not linked, but an untouched baseline colour on it is released to the
|
|
207
|
+
* new-node default all the same, so adopted text inside it is not painted the old literal.
|
|
208
|
+
*/
|
|
209
|
+
export function adoptElementStyles(tree: AdoptableNode[] | null | undefined, ctx: AdoptContext): AdoptReport {
|
|
210
|
+
const report: AdoptReport = { adopted: [], skipped: [], kept: 0 };
|
|
211
|
+
const visit = (nodes: AdoptableNode[] | null | undefined, inSymbol: boolean) => {
|
|
212
|
+
for (const node of nodes ?? []) {
|
|
213
|
+
if (!node?.type) continue;
|
|
214
|
+
const symbol = inSymbol || !!node.symbolId;
|
|
215
|
+
adoptNode(node, symbol, ctx, report);
|
|
216
|
+
visit(node.children, symbol);
|
|
217
|
+
}
|
|
218
|
+
};
|
|
219
|
+
visit(tree, false);
|
|
220
|
+
report.kept = report.adopted.reduce((n, a) => n + a.kept.length, 0);
|
|
221
|
+
return report;
|
|
222
|
+
}
|
|
223
|
+
|
|
224
|
+
function adoptNode(node: AdoptableNode, inSymbol: boolean, ctx: AdoptContext, report: AdoptReport): void {
|
|
225
|
+
const worn = elementStyleById(node.elementStyle);
|
|
226
|
+
const target = worn ? null : elementStyleById(adoptionStyleFor(node));
|
|
227
|
+
const reason = inSymbol ? AdoptSkipReason.Symbol : node.stylePresetId ? AdoptSkipReason.Preset : null;
|
|
228
|
+
if (reason) {
|
|
229
|
+
if (target) report.skipped.push({ id: node.id, type: node.type, reason });
|
|
230
|
+
return;
|
|
231
|
+
}
|
|
232
|
+
const baseline = ctx.baseline(node.type) ?? {};
|
|
233
|
+
if (worn) {
|
|
234
|
+
const upgraded = upgrade(node, worn, baseline);
|
|
235
|
+
if (upgraded) report.adopted.push(upgraded);
|
|
236
|
+
return;
|
|
237
|
+
}
|
|
238
|
+
if (node.elementStyle) return;
|
|
239
|
+
if (!target) {
|
|
240
|
+
releaseNewNodeDefaults(node, null, baseline, []);
|
|
241
|
+
report.skipped.push({ id: node.id, type: node.type, reason: AdoptSkipReason.NoStyle });
|
|
242
|
+
return;
|
|
243
|
+
}
|
|
244
|
+
report.adopted.push(link(node, target, baseline));
|
|
245
|
+
}
|
|
246
|
+
|
|
247
|
+
/** What adopting `tree` WOULD do, without touching it (the banner's count). */
|
|
248
|
+
export function previewElementStyleAdoption(tree: AdoptableNode[] | null | undefined, ctx: AdoptContext): AdoptReport {
|
|
249
|
+
return adoptElementStyles(JSON.parse(JSON.stringify(tree ?? [])), ctx);
|
|
250
|
+
}
|
|
@@ -2,7 +2,7 @@ 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 { FlavorRevertPlan, FlavorSeedPlan, planFlavorRevert, planFlavorSeed, SeedBaseline, SeedExpectation } from './flavor-seed';
|
|
6
6
|
import { DesignChoices, DesignTokenRow, flavorSourceStamp, StoredDesignTokenRow } from './design-token.model';
|
|
7
7
|
import { fontFamilyKey, FontFamilyToken, fontRoleReferences, parseFontFamilies } from './fonts';
|
|
8
8
|
import { flavorPackById, isRetiredFlavorId, packDesignChoices, StoredDesign } from './design-settings';
|
|
@@ -65,6 +65,19 @@ export function planDesignApply(
|
|
|
65
65
|
return planFlavorSeed(designTokenRows(next), existing, flavorSourceStamp(pack.id), designBaseline(previous));
|
|
66
66
|
}
|
|
67
67
|
|
|
68
|
+
/**
|
|
69
|
+
* "Revert to previous look" over the org's rows — the ONE plan the Revert executes and its hint counts
|
|
70
|
+
* (`kept`: the author's edits since the apply, left as they are). The apply's record of what it wrote
|
|
71
|
+
* decides what an edit since is; a snapshot stored before that record existed (DS8.1) is judged against
|
|
72
|
+
* the rows the applied design writes — the look's own author rows (a chosen font) still go back.
|
|
73
|
+
* `null` when the design holds no snapshot.
|
|
74
|
+
*/
|
|
75
|
+
export function planDesignRevert(current: ReadonlyArray<StoredDesignTokenRow>, design: StoredDesign): FlavorRevertPlan | null {
|
|
76
|
+
const snapshot = design?.revert;
|
|
77
|
+
if (!snapshot) return null;
|
|
78
|
+
return planFlavorRevert(current, snapshot.tokens, snapshot.wrote ?? designTokenRows(design.choices));
|
|
79
|
+
}
|
|
80
|
+
|
|
68
81
|
/** The rows a design is missing by name — the builder-load top-up (never touches an existing row). */
|
|
69
82
|
export function missingDesignRows(existing: ReadonlyArray<{ name: string }>, choices: DesignChoices): DesignTokenRow[] {
|
|
70
83
|
const have = new Set((existing ?? []).map(r => r.name));
|
|
@@ -163,6 +163,23 @@ function ownedEntries(style: ElementStyle): Array<OwnedTrait & { value: string }
|
|
|
163
163
|
return out;
|
|
164
164
|
}
|
|
165
165
|
|
|
166
|
+
/** Every trait the style owns with the value it writes — base first, then hover/active/focus. */
|
|
167
|
+
export function ownedTraitsOf(style: ElementStyle): Array<OwnedTrait & { value: string }> {
|
|
168
|
+
return ownedEntries(style);
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
/** The node's value of one owned trait (a base trait, or a trait inside a pseudo-state map). */
|
|
172
|
+
export function readOwnedTrait(target: ElementStyleTarget, owned: OwnedTrait): unknown {
|
|
173
|
+
return owned.state ? readState(target, owned.state, owned.trait) : readBase(target, owned.trait);
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
/** Write one owned trait. False when a builder node has no slot for a base trait (dropped). */
|
|
177
|
+
export function writeOwnedTrait(target: ElementStyleTarget, owned: OwnedTrait, value: string): boolean {
|
|
178
|
+
if (!owned.state) return writeBase(target, owned.trait, value);
|
|
179
|
+
writeState(target, owned.state, owned.trait, value);
|
|
180
|
+
return true;
|
|
181
|
+
}
|
|
182
|
+
|
|
166
183
|
const sameOwned = (a: OwnedTrait, b: OwnedTrait) => a.trait === b.trait && a.state === b.state;
|
|
167
184
|
|
|
168
185
|
function writeEntries(target: ElementStyleTarget, style: ElementStyle, skip: OwnedTrait[] = []): string[] {
|
|
@@ -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;
|