@vectoriox/iox-ui 5.10.0 → 5.12.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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@vectoriox/iox-ui",
3
- "version": "5.10.0",
3
+ "version": "5.12.0",
4
4
  "peerDependencies": {
5
5
  "@angular/common": ">=20.0.0",
6
6
  "@angular/core": ">=20.0.0",
@@ -226,13 +226,18 @@ export function deriveBrandTokens(seed: string, pack: Pick<FlavorPack, 'accentHu
226
226
  /** WCAG 2 AA for body text — the gate every quartet pair is held to. */
227
227
  export const WCAG_AA_TEXT = 4.5;
228
228
 
229
- export const ACCENT_TOKEN_NAMES = ['accent', 'accent-ink', 'accent-hover', 'accent-text'] as const;
229
+ export const ACCENT_TOKEN_NAMES = [
230
+ 'accent', 'accent-ink', 'accent-hover', 'accent-text', 'inverse-accent', 'inverse-accent-ink',
231
+ ] as const;
230
232
  export type AccentTokenName = (typeof ACCENT_TOKEN_NAMES)[number];
231
233
 
232
- /** The page backgrounds the accent must read on, light and (optionally) dark. */
234
+ /** The backgrounds the accent must read on, light and (optionally) dark. Without an inverse
235
+ * background the inverse pair is not derived — the pack's own stays. */
233
236
  export interface AccentBackgrounds {
234
237
  bg: string;
235
238
  bgDark?: string;
239
+ inverseBg?: string;
240
+ inverseBgDark?: string;
236
241
  }
237
242
 
238
243
  const RAMP_ORDER = RAMP_STEPS.map(s => s.step);
@@ -252,7 +257,13 @@ const bestInkHex = (bg: string) =>
252
257
  * - dark: the first of brand-300/200/400 an ink clears 4.5:1 on, the same hover rule, and
253
258
  * `accent-text` = the first light step that clears 4.5:1 on `bgDark`
254
259
  *
255
- * Every pair (ink/accent, ink/hover, text/bg) is asserted ≥ 4.5:1 in `brand.spec.ts`.
260
+ * - `inverse-accent` = the first step from 500 toward the end that reads on `inverseBg` clearing
261
+ * 4.5:1 on it; `inverse-accent-ink` = the higher-contrast of `brandInk` and the OPPOSITE ramp end
262
+ * (the spec's `brandSeed` rule). A step whose best ink still misses 4.5:1 is passed over, so the
263
+ * pair is a guarantee rather than a hope. Dark: the same against `inverseBgDark`.
264
+ *
265
+ * Every pair (ink/accent, ink/hover, text/bg, inverse ink/inverse accent, inverse accent/inverse
266
+ * bg) is asserted ≥ 4.5:1 across packs and seeds in `flavor-tokens.spec.ts`.
256
267
  */
257
268
  export function deriveAccentTokens(
258
269
  seed: string,
@@ -289,6 +300,24 @@ export function deriveAccentTokens(
289
300
  };
290
301
  })() : null;
291
302
 
303
+ const inverseOn = (inverseBg: string) => {
304
+ const toward = wcagContrastRatio(step(RAMP_ORDER[0]), inverseBg) >= wcagContrastRatio(step(RAMP_ORDER[RAMP_ORDER.length - 1]), inverseBg)
305
+ ? -1 : 1;
306
+ const walk = RAMP_ORDER.filter(n => (toward < 0 ? n <= 500 : n >= 500));
307
+ if (toward < 0) walk.reverse();
308
+ const rampEnd = step(toward < 0 ? RAMP_ORDER[RAMP_ORDER.length - 1] : RAMP_ORDER[0]);
309
+ const inkOn = (fill: string) =>
310
+ (wcagContrastRatio(ink, fill) >= wcagContrastRatio(rampEnd, fill) ? ink : rampEnd);
311
+ for (const n of walk) {
312
+ const fill = step(n);
313
+ if (passesText(fill, inverseBg) && passesText(inkOn(fill), fill)) return { accent: fill, ink: inkOn(fill) };
314
+ }
315
+ const fill = step(walk[walk.length - 1]);
316
+ return { accent: fill, ink: passesText(inkOn(fill), fill) ? inkOn(fill) : bestInkHex(fill) };
317
+ };
318
+ const inverse = against.inverseBg ? inverseOn(against.inverseBg) : null;
319
+ const inverseDark = against.inverseBg && against.inverseBgDark ? inverseOn(against.inverseBgDark) : null;
320
+
292
321
  const row = (name: AccentTokenName, value: string, darkValue?: string): FlavorTokenDef => ({
293
322
  name, value, type: 'color', group: 'colour', tier: 'semantic',
294
323
  ...(darkValue ? { values: { dark: darkValue } } : {}),
@@ -298,5 +327,9 @@ export function deriveAccentTokens(
298
327
  row('accent-ink', light.ink, dark?.ink),
299
328
  row('accent-hover', light.hover, dark?.hover),
300
329
  row('accent-text', light.text, dark?.text),
330
+ ...(inverse ? [
331
+ row('inverse-accent', inverse.accent, inverseDark?.accent),
332
+ row('inverse-accent-ink', inverse.ink, inverseDark?.ink),
333
+ ] : []),
301
334
  ];
302
335
  }
@@ -1,15 +1,17 @@
1
- import { FLAVOR_SPEC } from './flavor-spec.generated';
1
+ import {
2
+ FLAVOR_SPEC_VERSION, SPEC_AXES, SPEC_FLAVOR_PACKS, SPEC_FONT_ROLES, SPEC_SCHEMES, SPEC_TIER_IDS, SPEC_TOKEN_TYPES,
3
+ } from './flavor-spec.generated';
2
4
 
3
5
  /**
4
6
  * The design-token vocabulary (design-system plan §3a/§3f). Every list here is READ from the
5
7
  * generated spec, so the contract has one source: iox-ai-guidance `flavor-spec.v1.json`.
6
8
  */
7
9
 
8
- export type DesignTokenType = (typeof FLAVOR_SPEC.tokenTypes)[number];
9
- export const DESIGN_TOKEN_TYPES: ReadonlyArray<DesignTokenType> = FLAVOR_SPEC.tokenTypes;
10
+ export type DesignTokenType = (typeof SPEC_TOKEN_TYPES)[number];
11
+ export const DESIGN_TOKEN_TYPES: ReadonlyArray<DesignTokenType> = SPEC_TOKEN_TYPES;
10
12
 
11
- export type DesignTokenTier = keyof typeof FLAVOR_SPEC.tiers;
12
- export const DESIGN_TOKEN_TIERS = Object.keys(FLAVOR_SPEC.tiers) as ReadonlyArray<DesignTokenTier>;
13
+ export type DesignTokenTier = (typeof SPEC_TIER_IDS)[number];
14
+ export const DESIGN_TOKEN_TIERS: ReadonlyArray<DesignTokenTier> = SPEC_TIER_IDS;
13
15
 
14
16
  /** The spec carries groups per row, not as a list — `design-token.model.spec` pins this list
15
17
  * to exactly the set of groups the spec's rows use. */
@@ -35,12 +37,12 @@ export interface StoredDesignTokenRow extends DesignTokenRow {
35
37
  _id?: string;
36
38
  }
37
39
 
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'];
40
+ export type ShapeId = keyof typeof SPEC_AXES.shapes;
41
+ export type DensityId = keyof typeof SPEC_AXES.densities;
42
+ export type MotionId = keyof typeof SPEC_AXES.motions;
43
+ export type DesignMode = (typeof SPEC_AXES.modes)[number];
44
+ export type SchemeId = keyof typeof SPEC_SCHEMES;
45
+ export type FontRoleToken = (typeof SPEC_FONT_ROLES)[number]['token'];
44
46
 
45
47
  /** Everything a studio chose about its look — the `settings.design` row, typed. */
46
48
  export interface DesignChoices {
@@ -97,9 +99,7 @@ export interface FlavorPackV2 {
97
99
 
98
100
  /** The spec's flavors, typed. Assignment here is the compile-time proof that the generated
99
101
  * data fits `FlavorPackV2`. */
100
- export const SPEC_FLAVORS: ReadonlyArray<FlavorPackV2> = FLAVOR_SPEC.flavors;
101
-
102
- export const FLAVOR_SPEC_VERSION: number = FLAVOR_SPEC.version;
102
+ export const SPEC_FLAVORS: ReadonlyArray<FlavorPackV2> = SPEC_FLAVOR_PACKS;
103
103
 
104
104
  /** The stamp seeded rows carry, so an upgrade can tell the machine's rows from the author's. */
105
105
  export function flavorSourceStamp(flavorId: string, version: number = FLAVOR_SPEC_VERSION): string {
@@ -0,0 +1,271 @@
1
+ import {
2
+ SPEC_BUTTON_VARIANT_TO_STYLE, SPEC_DEFAULT_ELEMENT_STYLE, SPEC_ELEMENT_STYLES, SPEC_PALETTE_PRESETS,
3
+ } from './flavor-spec.generated';
4
+
5
+ /**
6
+ * Element styles — the author's "classes" (design-system plan §3b). A named look is an EXACT trait
7
+ * map written into a node; every value is a `var(--iox-…)` reference or a flavor-independent
8
+ * keyword, so a re-theme rewrites token rows and every node wearing the style follows. No renderer
9
+ * reads `elementStyle`: it records which look a node wears, for the panel row, Reset, adopt and AI.
10
+ *
11
+ * Works on two node shapes through one minimal interface, so the builder (a ComponentNode whose
12
+ * values live in `styleTraits[].traits[].default`) and the server-side composer (a plain serialized
13
+ * layout node with `styleProps`) share every function here.
14
+ */
15
+
16
+ export enum ElementStyleState {
17
+ Hover = 'hover',
18
+ Active = 'active',
19
+ Focus = 'focus',
20
+ }
21
+
22
+ export const ELEMENT_STYLE_STATES: ReadonlyArray<ElementStyleState> =
23
+ [ElementStyleState.Hover, ElementStyleState.Active, ElementStyleState.Focus];
24
+
25
+ export interface ElementStyle {
26
+ readonly id: string;
27
+ readonly label: string;
28
+ readonly group: string;
29
+ readonly appliesTo: ReadonlyArray<string>;
30
+ readonly base: Readonly<Record<string, string>>;
31
+ readonly hover?: Readonly<Record<string, string>>;
32
+ readonly active?: Readonly<Record<string, string>>;
33
+ readonly focus?: Readonly<Record<string, string>>;
34
+ }
35
+
36
+ export const ELEMENT_STYLES: ReadonlyArray<ElementStyle> = SPEC_ELEMENT_STYLES;
37
+
38
+ /** Legacy `Button.variant` → the element style it maps to (adopt, DS7). */
39
+ export const BUTTON_VARIANT_TO_STYLE: Readonly<Record<string, string>> = SPEC_BUTTON_VARIANT_TO_STYLE;
40
+
41
+ export interface PalettePreset {
42
+ readonly label: string;
43
+ readonly type: string;
44
+ readonly elementStyle: string;
45
+ readonly traits?: Readonly<Record<string, string>>;
46
+ readonly styleProps: Readonly<Record<string, string>>;
47
+ }
48
+
49
+ /** Palette entries that are a Container wearing an element style (Card · Panel · Navigation bar). */
50
+ export const PALETTE_PRESETS: ReadonlyArray<PalettePreset> = SPEC_PALETTE_PRESETS;
51
+
52
+ const BY_ID = new Map(ELEMENT_STYLES.map(s => [s.id, s]));
53
+
54
+ export function elementStyleById(id: string | null | undefined): ElementStyle | undefined {
55
+ return id ? BY_ID.get(id) : undefined;
56
+ }
57
+
58
+ /** The styles a node of `type` may wear — the Style panel's select. */
59
+ export function elementStylesFor(type: string): ElementStyle[] {
60
+ return ELEMENT_STYLES.filter(s => s.appliesTo.includes(type));
61
+ }
62
+
63
+ /** The minimal shape both a builder node and a serialized layout node satisfy. */
64
+ export interface ElementStyleTraitLike {
65
+ name: string;
66
+ default?: unknown;
67
+ }
68
+
69
+ export interface ElementStyleTarget {
70
+ type: string;
71
+ elementStyle?: string;
72
+ /** Builder shape: values live in `trait.default`. Wins when present. */
73
+ styleTraits?: ReadonlyArray<{ traits: ElementStyleTraitLike[] }>;
74
+ /** Serialized shape: a flat map. Created on first write when the target has no `styleTraits`. */
75
+ styleProps?: Record<string, unknown>;
76
+ stateStyles?: Partial<Record<string, Record<string, unknown>>>;
77
+ }
78
+
79
+ /** One trait an element style owns: a base trait, or a trait inside a pseudo-state. */
80
+ export interface OwnedTrait {
81
+ trait: string;
82
+ state?: ElementStyleState;
83
+ }
84
+
85
+ export enum OwnedTraitLink {
86
+ /** The node still holds the style's value. */
87
+ Linked = 'linked',
88
+ /** The author replaced it on this node. */
89
+ Changed = 'changed',
90
+ }
91
+
92
+ export interface OwnedTraitStatus extends OwnedTrait {
93
+ expected: string;
94
+ actual: unknown;
95
+ status: OwnedTraitLink;
96
+ }
97
+
98
+ export interface ApplyElementStyleResult {
99
+ applied: boolean;
100
+ /** Base traits the target has no slot for — a builder node silently drops these, so a spec
101
+ * asserts this list is empty for every style × every type it applies to. */
102
+ unknownTraits: string[];
103
+ }
104
+
105
+ export interface SwitchElementStyleResult {
106
+ applied: boolean;
107
+ /** Traits the old style owned whose value the author had changed — kept, never overwritten. */
108
+ kept: OwnedTrait[];
109
+ }
110
+
111
+ const usesTraits = (t: ElementStyleTarget): boolean => Array.isArray(t.styleTraits);
112
+
113
+ function findTrait(target: ElementStyleTarget, name: string): ElementStyleTraitLike | undefined {
114
+ for (const group of target.styleTraits ?? []) {
115
+ const hit = group.traits.find(tr => tr.name === name);
116
+ if (hit) return hit;
117
+ }
118
+ return undefined;
119
+ }
120
+
121
+ function readBase(target: ElementStyleTarget, name: string): unknown {
122
+ return usesTraits(target) ? findTrait(target, name)?.default : target.styleProps?.[name];
123
+ }
124
+
125
+ /** False when a builder node has no such trait — the value would be dropped. */
126
+ function writeBase(target: ElementStyleTarget, name: string, value: unknown): boolean {
127
+ if (usesTraits(target)) {
128
+ const trait = findTrait(target, name);
129
+ if (!trait) return false;
130
+ trait.default = value;
131
+ return true;
132
+ }
133
+ target.styleProps = target.styleProps ?? {};
134
+ if (value === undefined) delete target.styleProps[name];
135
+ else target.styleProps[name] = value;
136
+ return true;
137
+ }
138
+
139
+ function readState(target: ElementStyleTarget, state: ElementStyleState, name: string): unknown {
140
+ return target.stateStyles?.[state]?.[name];
141
+ }
142
+
143
+ function writeState(target: ElementStyleTarget, state: ElementStyleState, name: string, value: string): void {
144
+ target.stateStyles = target.stateStyles ?? {};
145
+ target.stateStyles[state] = { ...(target.stateStyles[state] ?? {}), [name]: value };
146
+ }
147
+
148
+ function deleteState(target: ElementStyleTarget, state: ElementStyleState, name: string): void {
149
+ const map = target.stateStyles?.[state];
150
+ if (!map) return;
151
+ const { [name]: _drop, ...rest } = map;
152
+ if (Object.keys(rest).length) target.stateStyles![state] = rest;
153
+ else delete target.stateStyles![state];
154
+ }
155
+
156
+ /** Every (state, trait, value) a style owns, base first. */
157
+ function ownedEntries(style: ElementStyle): Array<OwnedTrait & { value: string }> {
158
+ const out: Array<OwnedTrait & { value: string }> = [];
159
+ for (const [trait, value] of Object.entries(style.base)) out.push({ trait, value });
160
+ for (const state of ELEMENT_STYLE_STATES) {
161
+ for (const [trait, value] of Object.entries(style[state] ?? {})) out.push({ trait, state, value });
162
+ }
163
+ return out;
164
+ }
165
+
166
+ const sameOwned = (a: OwnedTrait, b: OwnedTrait) => a.trait === b.trait && a.state === b.state;
167
+
168
+ function writeEntries(target: ElementStyleTarget, style: ElementStyle, skip: OwnedTrait[] = []): string[] {
169
+ const unknown: string[] = [];
170
+ for (const entry of ownedEntries(style)) {
171
+ if (skip.some(s => sameOwned(s, entry))) continue;
172
+ if (entry.state) writeState(target, entry.state, entry.trait, entry.value);
173
+ else if (!writeBase(target, entry.trait, entry.value)) unknown.push(entry.trait);
174
+ }
175
+ target.elementStyle = style.id;
176
+ return unknown;
177
+ }
178
+
179
+ /** Write the style's exact base map and its hover/active/focus maps into the target. */
180
+ export function applyElementStyle(target: ElementStyleTarget, id: string): ApplyElementStyleResult {
181
+ const style = elementStyleById(id);
182
+ if (!style) return { applied: false, unknownTraits: [] };
183
+ return { applied: true, unknownTraits: writeEntries(target, style) };
184
+ }
185
+
186
+ /** `Heading[level=h3..h6]` → { type: 'Heading', prop: 'level', values: ['h3','h4','h5','h6'] }. */
187
+ interface DefaultRule {
188
+ type: string;
189
+ prop?: string;
190
+ values?: string[];
191
+ style: string;
192
+ }
193
+
194
+ const RULE_KEY = /^(\w+)(?:\[(\w+)=([^\]]+)\])?$/;
195
+ const RANGE = /^([a-z]*)(\d+)\.\.\1(\d+)$/;
196
+
197
+ function expandValues(raw: string): string[] {
198
+ const range = RANGE.exec(raw);
199
+ if (!range) return [raw];
200
+ const [, prefix, from, to] = range;
201
+ const out: string[] = [];
202
+ for (let n = Number(from); n <= Number(to); n++) out.push(`${prefix}${n}`);
203
+ return out;
204
+ }
205
+
206
+ const DEFAULT_RULES: ReadonlyArray<DefaultRule> = Object.entries(SPEC_DEFAULT_ELEMENT_STYLE).map(([key, style]) => {
207
+ const m = RULE_KEY.exec(key);
208
+ if (!m) throw new Error(`defaultElementStyle: unreadable key "${key}"`);
209
+ const [, type, prop, raw] = m;
210
+ return prop ? { type, prop, values: expandValues(raw), style } : { type, style };
211
+ });
212
+
213
+ /**
214
+ * The element style a NEW node of `type` is born wearing (the spec's `defaultElementStyle`), or null
215
+ * when the type has none. `traits` is the node's own trait values (props) — a Heading's default
216
+ * follows its `level`; a Heading with no level matches no rule.
217
+ */
218
+ export function defaultElementStyleFor(type: string, traits: Readonly<Record<string, unknown>> = {}): string | null {
219
+ for (const rule of DEFAULT_RULES) {
220
+ if (rule.type !== type) continue;
221
+ if (!rule.prop) return rule.style;
222
+ if (rule.values!.includes(String(traits[rule.prop]))) return rule.style;
223
+ }
224
+ return null;
225
+ }
226
+
227
+ /** Per owned trait: does the node still hold the style's value (linked) or the author's (changed)? */
228
+ export function ownedTraitStatus(target: ElementStyleTarget, id: string): OwnedTraitStatus[] {
229
+ const style = elementStyleById(id);
230
+ if (!style) return [];
231
+ return ownedEntries(style).map(({ trait, state, value }) => {
232
+ const actual = state ? readState(target, state, trait) : readBase(target, trait);
233
+ return {
234
+ trait, ...(state ? { state } : {}), expected: value, actual,
235
+ status: actual === value ? OwnedTraitLink.Linked : OwnedTraitLink.Changed,
236
+ };
237
+ });
238
+ }
239
+
240
+ /**
241
+ * Move a node from one style to another, rewriting ONLY what the old style owned:
242
+ * - an owned trait still holding the old style's value is released — a base trait goes back to
243
+ * `baseline[trait]` (the type's unstyled value; deleted when the baseline has none), a state trait
244
+ * is removed from its state map;
245
+ * - an owned trait the author changed is KEPT, and the new style does not overwrite it.
246
+ * Then the new style is written. Traits neither style owns are never touched.
247
+ */
248
+ export function switchElementStyle(
249
+ target: ElementStyleTarget,
250
+ fromId: string | null | undefined,
251
+ toId: string,
252
+ baseline: Readonly<Record<string, unknown>> = {},
253
+ ): SwitchElementStyleResult {
254
+ const to = elementStyleById(toId);
255
+ if (!to) return { applied: false, kept: [] };
256
+ const kept: OwnedTrait[] = [];
257
+ const from = elementStyleById(fromId);
258
+ if (from) {
259
+ for (const status of ownedTraitStatus(target, from.id)) {
260
+ const ref: OwnedTrait = status.state ? { trait: status.trait, state: status.state } : { trait: status.trait };
261
+ if (status.status === OwnedTraitLink.Changed) {
262
+ if (status.actual !== undefined) kept.push(ref);
263
+ continue;
264
+ }
265
+ if (ref.state) deleteState(target, ref.state, ref.trait);
266
+ else writeBase(target, ref.trait, baseline[ref.trait]);
267
+ }
268
+ }
269
+ writeEntries(target, to, kept);
270
+ return { applied: true, kept };
271
+ }