@vectoriox/iox-ui 5.8.0 → 5.10.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/actions-core/index.d.ts +4 -1
- package/design-core/index.d.ts +9343 -0
- package/design-core/package.json +3 -0
- package/fesm2022/vectoriox-iox-ui-actions-core.mjs +23 -1
- package/fesm2022/vectoriox-iox-ui-actions-core.mjs.map +1 -1
- package/fesm2022/vectoriox-iox-ui-design-core.mjs +11325 -0
- package/fesm2022/vectoriox-iox-ui-design-core.mjs.map +1 -0
- package/fesm2022/vectoriox-iox-ui.mjs +11286 -311
- package/fesm2022/vectoriox-iox-ui.mjs.map +1 -1
- package/index.d.ts +9251 -59
- package/package.json +5 -1
- package/src/actions-core/element-actions.ts +25 -0
- package/src/design-core/brand.ts +302 -0
- package/src/design-core/css-names.ts +4 -0
- package/src/design-core/design-token.model.ts +121 -0
- package/src/design-core/flavor-seed.ts +126 -0
- package/src/design-core/flavor-spec.generated.ts +10540 -0
- package/src/design-core/flavor-tokens.ts +99 -0
- package/src/design-core/flavor.ts +137 -0
- package/src/design-core/fonts.ts +88 -0
- package/src/design-core/public-api.ts +20 -0
- package/src/design-core/spring.ts +37 -0
- package/src/design-core/token-css.ts +183 -0
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@vectoriox/iox-ui",
|
|
3
|
-
"version": "5.
|
|
3
|
+
"version": "5.10.0",
|
|
4
4
|
"peerDependencies": {
|
|
5
5
|
"@angular/common": ">=20.0.0",
|
|
6
6
|
"@angular/core": ">=20.0.0",
|
|
@@ -27,6 +27,10 @@
|
|
|
27
27
|
"types": "./consent-core/index.d.ts",
|
|
28
28
|
"default": "./fesm2022/vectoriox-iox-ui-consent-core.mjs"
|
|
29
29
|
},
|
|
30
|
+
"./design-core": {
|
|
31
|
+
"types": "./design-core/index.d.ts",
|
|
32
|
+
"default": "./fesm2022/vectoriox-iox-ui-design-core.mjs"
|
|
33
|
+
},
|
|
30
34
|
"./forms-core": {
|
|
31
35
|
"types": "./forms-core/index.d.ts",
|
|
32
36
|
"default": "./fesm2022/vectoriox-iox-ui-forms-core.mjs"
|
|
@@ -75,6 +75,26 @@ export const ELEMENT_ACTION_DESCRIPTORS: readonly ActionDescriptor[] = Object.fr
|
|
|
75
75
|
{ type: 'openConsentPreferences', labelKey: 'builder.actions.openConsentPreferences', kind: 'instant', group: 'consent', builderBehaviour: 'run',
|
|
76
76
|
target: { required: true, types: ['Dialog'] } },
|
|
77
77
|
|
|
78
|
+
// ── Server actions (A2 — Tier B): the canvas SIMULATES, the LIVE site POSTs the node's
|
|
79
|
+
// identity to ONE public route and the server re-derives the spec from the SAVED page.
|
|
80
|
+
// The author's saved params are the whole spec — a visitor contributes only the click.
|
|
81
|
+
{ type: 'sendEmail', labelKey: 'builder.actions.sendEmail', kind: 'server', group: 'service', builderBehaviour: 'simulate',
|
|
82
|
+
connector: 'mailer',
|
|
83
|
+
params: [
|
|
84
|
+
{ name: 'to', type: 'string', label: 'To' },
|
|
85
|
+
{ name: 'subject', type: 'string', label: 'Subject' },
|
|
86
|
+
{ name: 'message', type: 'string', label: 'Message' },
|
|
87
|
+
] },
|
|
88
|
+
{ type: 'callWebhook', labelKey: 'builder.actions.callWebhook', kind: 'server', group: 'service', builderBehaviour: 'simulate',
|
|
89
|
+
params: [
|
|
90
|
+
{ name: 'url', type: 'string', label: 'URL' }, // non-secret config (K2); the server SSRF-guards it
|
|
91
|
+
] },
|
|
92
|
+
{ type: 'createItem', labelKey: 'builder.actions.createItem', kind: 'server', group: 'data', builderBehaviour: 'simulate',
|
|
93
|
+
params: [
|
|
94
|
+
{ name: 'contentType', type: 'string', label: 'Content type' },
|
|
95
|
+
{ name: 'values', type: 'string', label: 'Field values (JSON)' },
|
|
96
|
+
] },
|
|
97
|
+
|
|
78
98
|
// ── Services (D-A5: visible, greyed, with the reason — never hidden, never live) ──
|
|
79
99
|
{ type: 'signIn', labelKey: 'builder.actions.signIn', kind: 'server', group: 'service', builderBehaviour: 'inert',
|
|
80
100
|
planned: true, entitlement: 'visitorIdentity' },
|
|
@@ -82,6 +102,11 @@ export const ELEMENT_ACTION_DESCRIPTORS: readonly ActionDescriptor[] = Object.fr
|
|
|
82
102
|
planned: true, connector: 'payment', entitlement: 'commerce' },
|
|
83
103
|
]);
|
|
84
104
|
|
|
105
|
+
/** The A2 server executors' dispatch set — the live engine POSTs these to the public actions
|
|
106
|
+
* route; the canvas simulates them (a server action never runs from the ADMIN's browser). */
|
|
107
|
+
export const SERVER_ELEMENT_ACTION_TYPES: ReadonlySet<string> =
|
|
108
|
+
new Set(['sendEmail', 'callWebhook', 'createItem']);
|
|
109
|
+
|
|
85
110
|
/** The Tier-A behaviour executors' dispatch set (A1c) — the engines branch on it beside
|
|
86
111
|
* DIALOG/SWIPER. Dialog actions have their own set; context/visibility stay in the legacy path. */
|
|
87
112
|
export const BEHAVIOUR_ACTION_TYPES: ReadonlySet<string> =
|
|
@@ -0,0 +1,302 @@
|
|
|
1
|
+
import { FlavorPack, FlavorTokenDef } from './flavor';
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* BRAND DERIVATION (flavors plan §6, F2): one colour in, an asserted accessible ramp out.
|
|
5
|
+
*
|
|
6
|
+
* Competitors ask for a palette of five and hope; we ask for ONE seed and compute the rest so
|
|
7
|
+
* that contrast is a TEST, not a promise. WCAG 2 ratios are the gate (every guarantee below is
|
|
8
|
+
* unit-asserted across seeds); APCA is exposed as the picker's quality read-out only.
|
|
9
|
+
*
|
|
10
|
+
* Colour math is OKLab (perceptually even lightness — the ramp steps LOOK even), hand-rolled
|
|
11
|
+
* from the published matrices because this module must stay dependency-free: it runs in the
|
|
12
|
+
* builder, the engine and (later) the generator's Node service.
|
|
13
|
+
*/
|
|
14
|
+
|
|
15
|
+
// ── sRGB ⇄ OKLab ─────────────────────────────────────────────────────────────
|
|
16
|
+
|
|
17
|
+
export interface Rgb { r: number; g: number; b: number; } // 0..1
|
|
18
|
+
export interface OkLab { L: number; a: number; b: number; }
|
|
19
|
+
|
|
20
|
+
export function parseHexColor(hex: string): Rgb | null {
|
|
21
|
+
const m = /^#?([0-9a-f]{3}|[0-9a-f]{6})$/i.exec((hex ?? '').trim());
|
|
22
|
+
if (!m) return null;
|
|
23
|
+
let h = m[1];
|
|
24
|
+
if (h.length === 3) h = h.split('').map(c => c + c).join('');
|
|
25
|
+
const n = parseInt(h, 16);
|
|
26
|
+
return { r: ((n >> 16) & 255) / 255, g: ((n >> 8) & 255) / 255, b: (n & 255) / 255 };
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
export function toHexColor(c: Rgb): string {
|
|
30
|
+
const q = (v: number) => Math.round(Math.min(1, Math.max(0, v)) * 255).toString(16).padStart(2, '0');
|
|
31
|
+
return `#${q(c.r)}${q(c.g)}${q(c.b)}`;
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
const srgbToLinear = (v: number) => (v <= 0.04045 ? v / 12.92 : Math.pow((v + 0.055) / 1.055, 2.4));
|
|
35
|
+
const linearToSrgb = (v: number) => (v <= 0.0031308 ? v * 12.92 : 1.055 * Math.pow(v, 1 / 2.4) - 0.055);
|
|
36
|
+
|
|
37
|
+
export function rgbToOklab(c: Rgb): OkLab {
|
|
38
|
+
const r = srgbToLinear(c.r), g = srgbToLinear(c.g), b = srgbToLinear(c.b);
|
|
39
|
+
const l = Math.cbrt(0.4122214708 * r + 0.5363325363 * g + 0.0514459929 * b);
|
|
40
|
+
const m = Math.cbrt(0.2119034982 * r + 0.6806995451 * g + 0.1073969566 * b);
|
|
41
|
+
const s = Math.cbrt(0.0883024619 * r + 0.2817188376 * g + 0.6299787005 * b);
|
|
42
|
+
return {
|
|
43
|
+
L: 0.2104542553 * l + 0.793617785 * m - 0.0040720468 * s,
|
|
44
|
+
a: 1.9779984951 * l - 2.428592205 * m + 0.4505937099 * s,
|
|
45
|
+
b: 0.0259040371 * l + 0.7827717662 * m - 0.808675766 * s,
|
|
46
|
+
};
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
export function oklabToRgb(c: OkLab): Rgb {
|
|
50
|
+
const l = Math.pow(c.L + 0.3963377774 * c.a + 0.2158037573 * c.b, 3);
|
|
51
|
+
const m = Math.pow(c.L - 0.1055613458 * c.a - 0.0638541728 * c.b, 3);
|
|
52
|
+
const s = Math.pow(c.L - 0.0894841775 * c.a - 1.291485548 * c.b, 3);
|
|
53
|
+
return {
|
|
54
|
+
r: linearToSrgb(+4.0767416621 * l - 3.3077115913 * m + 0.2309699292 * s),
|
|
55
|
+
g: linearToSrgb(-1.2684380046 * l + 2.6097574011 * m - 0.3413193965 * s),
|
|
56
|
+
b: linearToSrgb(-0.0041960863 * l - 0.7034186147 * m + 1.707614701 * s),
|
|
57
|
+
};
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
const inGamut = (c: Rgb) => [c.r, c.g, c.b].every(v => v >= -1e-6 && v <= 1 + 1e-6);
|
|
61
|
+
|
|
62
|
+
/** The OKLab colour at (L, hue, chroma), chroma reduced until it fits sRGB. */
|
|
63
|
+
function atLightness(L: number, hueA: number, hueB: number, chroma: number): Rgb {
|
|
64
|
+
const len = Math.hypot(hueA, hueB) || 1;
|
|
65
|
+
const ua = hueA / len, ub = hueB / len;
|
|
66
|
+
let c = chroma;
|
|
67
|
+
for (let i = 0; i < 24; i++) {
|
|
68
|
+
const rgb = oklabToRgb({ L, a: ua * c, b: ub * c });
|
|
69
|
+
if (inGamut(rgb)) return rgb;
|
|
70
|
+
c *= 0.82;
|
|
71
|
+
}
|
|
72
|
+
return oklabToRgb({ L, a: 0, b: 0 });
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
// ── WCAG 2 (the gate) ────────────────────────────────────────────────────────
|
|
76
|
+
|
|
77
|
+
export function relativeLuminance(c: Rgb): number {
|
|
78
|
+
return 0.2126 * srgbToLinear(c.r) + 0.7152 * srgbToLinear(c.g) + 0.0722 * srgbToLinear(c.b);
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
/** WCAG 2 contrast ratio between two colours (order-independent), 1..21. */
|
|
82
|
+
export function wcagContrastRatio(a: string | Rgb, b: string | Rgb): number {
|
|
83
|
+
const ca = typeof a === 'string' ? parseHexColor(a) : a;
|
|
84
|
+
const cb = typeof b === 'string' ? parseHexColor(b) : b;
|
|
85
|
+
if (!ca || !cb) return 1;
|
|
86
|
+
const la = relativeLuminance(ca), lb = relativeLuminance(cb);
|
|
87
|
+
const [hi, lo] = la >= lb ? [la, lb] : [lb, la];
|
|
88
|
+
return (hi + 0.05) / (lo + 0.05);
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
// ── APCA (the read-out — WCAG 2 stays the gate) ─────────────────────────────
|
|
92
|
+
|
|
93
|
+
/**
|
|
94
|
+
* APCA-W3 lightness contrast Lc (0.0.98G-4g constants), text-on-background and signed:
|
|
95
|
+
* positive for dark-on-light, negative for light-on-dark; |Lc| ≈ 106–108 at the extremes.
|
|
96
|
+
* Shown in the picker as the perceptual read-out; never used as the pass/fail gate here.
|
|
97
|
+
*/
|
|
98
|
+
export function apcaLc(text: string | Rgb, bg: string | Rgb): number {
|
|
99
|
+
const toY = (c: Rgb) =>
|
|
100
|
+
0.2126729 * Math.pow(c.r, 2.4) + 0.7151522 * Math.pow(c.g, 2.4) + 0.072175 * Math.pow(c.b, 2.4);
|
|
101
|
+
const clamp = (y: number) => (y < 0.022 ? y + Math.pow(0.022 - y, 1.414) : y);
|
|
102
|
+
const ct = typeof text === 'string' ? parseHexColor(text) : text;
|
|
103
|
+
const cb = typeof bg === 'string' ? parseHexColor(bg) : bg;
|
|
104
|
+
if (!ct || !cb) return 0;
|
|
105
|
+
const yTxt = clamp(toY(ct)), yBg = clamp(toY(cb));
|
|
106
|
+
let lc: number;
|
|
107
|
+
if (yBg > yTxt) lc = (Math.pow(yBg, 0.56) - Math.pow(yTxt, 0.57)) * 1.14;
|
|
108
|
+
else lc = (Math.pow(yBg, 0.65) - Math.pow(yTxt, 0.62)) * 1.14;
|
|
109
|
+
if (Math.abs(lc) < 0.1) return 0;
|
|
110
|
+
return Math.round((lc > 0 ? lc - 0.027 : lc + 0.027) * 1000) / 10;
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
// ── The ramp ─────────────────────────────────────────────────────────────────
|
|
114
|
+
|
|
115
|
+
/** Fixed, perceptually even lightness ladder for brand-50 … brand-900. */
|
|
116
|
+
const RAMP_STEPS: ReadonlyArray<{ step: number; L: number; chromaScale: number }> = [
|
|
117
|
+
{ step: 50, L: 0.97, chromaScale: 0.22 },
|
|
118
|
+
{ step: 100, L: 0.93, chromaScale: 0.42 },
|
|
119
|
+
{ step: 200, L: 0.86, chromaScale: 0.66 },
|
|
120
|
+
{ step: 300, L: 0.78, chromaScale: 0.85 },
|
|
121
|
+
{ step: 400, L: 0.70, chromaScale: 0.96 },
|
|
122
|
+
{ step: 500, L: 0.62, chromaScale: 1.0 },
|
|
123
|
+
{ step: 600, L: 0.53, chromaScale: 0.96 },
|
|
124
|
+
{ step: 700, L: 0.45, chromaScale: 0.88 },
|
|
125
|
+
{ step: 800, L: 0.36, chromaScale: 0.76 },
|
|
126
|
+
{ step: 900, L: 0.27, chromaScale: 0.62 },
|
|
127
|
+
];
|
|
128
|
+
|
|
129
|
+
const BLACK: Rgb = { r: 0, g: 0, b: 0 };
|
|
130
|
+
const WHITE: Rgb = { r: 1, g: 1, b: 1 };
|
|
131
|
+
|
|
132
|
+
/** Snap to the 8-bit hex the token will actually carry — contrast is judged on WHAT SHIPS.
|
|
133
|
+
* (The first version searched in float space and hex rounding landed 6.9596 where 7 was owed.) */
|
|
134
|
+
const quant = (c: Rgb): Rgb => parseHexColor(toHexColor(c))!;
|
|
135
|
+
|
|
136
|
+
/** Binary-search an OKLab lightness so `contrast(colour, against) ≥ ratio`, darkening-or-lightening. */
|
|
137
|
+
function atContrast(against: Rgb, ratio: number, hueA: number, hueB: number, chroma: number, darker: boolean): Rgb {
|
|
138
|
+
let lo = darker ? 0 : rgbToOklab(against).L;
|
|
139
|
+
let hi = darker ? rgbToOklab(against).L : 1;
|
|
140
|
+
let best = quant(atLightness(darker ? 0.18 : 0.92, hueA, hueB, chroma));
|
|
141
|
+
for (let i = 0; i < 28; i++) {
|
|
142
|
+
const mid = (lo + hi) / 2;
|
|
143
|
+
const candidate = quant(atLightness(mid, hueA, hueB, chroma));
|
|
144
|
+
if (wcagContrastRatio(candidate, against) >= ratio) {
|
|
145
|
+
best = candidate;
|
|
146
|
+
if (darker) lo = mid; else hi = mid; // pull back toward `against`: the LOWEST passing step
|
|
147
|
+
} else {
|
|
148
|
+
if (darker) hi = mid; else lo = mid;
|
|
149
|
+
}
|
|
150
|
+
}
|
|
151
|
+
return best;
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
/**
|
|
155
|
+
* The derived brand tokens (plan §6's table, every row asserted in brand.spec.ts):
|
|
156
|
+
* `brand-50…900` (monotonic OKLab lightness, gamut-safe) · `brandInk` (black or white, ≥4.5 on
|
|
157
|
+
* brand-500 — brand-500's lightness is nudged when neither side clears it) · `surface`/`ink`/
|
|
158
|
+
* `inkMuted` (seed-tinted neutrals, ≥7:1 / ≥4.5:1 on surface) · `line` (the LIGHTEST neutral that
|
|
159
|
+
* still clears 3:1 on surface) · `accent` (pack-rotated hue, ≥4.5:1 on surface) — with dark-scheme
|
|
160
|
+
* values for the neutral family riding `values.dark`.
|
|
161
|
+
*
|
|
162
|
+
* Throws on an unparseable seed: callers (the picker) validate; a silent fallback here would
|
|
163
|
+
* launch a studio into a palette nobody chose.
|
|
164
|
+
*/
|
|
165
|
+
export function deriveBrandTokens(seed: string, pack: Pick<FlavorPack, 'accentHueDeg'>): FlavorTokenDef[] {
|
|
166
|
+
const seedRgb = parseHexColor(seed);
|
|
167
|
+
if (!seedRgb) throw new TypeError(`deriveBrandTokens: not a colour: "${seed}"`);
|
|
168
|
+
const lab = rgbToOklab(seedRgb);
|
|
169
|
+
const chroma = Math.hypot(lab.a, lab.b);
|
|
170
|
+
const ua = chroma > 1e-6 ? lab.a : 0.0, ub = chroma > 1e-6 ? lab.b : 1e-6; // grey seeds: keep a stable (arbitrary) hue axis
|
|
171
|
+
const baseChroma = Math.max(chroma, 0.02); // near-grey seeds still get a usable, quiet ramp
|
|
172
|
+
|
|
173
|
+
const token = (name: string, value: string, dark?: string): FlavorTokenDef =>
|
|
174
|
+
({ name, value, ...(dark ? { values: { dark } } : {}), type: 'color', group: 'colour' });
|
|
175
|
+
|
|
176
|
+
// 1 — the ramp
|
|
177
|
+
const ramp = RAMP_STEPS.map(({ step, L, chromaScale }) =>
|
|
178
|
+
({ step, rgb: atLightness(L, ua, ub, baseChroma * chromaScale) }));
|
|
179
|
+
|
|
180
|
+
// 2 — brandInk on brand-500, nudging 500 (within its neighbours) when neither ink clears 4.5
|
|
181
|
+
let five = ramp.find(r => r.step === 500)!;
|
|
182
|
+
five.rgb = quant(five.rgb);
|
|
183
|
+
const inkFor = (bg: Rgb) => (wcagContrastRatio(WHITE, bg) >= wcagContrastRatio(BLACK, bg) ? WHITE : BLACK);
|
|
184
|
+
for (let i = 0; i < 40 && wcagContrastRatio(inkFor(five.rgb), five.rgb) < 4.5; i++) {
|
|
185
|
+
const L = rgbToOklab(five.rgb).L;
|
|
186
|
+
// Mid tones can fail both inks — walk toward whichever side is already winning, but
|
|
187
|
+
// never past the neighbouring steps: the ramp's monotonic lightness is a guarantee.
|
|
188
|
+
const towardDark = wcagContrastRatio(WHITE, five.rgb) >= wcagContrastRatio(BLACK, five.rgb);
|
|
189
|
+
const next = Math.min(0.695, Math.max(0.545, L + (towardDark ? -0.012 : 0.012)));
|
|
190
|
+
if (next === L) break;
|
|
191
|
+
five.rgb = quant(atLightness(next, ua, ub, baseChroma));
|
|
192
|
+
}
|
|
193
|
+
const brandInk = inkFor(five.rgb);
|
|
194
|
+
|
|
195
|
+
// 3 — the neutral family, faintly tinted by the seed hue. Surfaces are quantized FIRST:
|
|
196
|
+
// every later guarantee is measured against the hex that ships, not a float ideal.
|
|
197
|
+
const tint = 0.006;
|
|
198
|
+
const surface = quant(atLightness(0.985, ua, ub, tint));
|
|
199
|
+
const surfaceDark = quant(atLightness(0.145, ua, ub, tint * 1.5));
|
|
200
|
+
const ink = atContrast(surface, 7, ua, ub, tint * 2, true);
|
|
201
|
+
const inkDark = atContrast(surfaceDark, 7, ua, ub, tint * 2, false);
|
|
202
|
+
const inkMuted = atContrast(surface, 4.5, ua, ub, tint * 2, true);
|
|
203
|
+
const inkMutedDark = atContrast(surfaceDark, 4.5, ua, ub, tint * 2, false);
|
|
204
|
+
const line = atContrast(surface, 3, ua, ub, tint, true);
|
|
205
|
+
const lineDark = atContrast(surfaceDark, 3, ua, ub, tint, false);
|
|
206
|
+
|
|
207
|
+
// 4 — the accent: pack-defined hue rotation from the seed, readable on surface
|
|
208
|
+
const rot = ((pack.accentHueDeg ?? 150) * Math.PI) / 180;
|
|
209
|
+
const ra = ua * Math.cos(rot) - ub * Math.sin(rot);
|
|
210
|
+
const rb = ua * Math.sin(rot) + ub * Math.cos(rot);
|
|
211
|
+
const accent = atContrast(surface, 4.5, ra, rb, Math.max(baseChroma, 0.06), true);
|
|
212
|
+
|
|
213
|
+
return [
|
|
214
|
+
...ramp.map(({ step, rgb }) => token(`brand-${step}`, toHexColor(rgb))),
|
|
215
|
+
token('brandInk', toHexColor(brandInk)),
|
|
216
|
+
token('surface', toHexColor(surface), toHexColor(surfaceDark)),
|
|
217
|
+
token('ink', toHexColor(ink), toHexColor(inkDark)),
|
|
218
|
+
token('inkMuted', toHexColor(inkMuted), toHexColor(inkMutedDark)),
|
|
219
|
+
token('line', toHexColor(line), toHexColor(lineDark)),
|
|
220
|
+
token('accent', toHexColor(accent)),
|
|
221
|
+
];
|
|
222
|
+
}
|
|
223
|
+
|
|
224
|
+
// ── The design-system accent quartet (design-system plan §3e) ────────────────
|
|
225
|
+
|
|
226
|
+
/** WCAG 2 AA for body text — the gate every quartet pair is held to. */
|
|
227
|
+
export const WCAG_AA_TEXT = 4.5;
|
|
228
|
+
|
|
229
|
+
export const ACCENT_TOKEN_NAMES = ['accent', 'accent-ink', 'accent-hover', 'accent-text'] as const;
|
|
230
|
+
export type AccentTokenName = (typeof ACCENT_TOKEN_NAMES)[number];
|
|
231
|
+
|
|
232
|
+
/** The page backgrounds the accent must read on, light and (optionally) dark. */
|
|
233
|
+
export interface AccentBackgrounds {
|
|
234
|
+
bg: string;
|
|
235
|
+
bgDark?: string;
|
|
236
|
+
}
|
|
237
|
+
|
|
238
|
+
const RAMP_ORDER = RAMP_STEPS.map(s => s.step);
|
|
239
|
+
const WHITE_HEX = toHexColor(WHITE);
|
|
240
|
+
const BLACK_HEX = toHexColor(BLACK);
|
|
241
|
+
const passesText = (fg: string, bg: string) => wcagContrastRatio(fg, bg) >= WCAG_AA_TEXT;
|
|
242
|
+
const bestInkHex = (bg: string) =>
|
|
243
|
+
(wcagContrastRatio(WHITE_HEX, bg) >= wcagContrastRatio(BLACK_HEX, bg) ? WHITE_HEX : BLACK_HEX);
|
|
244
|
+
|
|
245
|
+
/**
|
|
246
|
+
* The quartet a seed different from the pack's own becomes, built ON `deriveBrandTokens` (its
|
|
247
|
+
* ramp and its WCAG-gated `brandInk`) — never a second colour model:
|
|
248
|
+
*
|
|
249
|
+
* - `accent` = brand-500 (already nudged until `brandInk` clears 4.5:1 on it), `accent-ink` = brandInk
|
|
250
|
+
* - `accent-hover` = the neighbouring step AWAY from the ink, so ink-on-hover only gains contrast
|
|
251
|
+
* - `accent-text` = the first step from 500 toward the far end that clears 4.5:1 on `bg`
|
|
252
|
+
* - dark: the first of brand-300/200/400 an ink clears 4.5:1 on, the same hover rule, and
|
|
253
|
+
* `accent-text` = the first light step that clears 4.5:1 on `bgDark`
|
|
254
|
+
*
|
|
255
|
+
* Every pair (ink/accent, ink/hover, text/bg) is asserted ≥ 4.5:1 in `brand.spec.ts`.
|
|
256
|
+
*/
|
|
257
|
+
export function deriveAccentTokens(
|
|
258
|
+
seed: string,
|
|
259
|
+
against: AccentBackgrounds,
|
|
260
|
+
pack: Pick<FlavorPack, 'accentHueDeg'> = {},
|
|
261
|
+
): FlavorTokenDef[] {
|
|
262
|
+
const derived = deriveBrandTokens(seed, pack);
|
|
263
|
+
const hexOf = (name: string) => derived.find(t => t.name === name)!.value;
|
|
264
|
+
const step = (n: number) => hexOf(`brand-${n}`);
|
|
265
|
+
|
|
266
|
+
const hoverFor = (accentStep: number, ink: string): string => {
|
|
267
|
+
const i = RAMP_ORDER.indexOf(accentStep) + (ink === WHITE_HEX ? 1 : -1);
|
|
268
|
+
const candidate = RAMP_ORDER[i] !== undefined ? step(RAMP_ORDER[i]) : null;
|
|
269
|
+
return candidate && passesText(ink, candidate) ? candidate : step(accentStep);
|
|
270
|
+
};
|
|
271
|
+
const firstOn = (steps: number[], bg: string) => step(steps.find(n => passesText(step(n), bg)) ?? steps[steps.length - 1]);
|
|
272
|
+
|
|
273
|
+
const ink = hexOf('brandInk');
|
|
274
|
+
const light = {
|
|
275
|
+
accent: step(500),
|
|
276
|
+
ink,
|
|
277
|
+
hover: hoverFor(500, ink),
|
|
278
|
+
text: firstOn([500, 600, 700, 800, 900], against.bg),
|
|
279
|
+
};
|
|
280
|
+
|
|
281
|
+
const dark = against.bgDark ? (() => {
|
|
282
|
+
const darkStep = [300, 200, 400].find(n => passesText(bestInkHex(step(n)), step(n))) ?? 300;
|
|
283
|
+
const darkInk = bestInkHex(step(darkStep));
|
|
284
|
+
return {
|
|
285
|
+
accent: step(darkStep),
|
|
286
|
+
ink: darkInk,
|
|
287
|
+
hover: hoverFor(darkStep, darkInk),
|
|
288
|
+
text: firstOn([300, 200, 100, 50], against.bgDark),
|
|
289
|
+
};
|
|
290
|
+
})() : null;
|
|
291
|
+
|
|
292
|
+
const row = (name: AccentTokenName, value: string, darkValue?: string): FlavorTokenDef => ({
|
|
293
|
+
name, value, type: 'color', group: 'colour', tier: 'semantic',
|
|
294
|
+
...(darkValue ? { values: { dark: darkValue } } : {}),
|
|
295
|
+
});
|
|
296
|
+
return [
|
|
297
|
+
row('accent', light.accent, dark?.accent),
|
|
298
|
+
row('accent-ink', light.ink, dark?.ink),
|
|
299
|
+
row('accent-hover', light.hover, dark?.hover),
|
|
300
|
+
row('accent-text', light.text, dark?.text),
|
|
301
|
+
];
|
|
302
|
+
}
|
|
@@ -0,0 +1,121 @@
|
|
|
1
|
+
import { FLAVOR_SPEC } from './flavor-spec.generated';
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* The design-token vocabulary (design-system plan §3a/§3f). Every list here is READ from the
|
|
5
|
+
* generated spec, so the contract has one source: iox-ai-guidance `flavor-spec.v1.json`.
|
|
6
|
+
*/
|
|
7
|
+
|
|
8
|
+
export type DesignTokenType = (typeof FLAVOR_SPEC.tokenTypes)[number];
|
|
9
|
+
export const DESIGN_TOKEN_TYPES: ReadonlyArray<DesignTokenType> = FLAVOR_SPEC.tokenTypes;
|
|
10
|
+
|
|
11
|
+
export type DesignTokenTier = keyof typeof FLAVOR_SPEC.tiers;
|
|
12
|
+
export const DESIGN_TOKEN_TIERS = Object.keys(FLAVOR_SPEC.tiers) as ReadonlyArray<DesignTokenTier>;
|
|
13
|
+
|
|
14
|
+
/** The spec carries groups per row, not as a list — `design-token.model.spec` pins this list
|
|
15
|
+
* to exactly the set of groups the spec's rows use. */
|
|
16
|
+
export type DesignTokenGroup = 'colour' | 'type' | 'shape' | 'depth' | 'space' | 'motion' | 'components';
|
|
17
|
+
export const DESIGN_TOKEN_GROUPS: ReadonlyArray<DesignTokenGroup> =
|
|
18
|
+
['colour', 'type', 'shape', 'depth', 'space', 'motion', 'components'];
|
|
19
|
+
|
|
20
|
+
/** One `designtokens` row, transport-agnostic (stored rows add `_id`/`organization`). */
|
|
21
|
+
export interface DesignTokenRow {
|
|
22
|
+
name: string;
|
|
23
|
+
type: DesignTokenType;
|
|
24
|
+
value: string;
|
|
25
|
+
/** Per-scheme overrides. Only `dark` exists; absent means the base value everywhere. */
|
|
26
|
+
values?: { dark?: string };
|
|
27
|
+
/** Machine stamp: `flavor:<id>@<version>` on seeded rows, `user` on a font the author chose. */
|
|
28
|
+
source?: string;
|
|
29
|
+
group?: DesignTokenGroup;
|
|
30
|
+
tier?: DesignTokenTier;
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
/** A row as the planners see it: a stored row may carry its id. */
|
|
34
|
+
export interface StoredDesignTokenRow extends DesignTokenRow {
|
|
35
|
+
_id?: string;
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
export type ShapeId = keyof typeof FLAVOR_SPEC.axes.shapes;
|
|
39
|
+
export type DensityId = keyof typeof FLAVOR_SPEC.axes.densities;
|
|
40
|
+
export type MotionId = keyof typeof FLAVOR_SPEC.axes.motions;
|
|
41
|
+
export type DesignMode = (typeof FLAVOR_SPEC.axes.modes)[number];
|
|
42
|
+
export type SchemeId = keyof typeof FLAVOR_SPEC.schemes;
|
|
43
|
+
export type FontRoleToken = (typeof FLAVOR_SPEC.fontRoles)[number]['token'];
|
|
44
|
+
|
|
45
|
+
/** Everything a studio chose about its look — the `settings.design` row, typed. */
|
|
46
|
+
export interface DesignChoices {
|
|
47
|
+
flavorId: string;
|
|
48
|
+
seed: string;
|
|
49
|
+
pairId: string;
|
|
50
|
+
shape: ShapeId;
|
|
51
|
+
density: DensityId;
|
|
52
|
+
motion: MotionId;
|
|
53
|
+
pillButtons: boolean;
|
|
54
|
+
mode: DesignMode;
|
|
55
|
+
fonts?: Partial<Record<FontRoleToken, string>>;
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
export interface FlavorSpecToken {
|
|
59
|
+
readonly name: string;
|
|
60
|
+
readonly tier: DesignTokenTier;
|
|
61
|
+
readonly type: DesignTokenType;
|
|
62
|
+
readonly group: DesignTokenGroup;
|
|
63
|
+
readonly value: string;
|
|
64
|
+
readonly dark?: string;
|
|
65
|
+
/** The flavor departs from the component token's semantic default. */
|
|
66
|
+
readonly overridden?: boolean;
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
export interface FontPair {
|
|
70
|
+
readonly id: string;
|
|
71
|
+
readonly label: string;
|
|
72
|
+
readonly tokens: Readonly<Record<string, string>>;
|
|
73
|
+
readonly families: ReadonlyArray<string>;
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
/** A flavor exactly as the spec writes it. */
|
|
77
|
+
export interface FlavorPackV2 {
|
|
78
|
+
readonly id: string;
|
|
79
|
+
readonly name: string;
|
|
80
|
+
readonly personality: string;
|
|
81
|
+
readonly inspiredBy: string;
|
|
82
|
+
readonly blurb: string;
|
|
83
|
+
readonly popularFor: ReadonlyArray<string>;
|
|
84
|
+
readonly replaces?: string;
|
|
85
|
+
readonly defaults: {
|
|
86
|
+
readonly shape: ShapeId;
|
|
87
|
+
readonly density: DensityId;
|
|
88
|
+
readonly motion: MotionId;
|
|
89
|
+
readonly pillButtons: boolean;
|
|
90
|
+
readonly mode: DesignMode;
|
|
91
|
+
};
|
|
92
|
+
readonly seed: string;
|
|
93
|
+
readonly seeds: ReadonlyArray<string>;
|
|
94
|
+
readonly pairs: ReadonlyArray<FontPair>;
|
|
95
|
+
readonly tokens: ReadonlyArray<FlavorSpecToken>;
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
/** The spec's flavors, typed. Assignment here is the compile-time proof that the generated
|
|
99
|
+
* data fits `FlavorPackV2`. */
|
|
100
|
+
export const SPEC_FLAVORS: ReadonlyArray<FlavorPackV2> = FLAVOR_SPEC.flavors;
|
|
101
|
+
|
|
102
|
+
export const FLAVOR_SPEC_VERSION: number = FLAVOR_SPEC.version;
|
|
103
|
+
|
|
104
|
+
/** The stamp seeded rows carry, so an upgrade can tell the machine's rows from the author's. */
|
|
105
|
+
export function flavorSourceStamp(flavorId: string, version: number = FLAVOR_SPEC_VERSION): string {
|
|
106
|
+
return `flavor:${flavorId}@${version}`;
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
export const USER_TOKEN_SOURCE = 'user';
|
|
110
|
+
|
|
111
|
+
export function isDesignTokenType(v: unknown): v is DesignTokenType {
|
|
112
|
+
return typeof v === 'string' && (DESIGN_TOKEN_TYPES as ReadonlyArray<string>).includes(v);
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
export function isDesignTokenGroup(v: unknown): v is DesignTokenGroup {
|
|
116
|
+
return typeof v === 'string' && (DESIGN_TOKEN_GROUPS as ReadonlyArray<string>).includes(v);
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
export function isDesignTokenTier(v: unknown): v is DesignTokenTier {
|
|
120
|
+
return typeof v === 'string' && (DESIGN_TOKEN_TIERS as ReadonlyArray<string>).includes(v);
|
|
121
|
+
}
|
|
@@ -0,0 +1,126 @@
|
|
|
1
|
+
import { FlavorTokenDef } from './flavor';
|
|
2
|
+
import { DesignTokenRow, StoredDesignTokenRow } from './design-token.model';
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* Seeding and reverting a look, as pure plans (flavors F2, moved here from iox-cms-client in
|
|
6
|
+
* design-system DS1 so the builder, the wizard and the server-side site build share ONE merge).
|
|
7
|
+
* Each caller applies a plan with its own transport.
|
|
8
|
+
*/
|
|
9
|
+
|
|
10
|
+
const FLAVOR_SOURCE_PREFIX = 'flavor:';
|
|
11
|
+
const DEFAULT_TOKEN_TYPE = 'other';
|
|
12
|
+
|
|
13
|
+
/** One row's fate in a seed/re-seed pass (the §10 C2 merge semantics). */
|
|
14
|
+
export interface FlavorSeedPlan {
|
|
15
|
+
create: DesignTokenRow[];
|
|
16
|
+
update: { id: string; data: Partial<DesignTokenRow> }[];
|
|
17
|
+
/** Names deliberately left alone: hand-made rows, and seeded rows the author edited. */
|
|
18
|
+
kept: string[];
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
/** What Apply stores so the 7-day revert is honest: the WHOLE pre-apply token set plus the
|
|
22
|
+
* design identity it belonged to. */
|
|
23
|
+
export interface FlavorRevertSnapshot {
|
|
24
|
+
appliedAt: number;
|
|
25
|
+
/** The design row's identity BEFORE the apply, restored with the tokens. */
|
|
26
|
+
from: { flavorId: string | null; flavorVersion: string | null; brandSeed: string | null };
|
|
27
|
+
tokens: Array<Pick<DesignTokenRow, 'name' | 'type' | 'value' | 'values' | 'source' | 'group' | 'tier'>>;
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
export interface FlavorRevertPlan {
|
|
31
|
+
create: DesignTokenRow[];
|
|
32
|
+
update: Array<{ id: string; data: Partial<DesignTokenRow> }>;
|
|
33
|
+
/** Rows the apply created (flavor-stamped, absent from the snapshot) — removed on revert. */
|
|
34
|
+
remove: string[];
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
export const REVERT_WINDOW_MS = 7 * 24 * 60 * 60 * 1000;
|
|
38
|
+
|
|
39
|
+
const isSeeded = (row: { source?: string } | null | undefined) => (row?.source ?? '').startsWith(FLAVOR_SOURCE_PREFIX);
|
|
40
|
+
|
|
41
|
+
const classification = (def: Pick<DesignTokenRow, 'group' | 'tier'>): Pick<DesignTokenRow, 'group' | 'tier'> => ({
|
|
42
|
+
...(def.group ? { group: def.group } : {}),
|
|
43
|
+
...(def.tier ? { tier: def.tier } : {}),
|
|
44
|
+
});
|
|
45
|
+
|
|
46
|
+
/** Board F7's footer promise, as a boundary: available through the 7th day, not beyond. */
|
|
47
|
+
export function revertAvailable(appliedAt: number | null | undefined, now: number): boolean {
|
|
48
|
+
return typeof appliedAt === 'number' && appliedAt > 0 && now - appliedAt <= REVERT_WINDOW_MS;
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
/**
|
|
52
|
+
* Restore the snapshot EXACTLY: snapshot rows come back to their stored value/dark/source
|
|
53
|
+
* (update when present, recreate when deleted); rows the apply CREATED (flavor-stamped and
|
|
54
|
+
* absent from the snapshot) are removed. Hand-made rows born after the apply are not ours
|
|
55
|
+
* to touch — a revert undoes the FLAVOR, not the author's week.
|
|
56
|
+
*/
|
|
57
|
+
export function planFlavorRevert(
|
|
58
|
+
current: ReadonlyArray<StoredDesignTokenRow>,
|
|
59
|
+
snapshot: FlavorRevertSnapshot['tokens'],
|
|
60
|
+
): FlavorRevertPlan {
|
|
61
|
+
const byName = new Map<string, StoredDesignTokenRow>();
|
|
62
|
+
for (const row of current ?? []) byName.set(row.name, row);
|
|
63
|
+
const snapNames = new Set<string>();
|
|
64
|
+
const plan: FlavorRevertPlan = { create: [], update: [], remove: [] };
|
|
65
|
+
for (const snap of snapshot ?? []) {
|
|
66
|
+
snapNames.add(snap.name);
|
|
67
|
+
const live = byName.get(snap.name);
|
|
68
|
+
if (!live) {
|
|
69
|
+
plan.create.push({ name: snap.name, type: snap.type, value: snap.value, values: snap.values, source: snap.source, ...classification(snap) });
|
|
70
|
+
continue;
|
|
71
|
+
}
|
|
72
|
+
const differs = live.value !== snap.value
|
|
73
|
+
|| (live.values?.dark ?? null) !== (snap.values?.dark ?? null)
|
|
74
|
+
|| (live.source ?? null) !== (snap.source ?? null);
|
|
75
|
+
if (differs && live._id) {
|
|
76
|
+
plan.update.push({ id: live._id, data: { value: snap.value, values: snap.values, source: snap.source } });
|
|
77
|
+
}
|
|
78
|
+
}
|
|
79
|
+
for (const row of current ?? []) {
|
|
80
|
+
if (!snapNames.has(row.name) && row._id && isSeeded(row)) plan.remove.push(row._id);
|
|
81
|
+
}
|
|
82
|
+
return plan;
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
/**
|
|
86
|
+
* The seeding MERGE: desired tokens vs existing rows, deciding create / update / keep. The §10 C2
|
|
87
|
+
* rule — "an upgrade only rewrites untouched rows" — is a comparison against what the PREVIOUS
|
|
88
|
+
* seed wrote (`oldExpected`): a seeded row whose value still equals it is the machine's and may be
|
|
89
|
+
* rewritten; any drift means the author took it over, and their hand wins. A row without a
|
|
90
|
+
* `flavor:` stamp is never touched at all. `group`/`tier` ride every write.
|
|
91
|
+
*/
|
|
92
|
+
export function planFlavorSeed(
|
|
93
|
+
desired: ReadonlyArray<FlavorTokenDef>,
|
|
94
|
+
existing: ReadonlyArray<StoredDesignTokenRow>,
|
|
95
|
+
stamp: string,
|
|
96
|
+
oldExpected: Map<string, { value: string; dark?: string }> | null,
|
|
97
|
+
): FlavorSeedPlan {
|
|
98
|
+
const byName = new Map((existing ?? []).map(r => [r.name, r]));
|
|
99
|
+
const plan: FlavorSeedPlan = { create: [], update: [], kept: [] };
|
|
100
|
+
for (const def of desired ?? []) {
|
|
101
|
+
if (!def?.name) continue;
|
|
102
|
+
const row = byName.get(def.name);
|
|
103
|
+
if (!row) {
|
|
104
|
+
plan.create.push({
|
|
105
|
+
name: def.name,
|
|
106
|
+
type: def.type ?? DEFAULT_TOKEN_TYPE,
|
|
107
|
+
value: def.value,
|
|
108
|
+
...(def.values ? { values: def.values } : {}),
|
|
109
|
+
source: stamp,
|
|
110
|
+
...classification(def),
|
|
111
|
+
});
|
|
112
|
+
continue;
|
|
113
|
+
}
|
|
114
|
+
if (!isSeeded(row)) { plan.kept.push(def.name); continue; }
|
|
115
|
+
const sameAsDesired = row.value === def.value && (row.values?.dark ?? '') === (def.values?.dark ?? '');
|
|
116
|
+
if (sameAsDesired) continue; // already exactly this — nothing to write, nothing kept-against
|
|
117
|
+
const old = oldExpected?.get(def.name);
|
|
118
|
+
const untouched = !!old && row.value === old.value && (row.values?.dark ?? '') === (old.dark ?? '');
|
|
119
|
+
if (untouched && row._id) {
|
|
120
|
+
plan.update.push({ id: row._id, data: { value: def.value, values: def.values ?? {}, source: stamp, ...classification(def) } });
|
|
121
|
+
} else {
|
|
122
|
+
plan.kept.push(def.name); // edited since seeding (or unverifiable) — the author's hand wins
|
|
123
|
+
}
|
|
124
|
+
}
|
|
125
|
+
return plan;
|
|
126
|
+
}
|