@vectoriox/iox-ui 5.23.0 → 5.25.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/index.d.ts CHANGED
@@ -1668,8 +1668,40 @@ interface PageStyleOptions {
1668
1668
  */
1669
1669
  designSystem?: boolean;
1670
1670
  }
1671
- /** The token fallbacks of a design-system page (spec tokens only). */
1671
+ /** The three page-style fields a design-system page can inherit from the look. */
1672
+ declare enum PageStyleField {
1673
+ Background = "background",
1674
+ TextColor = "textColor",
1675
+ FontFamily = "fontFamily"
1676
+ }
1677
+ /** Where a page-style field's value comes from. */
1678
+ declare enum PageStyleSource {
1679
+ /** The page sets it itself. */
1680
+ Own = "own",
1681
+ /** A design-system page that sets nothing wears the look's token. */
1682
+ Look = "look",
1683
+ /** A page without the flag that sets nothing: the consumer's own default shows through. */
1684
+ Unset = "unset"
1685
+ }
1686
+ /** The token a design-system page falls back to, per field (spec tokens only). */
1687
+ declare const PAGE_STYLE_TOKENS: Readonly<Record<PageStyleField, string>>;
1688
+ /** The token fallbacks of a design-system page, as CSS declarations. */
1672
1689
  declare const PAGE_TOKEN_FALLBACKS: Readonly<Record<string, string>>;
1690
+ /** One field as a panel shows it: where its value comes from and, when it is the look's, which token. */
1691
+ interface PageStyleFieldState {
1692
+ field: PageStyleField;
1693
+ source: PageStyleSource;
1694
+ /** The token the field falls back to — set only when `source` is `Look`. */
1695
+ token: string | null;
1696
+ }
1697
+ /**
1698
+ * Where each page-style field comes from — decided by the SAME compile the renderers run, so the
1699
+ * Page panel can never say "from the look" for a field the canvas paints from the page, or the other
1700
+ * way round. A flagged page's empty field is the look's token; an unflagged page's is `Unset`.
1701
+ */
1702
+ declare function describePageStyle(style: Partial<PageStyleSettings> | null | undefined, options?: PageStyleOptions): Record<PageStyleField, PageStyleFieldState>;
1703
+ /** The style with one field handed back to the look: cleared to '', every other key kept. */
1704
+ declare function resetPageStyleField<T extends Partial<PageStyleSettings>>(style: T | null | undefined, field: PageStyleField): T;
1673
1705
  /**
1674
1706
  * Compile page style settings into CSS declarations.
1675
1707
  *
@@ -2808,5 +2840,5 @@ declare class GalleryLoop {
2808
2840
  private emit;
2809
2841
  }
2810
2842
 
2811
- export { AOSService, BACKDROP_TWIN_KEY, BACKDROP_WHOLE_KEY, BREAKPOINTS, Breakpoint, BreakpointQueryKind, BuilderButtonComponent, BuilderDividerComponent, BuilderHeadingComponent, BuilderIconComponent, BuilderImageComponent, BuilderLinkComponent, BuilderSpacerComponent, ButtonBlockComponent, CONTAINER_HTML_TAGS, CONTEXT_ACTION_TYPES, CardComponent, ClientGalleryComponent, ClientListComponent, ClientListItemComponent, ClientRepeaterComponent, ClientSliderContainerComponent, ClientSliderSlideComponent, ComponentInstanceRegistryService, ContainerComponent, ContextStore, DEFAULT_GALLERY_SCHEDULER, DEFAULT_PAGE_TRANSITION, DEFAULT_PAGE_TRANSITION_DURATION, DEFAULT_PAGE_TRANSITION_EASING, DEFAULT_PAGE_TRANSITION_PERSPECTIVE, DEFAULT_PHOSPHOR_WEIGHT, DEFAULT_WIDTH_BY_TYPE, DeclarationContext, FormRenderState, FormRuntimeService, GALLERY_EFFECTS, GALLERY_EFFECT_DESCRIPTORS, GalleryLoop, HOVER_DEFAULT_TRANSITION, HOVER_STATES, IOX_BUILDER_EVENTS, IOX_FORM_MESSAGES, IOX_FORM_NAVIGATOR, IOX_FORM_TRANSPORT, IS_PREVIEW, IoxAnimateContainer, IoxAosDirective, IoxAosModule, IoxBuilderComponentsModule, IoxComponentRegistryService, IoxDialogComponent, IoxFieldComponent, IoxFormComponent, IoxFormErrorComponent, IoxFormSuccessComponent, IoxPageComponent, IoxPageModule, IoxSubmitButtonComponent, IoxUiModule, LEGACY_HOVER_TRANSITION, LinkedContainerComponent, PAGE_TOKEN_FALLBACKS, PAGE_TRANSITION_CATEGORIES, PAGE_TRANSITION_PRESETS, PART_CLASS_NAMES, PIPE_DESCRIPTORS, PIPE_REGISTRY, PageScrollProvider, RESERVED_ALIAS_PREFIX, STATE_PSEUDO_CLASS, SUPPRESSED_STYLE_PROPS_BY_TYPE, SWIPER_RESPONSIVE_TRAITS, SectionComponent, TRANSITION_FEELS, TextBlockComponent, VALIDATION_STATES, VALIDATION_STATE_CLASS, VIRTUAL_TRAIT_GROUPS, VIRTUAL_TRAIT_KEYS, ViewPositionDirective, ViewPositionModule, activeActions, applyBindingPipes, applyFeel, breakpointDef, breakpointForWidth, breakpointQueryFor, buildDataSourceFilter, buildPartSelector, buildRelatedFilter, buildSwiperBreakpoints, buildTargetedStateSelector, buildTransitionTimeline, cascadeChain, clearInputOverride, clearOverride, collectContextSlotKeys, collectReferencedAliases, compileDeclarations, compilePageStyle, composeVirtualTraits, computeRelative, containerHtmlTag, contextActionEffect, contextSlotKeys, countOverrides, dataSourcePlanToRequest, declaredInputNames, decomposeBackdropFilter, defaultHoverTransition, defaultMessageResolver, deriveSeoFromContent, describeSeoFields, effectiveTransitionId, excludeSelf, feelFor, getByPath, getSeoValue, hasHoverState, hasOwnTransition, initialSlotValues, isActionEnabled, isContextAction, isInputOverriddenAt, isOverriddenAt, isReservedAlias, isValidSlotKey, isWholeBackdropSet, legacyEnterToTransition, matchRouteParams, mediaQueryFor, mergeSwiperBreakpoints, nearestCommonAncestorId, nodeCssId, nodeUsesContext, normalizeCollectionResult, normalizeGalleryImage, normalizeIconClass, parentBreakpoint, parseUrlList, partClassName, planDataSource, planResponsiveRules, referencedDataSources, referencedDataSourcesDeep, renderDefaultDeclarations, resetSeoField, resolveBoundValue, resolveContextValue, resolveDerivedInto, resolveGalleryEffect, resolveGalleryUrls, resolveInputChain, resolveInputs, resolvePageTransition, resolvePath, resolveRelativeInto, resolveRepeaterItems, resolveSeo, resolveSiblings, resolveSingleItemId, resolveStateStyles, resolveStyleProps, resolveValueChain, rewriteViewportUnits, safeSetInput, setInputOverride, setOverride, setSeoAuto, setSeoBinding, setSeoOverride, setSeoValue, stateNamesIn, statePseudoClass, stripSuppressedProps, variableAliases, variableItemsFrom, withContextSlots, withDefaultHoverTransition, wrapInBreakpointQuery, wrapInMediaQuery };
2812
- export type { BoundValueSpec, BreakpointDef, BuildRelatedFilterOptions, ContextActionEffect, ContextActionLike, ContextItemRef, ContextSlotDefault, ContextValueSpec, ContextWriteScope, DataSourceDomain, DataSourceEndpoints, DataSourceMode, DataSourcePlan, DataSourceQueryFilter, DataSourceRequest, DataSourceRequestSpec, DataSourceRouteFilter, DataSourceSpec, FormMessageResolver, GalleryBoundSource, GalleryDurations, GalleryEffect, GalleryEffectDescriptor, GalleryImage, GalleryLoopOptions, GalleryLoopScheduler, GalleryLoopState, GallerySlideState, IBuilderEventEmitter, InputMap, IoxContextSlot, IoxPipe, OverrideBreakpoint, PageStyleOptions, PageStyleSettings, PageTransitionCategory, PageTransitionCategoryMeta, PageTransitionOptions, PageTransitionPreset, PipeArgSpec, PipeContext, PipeDescriptor, RelativeInputs, RelativeSourceKind, RelativeSourceSpec, ResolveSiblingsOptions, ResolvedPageTransition, ResponsiveLayer, ResponsiveOverrides, ResponsiveRuleSet, RouteContext, SeoDerivedContent, SeoFieldSource, SeoFieldState, SeoResolveContext, SetContextParams, SiblingsResult, StateStyleMap, StyleMap, StyleTreeNode, SwiperBreakpointMap, ToggleableAction, TransitionFeel, TransitionPhase, TransitionTimelineInput, ValueSource, VirtualTraitGroup };
2843
+ export { AOSService, BACKDROP_TWIN_KEY, BACKDROP_WHOLE_KEY, BREAKPOINTS, Breakpoint, BreakpointQueryKind, BuilderButtonComponent, BuilderDividerComponent, BuilderHeadingComponent, BuilderIconComponent, BuilderImageComponent, BuilderLinkComponent, BuilderSpacerComponent, ButtonBlockComponent, CONTAINER_HTML_TAGS, CONTEXT_ACTION_TYPES, CardComponent, ClientGalleryComponent, ClientListComponent, ClientListItemComponent, ClientRepeaterComponent, ClientSliderContainerComponent, ClientSliderSlideComponent, ComponentInstanceRegistryService, ContainerComponent, ContextStore, DEFAULT_GALLERY_SCHEDULER, DEFAULT_PAGE_TRANSITION, DEFAULT_PAGE_TRANSITION_DURATION, DEFAULT_PAGE_TRANSITION_EASING, DEFAULT_PAGE_TRANSITION_PERSPECTIVE, DEFAULT_PHOSPHOR_WEIGHT, DEFAULT_WIDTH_BY_TYPE, DeclarationContext, FormRenderState, FormRuntimeService, GALLERY_EFFECTS, GALLERY_EFFECT_DESCRIPTORS, GalleryLoop, HOVER_DEFAULT_TRANSITION, HOVER_STATES, IOX_BUILDER_EVENTS, IOX_FORM_MESSAGES, IOX_FORM_NAVIGATOR, IOX_FORM_TRANSPORT, IS_PREVIEW, IoxAnimateContainer, IoxAosDirective, IoxAosModule, IoxBuilderComponentsModule, IoxComponentRegistryService, IoxDialogComponent, IoxFieldComponent, IoxFormComponent, IoxFormErrorComponent, IoxFormSuccessComponent, IoxPageComponent, IoxPageModule, IoxSubmitButtonComponent, IoxUiModule, LEGACY_HOVER_TRANSITION, LinkedContainerComponent, PAGE_STYLE_TOKENS, PAGE_TOKEN_FALLBACKS, PAGE_TRANSITION_CATEGORIES, PAGE_TRANSITION_PRESETS, PART_CLASS_NAMES, PIPE_DESCRIPTORS, PIPE_REGISTRY, PageScrollProvider, PageStyleField, PageStyleSource, RESERVED_ALIAS_PREFIX, STATE_PSEUDO_CLASS, SUPPRESSED_STYLE_PROPS_BY_TYPE, SWIPER_RESPONSIVE_TRAITS, SectionComponent, TRANSITION_FEELS, TextBlockComponent, VALIDATION_STATES, VALIDATION_STATE_CLASS, VIRTUAL_TRAIT_GROUPS, VIRTUAL_TRAIT_KEYS, ViewPositionDirective, ViewPositionModule, activeActions, applyBindingPipes, applyFeel, breakpointDef, breakpointForWidth, breakpointQueryFor, buildDataSourceFilter, buildPartSelector, buildRelatedFilter, buildSwiperBreakpoints, buildTargetedStateSelector, buildTransitionTimeline, cascadeChain, clearInputOverride, clearOverride, collectContextSlotKeys, collectReferencedAliases, compileDeclarations, compilePageStyle, composeVirtualTraits, computeRelative, containerHtmlTag, contextActionEffect, contextSlotKeys, countOverrides, dataSourcePlanToRequest, declaredInputNames, decomposeBackdropFilter, defaultHoverTransition, defaultMessageResolver, deriveSeoFromContent, describePageStyle, describeSeoFields, effectiveTransitionId, excludeSelf, feelFor, getByPath, getSeoValue, hasHoverState, hasOwnTransition, initialSlotValues, isActionEnabled, isContextAction, isInputOverriddenAt, isOverriddenAt, isReservedAlias, isValidSlotKey, isWholeBackdropSet, legacyEnterToTransition, matchRouteParams, mediaQueryFor, mergeSwiperBreakpoints, nearestCommonAncestorId, nodeCssId, nodeUsesContext, normalizeCollectionResult, normalizeGalleryImage, normalizeIconClass, parentBreakpoint, parseUrlList, partClassName, planDataSource, planResponsiveRules, referencedDataSources, referencedDataSourcesDeep, renderDefaultDeclarations, resetPageStyleField, resetSeoField, resolveBoundValue, resolveContextValue, resolveDerivedInto, resolveGalleryEffect, resolveGalleryUrls, resolveInputChain, resolveInputs, resolvePageTransition, resolvePath, resolveRelativeInto, resolveRepeaterItems, resolveSeo, resolveSiblings, resolveSingleItemId, resolveStateStyles, resolveStyleProps, resolveValueChain, rewriteViewportUnits, safeSetInput, setInputOverride, setOverride, setSeoAuto, setSeoBinding, setSeoOverride, setSeoValue, stateNamesIn, statePseudoClass, stripSuppressedProps, variableAliases, variableItemsFrom, withContextSlots, withDefaultHoverTransition, wrapInBreakpointQuery, wrapInMediaQuery };
2844
+ export type { BoundValueSpec, BreakpointDef, BuildRelatedFilterOptions, ContextActionEffect, ContextActionLike, ContextItemRef, ContextSlotDefault, ContextValueSpec, ContextWriteScope, DataSourceDomain, DataSourceEndpoints, DataSourceMode, DataSourcePlan, DataSourceQueryFilter, DataSourceRequest, DataSourceRequestSpec, DataSourceRouteFilter, DataSourceSpec, FormMessageResolver, GalleryBoundSource, GalleryDurations, GalleryEffect, GalleryEffectDescriptor, GalleryImage, GalleryLoopOptions, GalleryLoopScheduler, GalleryLoopState, GallerySlideState, IBuilderEventEmitter, InputMap, IoxContextSlot, IoxPipe, OverrideBreakpoint, PageStyleFieldState, PageStyleOptions, PageStyleSettings, PageTransitionCategory, PageTransitionCategoryMeta, PageTransitionOptions, PageTransitionPreset, PipeArgSpec, PipeContext, PipeDescriptor, RelativeInputs, RelativeSourceKind, RelativeSourceSpec, ResolveSiblingsOptions, ResolvedPageTransition, ResponsiveLayer, ResponsiveOverrides, ResponsiveRuleSet, RouteContext, SeoDerivedContent, SeoFieldSource, SeoFieldState, SeoResolveContext, SetContextParams, SiblingsResult, StateStyleMap, StyleMap, StyleTreeNode, SwiperBreakpointMap, ToggleableAction, TransitionFeel, TransitionPhase, TransitionTimelineInput, ValueSource, VirtualTraitGroup };
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@vectoriox/iox-ui",
3
- "version": "5.23.0",
3
+ "version": "5.25.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 { FlavorRevertPlan, FlavorSeedPlan, planFlavorRevert, 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,64 @@ 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 });
66
100
  }
67
101
 
68
102
  /**
@@ -85,23 +119,34 @@ export function missingDesignRows(existing: ReadonlyArray<{ name: string }>, cho
85
119
  }
86
120
 
87
121
  /**
88
- * The builder-load REPAIR (DS4.1): rows the design owns and nobody touched — stamped by the current
89
- * pack, light value exactly the pack's — whose dark override differs from the pack's are rewritten.
90
- * iox-cms dropped `values.dark` on every write from F0 to DS4 (`values` was an implicit relation),
91
- * and a revert could leave the previous apply's dark behind; both leave a row whose light half is the
92
- * design's and whose dark half is not. No author edit can produce that shape — the Tokens panel edits
93
- * 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.
94
134
  */
95
135
  export function designTokenRepairs(existing: ReadonlyArray<StoredDesignTokenRow>, choices: DesignChoices): FlavorSeedPlan['update'] {
96
136
  const pack = flavorPackById(choices.flavorId);
97
137
  if (!pack) return [];
98
138
  const stamp = flavorSourceStamp(pack.id);
99
139
  const desired = new Map(designTokenRows(choices).map(r => [r.name, r]));
140
+ const packDefaults = packDefaultsLookup();
100
141
  const out: FlavorSeedPlan['update'] = [];
101
142
  for (const row of existing ?? []) {
102
143
  const want = desired.get(row.name);
103
- if (!want || !row._id || row.source !== stamp || want.source !== stamp || row.value !== want.value) continue;
104
- 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;
105
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 } : {}) } });
106
151
  }
107
152
  return out;
@@ -125,19 +125,44 @@ export interface SeedExpectation {
125
125
  }
126
126
 
127
127
  /**
128
- * What the PREVIOUS seed wrote, per row: a map by name, or a function of the stored row (a baseline
129
- * 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).
130
132
  */
131
133
  export type SeedBaseline =
132
134
  | ReadonlyMap<string, SeedExpectation>
133
- | ((row: StoredDesignTokenRow) => SeedExpectation | undefined);
135
+ | ((row: StoredDesignTokenRow) => SeedExpectation | ReadonlyArray<SeedExpectation> | undefined);
134
136
 
135
- const expectationFor = (baseline: SeedBaseline | null | undefined, row: StoredDesignTokenRow): SeedExpectation | undefined =>
136
- !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
+ }
137
150
 
138
151
  const sameValue = (row: { value: string; values?: { dark?: string } }, e: SeedExpectation) =>
139
152
  row.value === e.value && (row.values?.dark ?? '') === (e.dark ?? '');
140
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
+
141
166
  /**
142
167
  * The seeding MERGE: desired tokens vs existing rows, deciding create / update / keep. The §10 C2
143
168
  * rule — "an upgrade only rewrites untouched rows" — is a comparison against what the PREVIOUS
@@ -148,15 +173,18 @@ const sameValue = (row: { value: string; values?: { dark?: string } }, e: SeedEx
148
173
  * choice, so a new choice may replace it. `group`/`tier` ride every write; a desired row's own
149
174
  * `source` (a chosen font's `user`) wins over the pack stamp. A machine-owned row that already holds
150
175
  * the desired value but another pack's stamp is RE-STAMPED (§10 C2 provenance: the stamp names the
151
- * 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.
152
178
  */
153
179
  export function planFlavorSeed(
154
180
  desired: ReadonlyArray<FlavorTokenDef & { source?: string }>,
155
181
  existing: ReadonlyArray<StoredDesignTokenRow>,
156
182
  stamp: string,
157
183
  baseline: SeedBaseline | null,
184
+ options: FlavorSeedOptions = {},
158
185
  ): FlavorSeedPlan {
159
186
  const byName = new Map((existing ?? []).map(r => [r.name, r]));
187
+ const reclaim = new Set(options.reclaim ?? []);
160
188
  const plan: FlavorSeedPlan = { create: [], update: [], kept: [] };
161
189
  for (const def of desired ?? []) {
162
190
  if (!def?.name) continue;
@@ -173,13 +201,14 @@ export function planFlavorSeed(
173
201
  });
174
202
  continue;
175
203
  }
176
- const old = expectationFor(baseline, row);
177
- const designOwned = isSeeded(row) || (row.source === USER_TOKEN_SOURCE && !!old && sameValue(row, old));
178
- 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; }
179
208
  const sameAsDesired = row.value === def.value && (row.values?.dark ?? '') === (def.values?.dark ?? '');
180
209
  if (sameAsDesired && (row.source ?? '') === source) continue; // already exactly this
181
- const untouched = !!old && sameValue(row, old);
182
- if ((untouched || sameAsDesired) && row._id) {
210
+ const untouched = untouchedSeedRow(row, olds);
211
+ if ((untouched || sameAsDesired || reclaimed) && row._id) {
183
212
  plan.update.push({ id: row._id, data: { value: def.value, values: def.values ?? {}, source, ...classification(def) } });
184
213
  } else {
185
214
  plan.kept.push(def.name); // edited since seeding (or unverifiable) — the author's hand wins