@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.
- package/design-core/index.d.ts +1153 -191
- package/fesm2022/vectoriox-iox-ui-design-core.mjs +1680 -241
- package/fesm2022/vectoriox-iox-ui-design-core.mjs.map +1 -1
- package/fesm2022/vectoriox-iox-ui.mjs +81 -14
- package/fesm2022/vectoriox-iox-ui.mjs.map +1 -1
- package/index.d.ts +35 -3
- package/package.json +1 -1
- package/src/design-core/brand-neutrals.ts +236 -0
- package/src/design-core/brand.ts +24 -14
- package/src/design-core/colour-eval.ts +117 -0
- package/src/design-core/contrast-audit.ts +214 -0
- package/src/design-core/design-apply.ts +34 -14
- package/src/design-core/design-settings.ts +12 -3
- package/src/design-core/design-token.model.ts +26 -0
- package/src/design-core/flavor-fixtures.generated.ts +150 -0
- package/src/design-core/flavor-spec.generated.ts +1061 -189
- package/src/design-core/flavor-tokens.ts +36 -8
- package/src/design-core/public-api.ts +3 -0
- package/src/design-core/token-css.ts +48 -15
- package/styles/iox-design.css +16 -0
- package/styles/iox-forms.css +15 -9
|
@@ -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 —
|
|
43
|
-
|
|
44
|
-
|
|
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
|
-
|
|
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
|
|
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
|
|
76
|
-
return [current.get(row.name),
|
|
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.
|
|
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
|
-
*
|
|
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
|
|
149
|
-
|
|
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
|
-
/**
|
|
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
|
|