@vectoriox/iox-ui 5.24.0 → 5.26.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.
@@ -0,0 +1,214 @@
1
+ import { toHexColor, wcagContrastRatio, WCAG_AA_TEXT } from './brand';
2
+ import { WCAG_AA_UI } from './brand-neutrals';
3
+ import { compositeOver, paintColours, parseCssColour, quantizeRgb, Rgba, ringColour } from './colour-eval';
4
+ import { ElementStyle, ELEMENT_STYLES } from './element-styles';
5
+ import { SPEC_CONTRAST_AUDIT, SPEC_INK_SCOPES, SPEC_SCHEMES } from './flavor-spec.generated';
6
+ import { DesignTokenLike, ResolvedMode, resolveSchemeTokenValues } from './token-css';
7
+
8
+ /**
9
+ * The contrast AUDIT (design-system DS9). The contract's token pairs were all AA and Doron still found a
10
+ * button whose icon was "almost hard to see": the failures live in the PAIRINGS element styles make — an
11
+ * Icon dropped into a Primary button painted `icon-color` (accent-text) on the accent fill, 1:1 in a Brand
12
+ * section. So this resolves what each element style actually paints, the way CSS resolves it:
13
+ *
14
+ * - in every section scheme (`resolveSchemeTokenValues`), directly on the section and inside every
15
+ * container style (Card, Panel, Navigation bar, Dialog);
16
+ * - `inherit` → the container's ink, a transparent fill → what is under it, translucent fills composited,
17
+ * a gradient ground (Glass's mesh, Midnight's glow) checked at its worst stop;
18
+ * - base and hover; a button's icon read through the contract's `inkScopes` (it takes the button's ink),
19
+ * its focus ring; the form parts the field baseline paints (`contrastAudit.formParts`).
20
+ *
21
+ * WCAG 2: 4.5:1 for text, 3:1 for glyphs, UI boundaries and focus indicators (1.4.11), judged on 8-bit
22
+ * colours. The same audit gates the contract (`colour.mjs auditContrast`, every flavor × tint × a seed
23
+ * sweep) and `contrast-audit.spec.ts` sweeps it here, so no pack or brand can regress.
24
+ */
25
+
26
+ export interface ContrastFinding {
27
+ scheme: string;
28
+ /** `section`, or the container style the element sits in. */
29
+ container: string;
30
+ element: string;
31
+ part: string;
32
+ fg: string;
33
+ bg: string;
34
+ ratio: number;
35
+ min: number;
36
+ pass: boolean;
37
+ }
38
+
39
+ interface FormPart {
40
+ readonly part: string;
41
+ readonly fg: string | ReadonlyArray<string>;
42
+ readonly on?: string;
43
+ readonly any?: boolean;
44
+ readonly thumb?: string;
45
+ readonly min: number;
46
+ }
47
+ interface InkScope { readonly styles: ReadonlyArray<string>; readonly set: Readonly<Record<string, string>>; }
48
+
49
+ const FORM_PARTS: ReadonlyArray<FormPart> = SPEC_CONTRAST_AUDIT.formParts;
50
+ const INK_SCOPES: ReadonlyArray<InkScope> = SPEC_INK_SCOPES;
51
+ const SCHEME_IDS: ReadonlyArray<string> = Object.keys(SPEC_SCHEMES);
52
+ const CONTAINER_GROUPS: ReadonlySet<string> = new Set(['Layout', 'Overlays']);
53
+ const NOT_CONTENT: ReadonlySet<string> = new Set(['rule', 'media']);
54
+ const SECTION_STYLE = 'section';
55
+ const ICON_STYLE = 'icon';
56
+ const BUTTON_GROUP = 'Buttons';
57
+ const GLYPH_GROUP = 'Media';
58
+ const FORMS_GROUP = 'Forms';
59
+ const DEFAULT_SCHEME = 'default';
60
+ const SECTION = 'section';
61
+ const FIELD = 'field';
62
+ const WHITE: Rgba = { r: 1, g: 1, b: 1, a: 1 };
63
+ const SINGLE_REF = /^var\(--iox-([\w-]+)\)$/;
64
+ const TOKEN_REF = /var\(\s*--iox-([\w-]+)\s*(?:,([^()]*))?\)/g;
65
+
66
+ type Values = Readonly<Record<string, string>>;
67
+ type StyleMap = Readonly<Record<string, string>>;
68
+
69
+ /** A style value with its var() references resolved against `values` (already-resolved tokens). */
70
+ function resolveIn(values: Values, v: string | undefined): string {
71
+ const single = SINGLE_REF.exec(String(v ?? '').trim());
72
+ if (single) return values[single[1]] ?? (v as string);
73
+ return String(v ?? '').replace(TOKEN_REF, (ref, name: string, fallback?: string) =>
74
+ values[name] ?? (fallback !== undefined ? fallback.trim() : ref));
75
+ }
76
+
77
+ const overAll = (tops: Rgba[], unders: Rgba[]) => unders.flatMap(u => tops.map(t => compositeOver(t, u)));
78
+ const opaque = (paints: Rgba[]) => paints.some(c => c.a > 0);
79
+
80
+ const ownStyle = (e: ElementStyle) => (e.base ?? {}) as StyleMap;
81
+
82
+ function inkScoped(values: Values, styleId: string): Values {
83
+ const scope = INK_SCOPES.find(s => s.styles.includes(styleId));
84
+ return scope ? { ...values, ...scope.set } : values;
85
+ }
86
+
87
+ /** Every pair a reader meets, for one set of token rows in one mode. */
88
+ export function auditDesignContrast(rows: ReadonlyArray<DesignTokenLike>, mode: ResolvedMode): ContrastFinding[] {
89
+ const findings: ContrastFinding[] = [];
90
+ const styles = ELEMENT_STYLES;
91
+ const byId = new Map(styles.map(e => [e.id, e]));
92
+ const containers: Array<ElementStyle | null> = [
93
+ null,
94
+ ...styles.filter(e => CONTAINER_GROUPS.has(e.group) && ownStyle(e)['backgroundColor'] && e.id !== SECTION_STYLE),
95
+ ];
96
+ const content = styles.filter(e => !CONTAINER_GROUPS.has(e.group) && (ownStyle(e)['color'] || ownStyle(e)['backgroundColor'])
97
+ && e.group !== FORMS_GROUP && !NOT_CONTENT.has(e.id));
98
+ const notices = styles.filter(e => e.group === FORMS_GROUP && ownStyle(e)['color']);
99
+ const section = ownStyle(byId.get(SECTION_STYLE) as ElementStyle);
100
+ const iconColour = ownStyle(byId.get(ICON_STYLE) as ElementStyle)['color'];
101
+
102
+ for (const schemeId of SCHEME_IDS) {
103
+ const values = resolveSchemeTokenValues(rows, mode, schemeId);
104
+ const pageInk = parseCssColour(values['text']);
105
+ const page = paintColours(values['page-bg'], pageInk);
106
+ const sectionPaint = paintColours(values['section-bg'], pageInk);
107
+ const ground = opaque(sectionPaint)
108
+ ? sectionPaint.map(c => (c.a < 1 ? compositeOver(c, page[0] ?? WHITE) : c))
109
+ : page;
110
+ const sectionInk = parseCssColour(resolveIn(values, section['color']), pageInk);
111
+
112
+ for (const box of containers) {
113
+ const boxStyle = box ? ownStyle(box) : null;
114
+ const bgs = boxStyle ? overAll(paintColours(resolveIn(values, boxStyle['backgroundColor']), sectionInk), ground) : ground;
115
+ const boxInk = boxStyle?.['color'] && boxStyle['color'] !== 'inherit'
116
+ ? parseCssColour(resolveIn(values, boxStyle['color']), sectionInk) : sectionInk;
117
+ const where = { scheme: schemeId, container: box?.id ?? SECTION };
118
+ const record = (element: string, part: string, fg: string, bg: string, ratio: number, min: number) =>
119
+ findings.push({ ...where, element, part, fg, bg, ratio: Math.round(ratio * 100) / 100, min, pass: ratio >= min - 1e-9 });
120
+ const check = (element: string, part: string, fg: Rgba | null, under: Rgba[], min: number) => {
121
+ if (!fg) return;
122
+ for (const g of under) {
123
+ const bg = quantizeRgb(g);
124
+ const f = quantizeRgb(fg.a < 1 ? compositeOver(fg, bg) : fg);
125
+ record(element, part, toHexColor(f), toHexColor(bg), wcagContrastRatio(f, bg), min);
126
+ }
127
+ };
128
+
129
+ check(box?.id ?? SECTION, 'text', boxInk, bgs, WCAG_AA_TEXT);
130
+ for (const e of content) {
131
+ const states: Array<[string, StyleMap]> = [['base', ownStyle(e)], ...(e.hover ? [['hover', { ...ownStyle(e), ...e.hover }] as [string, StyleMap]] : [])];
132
+ const glyph = e.group === GLYPH_GROUP;
133
+ for (const [state, s] of states) {
134
+ const fill = s['backgroundColor'] ? paintColours(resolveIn(values, s['backgroundColor']), boxInk) : [];
135
+ const under = fill.length && opaque(fill) ? overAll(fill, bgs) : bgs;
136
+ const ink = s['color'] ? parseCssColour(resolveIn(values, s['color']), boxInk) : boxInk;
137
+ check(e.id, state === 'base' ? (glyph ? 'glyph' : 'text') : `${state} text`, ink, under, glyph ? WCAG_AA_UI : WCAG_AA_TEXT);
138
+ if (state === 'base' && e.group === BUTTON_GROUP) {
139
+ check(e.id, 'icon', parseCssColour(resolveIn(inkScoped(values, e.id), iconColour), ink), under, WCAG_AA_UI);
140
+ const focus = (e.focus as StyleMap | undefined)?.['boxShadow'];
141
+ if (focus) check(e.id, 'focus ring', ringColour(resolveIn(values, focus), ink), bgs, WCAG_AA_UI);
142
+ }
143
+ }
144
+ }
145
+ for (const part of FORM_PARTS) {
146
+ const fieldBg = paintColours(resolveIn(values, part.on ?? 'transparent'), boxInk);
147
+ const under = part.on && opaque(fieldBg) ? overAll(fieldBg, bgs) : bgs;
148
+ if (part.any) {
149
+ for (const g of under) {
150
+ const bg = quantizeRgb(g);
151
+ const ratio = Math.max(...(part.fg as ReadonlyArray<string>).map(fg => {
152
+ const c = parseCssColour(resolveIn(values, fg), boxInk);
153
+ return c ? wcagContrastRatio(quantizeRgb(c.a < 1 ? compositeOver(c, bg) : c), bg) : 1;
154
+ }));
155
+ record(FIELD, part.part, '', toHexColor(bg), ratio, part.min);
156
+ }
157
+ continue;
158
+ }
159
+ const paint = parseCssColour(resolveIn(values, part.fg as string), boxInk);
160
+ check(FIELD, part.part, paint, under, part.min);
161
+ if (part.thumb && paint) {
162
+ check(FIELD, `${part.part} thumb`, parseCssColour(resolveIn(values, part.thumb), boxInk), bgs.map(b => compositeOver(paint, b)), part.min);
163
+ }
164
+ }
165
+ if (box === null && schemeId === DEFAULT_SCHEME) {
166
+ for (const n of notices) {
167
+ const fill = paintColours(resolveIn(values, ownStyle(n)['backgroundColor']), boxInk);
168
+ check(n.id, 'text', parseCssColour(resolveIn(values, ownStyle(n)['color']), boxInk), overAll(fill, bgs), WCAG_AA_TEXT);
169
+ }
170
+ }
171
+ }
172
+ }
173
+ return findings;
174
+ }
175
+
176
+ /** The pairs the Design ▸ Colour tab lists for the CURRENT brand (Board 6 + DS9): what a filled button
177
+ * shows, and the link on every section scheme — each the worst case on the section itself. */
178
+ export enum ColourCheckId {
179
+ ButtonLabel = 'button-label',
180
+ ButtonIcon = 'button-icon',
181
+ Link = 'link',
182
+ }
183
+
184
+ export interface ColourCheck {
185
+ id: ColourCheckId;
186
+ /** The section scheme the pair sits in (`default` for the button). */
187
+ scheme: string;
188
+ fg: string;
189
+ bg: string;
190
+ ratio: number;
191
+ min: number;
192
+ passes: boolean;
193
+ }
194
+
195
+ const COLOUR_TAB_CHECKS: ReadonlyArray<{ id: ColourCheckId; element: string; part: string; schemes: ReadonlyArray<string> }> = [
196
+ { id: ColourCheckId.ButtonLabel, element: 'button-primary', part: 'text', schemes: [DEFAULT_SCHEME] },
197
+ { id: ColourCheckId.ButtonIcon, element: 'button-primary', part: 'icon', schemes: [DEFAULT_SCHEME] },
198
+ { id: ColourCheckId.Link, element: 'link', part: 'text', schemes: SCHEME_IDS },
199
+ ];
200
+
201
+ /** The Colour tab's brand rows, from one audit of the rows the canvas wears. */
202
+ export function colourTabChecks(rows: ReadonlyArray<DesignTokenLike>, mode: ResolvedMode): ColourCheck[] {
203
+ const findings = auditDesignContrast(rows, mode).filter(f => f.container === SECTION);
204
+ const out: ColourCheck[] = [];
205
+ for (const check of COLOUR_TAB_CHECKS) {
206
+ for (const scheme of check.schemes) {
207
+ const hits = findings.filter(f => f.scheme === scheme && f.element === check.element && f.part === check.part);
208
+ if (!hits.length) continue;
209
+ const worst = hits.reduce((a, b) => (b.ratio < a.ratio ? b : a));
210
+ out.push({ id: check.id, scheme, fg: worst.fg, bg: worst.bg, ratio: worst.ratio, min: worst.min, passes: worst.pass });
211
+ }
212
+ }
213
+ return out;
214
+ }
@@ -1,7 +1,7 @@
1
1
  import { deriveBrandTokens } from './brand';
2
2
  import { SPEC_LEGACY_ALIASES } from './flavor-spec.generated';
3
3
  import { SWISS_FLAVOR } from './flavor';
4
- import { buildFlavorTokens } from './flavor-tokens';
4
+ import { buildFlavorTokens, legacyFlavorTokens } from './flavor-tokens';
5
5
  import {
6
6
  FlavorRevertPlan, FlavorSeedOptions, FlavorSeedPlan, planFlavorRevert, planFlavorSeed, SeedBaseline, SeedExpectation, untouchedSeedRow,
7
7
  } from './flavor-seed';
@@ -28,6 +28,13 @@ export function designTokenRows(choices: DesignChoices): DesignTokenRow[] {
28
28
  return pack ? buildFlavorTokens(pack, { ...choices, flavorId: pack.id }) : [];
29
29
  }
30
30
 
31
+ /** What a design wrote BEFORE DS9 (the look's own palette, the rows DS9 changed at their old values, the
32
+ * accent derived against `bg` alone) — empty for an unknown pack. */
33
+ export function legacyDesignTokenRows(choices: DesignChoices): DesignTokenRow[] {
34
+ const pack = flavorPackById(choices.flavorId);
35
+ return pack ? legacyFlavorTokens(pack, { ...choices, flavorId: pack.id }) : [];
36
+ }
37
+
31
38
  /** What the F1/F2 Swiss seeding wrote: the four Swiss rows + the brand set derived from its seed. */
32
39
  function swissExpectations(seed: string | null): Map<string, SeedExpectation> {
33
40
  const rows: Array<Pick<DesignTokenRow, 'name' | 'value' | 'values'>> =
@@ -39,18 +46,22 @@ function swissExpectations(seed: string | null): Map<string, SeedExpectation> {
39
46
  return expectations(rows);
40
47
  }
41
48
 
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>();
49
+ /** What a v2 pack writes at its OWN defaults, by pack id — today and before DS9 (a studio seeded then holds
50
+ * the old defaults) — computed once per id per caller. */
51
+ function packDefaultsLookup(): (packId: string) => ReadonlyArray<Map<string, SeedExpectation>> {
52
+ const cache = new Map<string, ReadonlyArray<Map<string, SeedExpectation>>>();
45
53
  return packId => {
46
54
  if (!cache.has(packId)) {
47
55
  const pack = isRetiredFlavorId(packId) ? undefined : flavorPackById(packId);
48
- cache.set(packId, pack && pack.id === packId ? expectations(designTokenRows(packDesignChoices(pack.id))) : undefined);
56
+ const choices = pack && pack.id === packId ? packDesignChoices(pack.id) : null;
57
+ cache.set(packId, choices ? [expectations(designTokenRows(choices)), expectations(legacyDesignTokenRows(choices))] : []);
49
58
  }
50
- return cache.get(packId);
59
+ return cache.get(packId) as ReadonlyArray<Map<string, SeedExpectation>>;
51
60
  };
52
61
  }
53
62
 
63
+ const present = (list: ReadonlyArray<SeedExpectation | undefined>): SeedExpectation[] => list.filter((e): e is SeedExpectation => !!e);
64
+
54
65
  /**
55
66
  * What the PREVIOUS design wrote, as a baseline keyed by the row's own stamp: a row Swiss stamped is
56
67
  * judged against what Swiss wrote, every other row against `buildFlavorTokens` of the stored choices
@@ -66,14 +77,16 @@ function packDefaultsLookup(): (packId: string) => Map<string, SeedExpectation>
66
77
  */
67
78
  export function designBaseline(previous: StoredDesign): SeedBaseline {
68
79
  const retired = isRetiredFlavorId(previous.storedFlavorId);
69
- const current = expectations(designTokenRows(retired ? packDesignChoices(previous.choices.flavorId) : previous.choices));
80
+ const wrote = retired ? packDesignChoices(previous.choices.flavorId) : previous.choices;
81
+ const current = expectations(designTokenRows(wrote));
82
+ const beforeTint = expectations(legacyDesignTokenRows(wrote));
70
83
  const swiss = swissExpectations(retired ? previous.storedSeed : null);
71
84
  const packDefaults = packDefaultsLookup();
72
85
  return (row: StoredDesignTokenRow) => {
73
86
  const stampedBy = SEED_STAMP.exec(row.source ?? '')?.[1];
74
87
  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);
88
+ const ownDefaults = stampedBy ? packDefaults(stampedBy).map(m => m.get(row.name)) : [];
89
+ return present([current.get(row.name), beforeTint.get(row.name), ...ownDefaults]);
77
90
  };
78
91
  }
79
92
 
@@ -121,15 +134,18 @@ export function missingDesignRows(existing: ReadonlyArray<{ name: string }>, cho
121
134
  /**
122
135
  * The builder-load REPAIR (DS4.1): rows the design writes that are the machine's and nobody touched,
123
136
  * 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:
137
+ * with no author action. Three shapes:
125
138
  * - stamped by the current pack, light value exactly the pack's, dark half not: iox-cms dropped
126
139
  * `values.dark` on every write from F0 to DS4 (`values` was an implicit relation), and a revert could
127
140
  * leave the previous apply's dark behind;
128
141
  * - stamped by a v2 pack and still holding what THAT pack writes at its own defaults (its dark half
129
142
  * possibly lost the same way) while the stored design says otherwise: an apply that judged the row
130
143
  * 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
144
+ * whose brand colour never reached its accent;
145
+ * - stamped by the current pack and still holding what the design wrote BEFORE DS9
146
+ * (`legacyDesignTokenRows`): the contrast fixes — a 3:1 `line-strong`, a 3:1 focus ring, an
147
+ * `accent-text` that reads on every ground — reach a studio that applied its look before them.
148
+ * No author edit produces any of these shapes — the Tokens panel edited the light value only, and since DS8 an
133
149
  * edit makes the row `source: user`, which this never touches.
134
150
  */
135
151
  export function designTokenRepairs(existing: ReadonlyArray<StoredDesignTokenRow>, choices: DesignChoices): FlavorSeedPlan['update'] {
@@ -137,6 +153,7 @@ export function designTokenRepairs(existing: ReadonlyArray<StoredDesignTokenRow>
137
153
  if (!pack) return [];
138
154
  const stamp = flavorSourceStamp(pack.id);
139
155
  const desired = new Map(designTokenRows(choices).map(r => [r.name, r]));
156
+ const beforeTint = expectations(legacyDesignTokenRows(choices));
140
157
  const packDefaults = packDefaultsLookup();
141
158
  const out: FlavorSeedPlan['update'] = [];
142
159
  for (const row of existing ?? []) {
@@ -145,8 +162,11 @@ export function designTokenRepairs(existing: ReadonlyArray<StoredDesignTokenRow>
145
162
  if (row.value === want.value && (row.values?.dark ?? '') === (want.values?.dark ?? '') && row.source === stamp) continue;
146
163
  const stampedBy = SEED_STAMP.exec(row.source ?? '')?.[1];
147
164
  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;
165
+ const machineWrote = present([
166
+ ...(stampedBy ? packDefaults(stampedBy).map(m => m.get(row.name)) : []),
167
+ ...(stampedBy === pack.id ? [beforeTint.get(row.name)] : []),
168
+ ]);
169
+ if (!darkOnly && !untouchedSeedRow(row, machineWrote)) continue;
150
170
  out.push({ id: row._id, data: { value: want.value, values: want.values ?? {}, source: stamp, ...(want.group ? { group: want.group } : {}), ...(want.tier ? { tier: want.tier } : {}) } });
151
171
  }
152
172
  return out;
@@ -1,6 +1,6 @@
1
1
  import { SPEC_AXES, SPEC_FONT_ROLES } from './flavor-spec.generated';
2
2
  import { parseHexColor } from './brand';
3
- import { DesignChoices, DesignMode, FlavorPackV2, FontRoleToken, SPEC_FLAVORS } from './design-token.model';
3
+ import { DesignChoices, DesignMode, FlavorPackV2, FontRoleToken, PRE_TINT_DEFAULT, SPEC_FLAVORS, TintId } from './design-token.model';
4
4
  import type { FlavorRevertSnapshot } from './flavor-seed';
5
5
 
6
6
  /**
@@ -30,6 +30,7 @@ export enum DesignConfigKey {
30
30
  Motion = 'motion',
31
31
  PillButtons = 'pillButtons',
32
32
  Mode = 'mode',
33
+ Tint = 'tint',
33
34
  Fonts = 'fonts',
34
35
  Revert = 'revert',
35
36
  }
@@ -121,6 +122,7 @@ export function resolveDesignChoices(partial: Partial<DesignChoices> | null | un
121
122
  motion: keyOf(SPEC_AXES.motions, asString(partial?.motion)) ?? pack.defaults.motion,
122
123
  pillButtons: asBoolean(partial?.pillButtons) ?? pack.defaults.pillButtons,
123
124
  mode: (MODES.includes(asString(partial?.mode) ?? '') ? asString(partial?.mode) : pack.defaults.mode) as DesignMode,
125
+ tint: keyOf(SPEC_AXES.tints, asString(partial?.tint)) ?? pack.defaults.tint,
124
126
  ...(fonts ? { fonts } : {}),
125
127
  };
126
128
  }
@@ -141,9 +143,14 @@ export function choicesForPack(flavorId: string, applied: DesignChoices | null |
141
143
  return resolveDesignChoices({ flavorId: pack.id, fonts: applied?.fonts });
142
144
  }
143
145
 
144
- /** A stored `settings.design` config → the resolved design. No row (or no config) → Crisp's defaults. */
146
+ /**
147
+ * A stored `settings.design` config → the resolved design. No row (or no config) → Crisp's defaults. A
148
+ * row stored before DS9 has no `tint`: it was applied with the look's own palette, so it reads `neutral`
149
+ * (never the pack's new default — the studio's rows hold the palette, and nothing may repaint them unasked).
150
+ */
145
151
  export function readDesignConfig(config: ReadonlyArray<DesignConfigEntry> | null | undefined, hasRow = !!config): StoredDesign {
146
152
  const map = configMap(config);
153
+ const storedTint = asString(map.get(DesignConfigKey.Tint)) ?? (hasRow ? PRE_TINT_DEFAULT : null);
147
154
  const storedFlavorId = asString(map.get(DesignConfigKey.FlavorId));
148
155
  const storedSeed = asString(map.get(DesignConfigKey.BrandSeed));
149
156
  const choices = resolveDesignChoices({
@@ -155,6 +162,7 @@ export function readDesignConfig(config: ReadonlyArray<DesignConfigEntry> | null
155
162
  motion: map.get(DesignConfigKey.Motion) as DesignChoices['motion'],
156
163
  pillButtons: asBoolean(map.get(DesignConfigKey.PillButtons)) ?? undefined,
157
164
  mode: map.get(DesignConfigKey.Mode) as DesignMode,
165
+ tint: (storedTint ?? undefined) as TintId | undefined,
158
166
  fonts: map.get(DesignConfigKey.Fonts) as DesignChoices['fonts'],
159
167
  });
160
168
  return {
@@ -189,6 +197,7 @@ export function designConfigEntries(
189
197
  { key: DesignConfigKey.Motion, value: c.motion },
190
198
  { key: DesignConfigKey.PillButtons, value: String(c.pillButtons) },
191
199
  { key: DesignConfigKey.Mode, value: c.mode },
200
+ { key: DesignConfigKey.Tint, value: c.tint },
192
201
  ...(c.fonts ? [{ key: DesignConfigKey.Fonts, value: JSON.stringify(c.fonts) }] : []),
193
202
  ...(revert ? [{ key: DesignConfigKey.Revert, value: JSON.stringify(revert) }] : []),
194
203
  ];
@@ -200,6 +209,6 @@ export function sameDesignChoices(a: DesignChoices, b: DesignChoices): boolean {
200
209
  const y = resolveDesignChoices(b);
201
210
  return x.flavorId === y.flavorId && x.seed.toLowerCase() === y.seed.toLowerCase() && x.pairId === y.pairId
202
211
  && x.shape === y.shape && x.density === y.density && x.motion === y.motion
203
- && x.pillButtons === y.pillButtons && x.mode === y.mode
212
+ && x.pillButtons === y.pillButtons && x.mode === y.mode && x.tint === y.tint
204
213
  && JSON.stringify(x.fonts ?? {}) === JSON.stringify(y.fonts ?? {});
205
214
  }
@@ -41,6 +41,11 @@ export type ShapeId = keyof typeof SPEC_AXES.shapes;
41
41
  export type DensityId = keyof typeof SPEC_AXES.densities;
42
42
  export type MotionId = keyof typeof SPEC_AXES.motions;
43
43
  export type DesignMode = (typeof SPEC_AXES.modes)[number];
44
+ /** Background tint (DS9): Neutral keeps the look's own palette; Subtle / Rich tint its neutrals with the brand. */
45
+ export type TintId = keyof typeof SPEC_AXES.tints;
46
+ export const TINT_IDS = Object.keys(SPEC_AXES.tints) as ReadonlyArray<TintId>;
47
+ /** What a studio that stored its look BEFORE the tint existed wears: exactly the look's own palette. */
48
+ export const PRE_TINT_DEFAULT: TintId = 'neutral';
44
49
  export type SchemeId = keyof typeof SPEC_SCHEMES;
45
50
  export type FontRoleToken = (typeof SPEC_FONT_ROLES)[number]['token'];
46
51
 
@@ -54,6 +59,9 @@ export interface DesignChoices {
54
59
  motion: MotionId;
55
60
  pillButtons: boolean;
56
61
  mode: DesignMode;
62
+ /** Absent = the pack's default (`defaults.tint`). `readDesignConfig` reads a row stored before DS9 as
63
+ * `neutral` — the palette it was applied with. */
64
+ tint?: TintId;
57
65
  fonts?: Partial<Record<FontRoleToken, string>>;
58
66
  }
59
67
 
@@ -75,6 +83,19 @@ export interface FontPair {
75
83
  readonly families: ReadonlyArray<string>;
76
84
  }
77
85
 
86
+ /** A flavor's own colour palette (the Neutral tint): every colour token, light + the dark values that differ. */
87
+ export interface FlavorPalette {
88
+ readonly light: Readonly<Record<string, string>>;
89
+ readonly dark: Readonly<Record<string, string>>;
90
+ }
91
+
92
+ /** How a flavor takes the brand's tint: which neutral roles, how strongly, and its tinted paints. */
93
+ export interface FlavorTintConfig {
94
+ readonly scale: number;
95
+ readonly roles: ReadonlyArray<string>;
96
+ readonly paints?: ReadonlyArray<string>;
97
+ }
98
+
78
99
  /** A flavor exactly as the spec writes it. */
79
100
  export interface FlavorPackV2 {
80
101
  readonly id: string;
@@ -90,10 +111,15 @@ export interface FlavorPackV2 {
90
111
  readonly motion: MotionId;
91
112
  readonly pillButtons: boolean;
92
113
  readonly mode: DesignMode;
114
+ readonly tint: TintId;
93
115
  };
94
116
  readonly seed: string;
95
117
  readonly seeds: ReadonlyArray<string>;
96
118
  readonly pairs: ReadonlyArray<FontPair>;
119
+ readonly palette: FlavorPalette;
120
+ readonly tint: FlavorTintConfig;
121
+ /** What the contract wrote before DS9 for the rows DS9 changed at the Neutral tint (`legacyFlavorTokens`). */
122
+ readonly legacy: Readonly<Record<string, { readonly value: string; readonly dark?: string }>>;
97
123
  readonly tokens: ReadonlyArray<FlavorSpecToken>;
98
124
  }
99
125