@vectoriox/iox-ui 5.25.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.
@@ -1,4 +1,5 @@
1
1
  import { deriveAccentTokens } from './brand';
2
+ import { flavorColours, FlavorColours, preTintAccentGrounds } from './brand-neutrals';
2
3
  import { fontStackWithout, formatFontFamily } from './fonts';
3
4
  import { SPEC_AXES } from './flavor-spec.generated';
4
5
  import {
@@ -11,7 +12,8 @@ import {
11
12
  *
12
13
  * Layering, each step over the last (the boards' `tokenMap`, with the product's colour math):
13
14
  * pack tokens → chosen font pair → shape · density · motion axes → pill-button rule →
14
- * the author's own font roles → a seed ≠ the pack's own re-derives the accent quartet.
15
+ * the author's own font roles → every COLOUR token from the brand seed and the background tint
16
+ * (`flavorColours`, DS9: tinted neutrals, the accent quartet, the held contrast pairs).
15
17
  *
16
18
  * `mode` does not change rows — every row keeps its light value and its dark override; mode is
17
19
  * how the stylesheet is compiled.
@@ -91,13 +93,40 @@ export function buildFlavorTokens(pack: FlavorPackV2, choices: DesignChoices): D
91
93
  if (typeof family === 'string' && family.trim() !== '') set(role, userFontStack(family, byName.get(role)?.value), USER_TOKEN_SOURCE);
92
94
  }
93
95
 
96
+ setColours(byName, flavorColours(pack, { seed: choices.seed, tint: choices.tint ?? pack.defaults.tint }));
97
+
98
+ return rows;
99
+ }
100
+
101
+ /** Colour rows take their light value and the dark one only where it differs (the contract's own rule). */
102
+ function setColours(byName: Map<string, DesignTokenRow>, colours: FlavorColours): void {
103
+ for (const [name, [light, dark]] of Object.entries(colours)) {
104
+ const row = byName.get(name);
105
+ if (!row) continue;
106
+ row.value = light;
107
+ if (dark !== undefined && dark !== light) row.values = { dark };
108
+ else delete row.values;
109
+ }
110
+ }
111
+
112
+ /**
113
+ * What the contract wrote for `choices` BEFORE DS9 — the baseline a studio that applied its look before
114
+ * the tint existed is judged against (`designBaseline`, `designTokenRepairs`): the look's own palette, the
115
+ * rows DS9 changed at their old values (`pack.legacy`), and a seed ≠ the pack's own re-deriving the accent
116
+ * set against `bg` alone, as `deriveAccentTokens` was called then. No held pairs, no tint.
117
+ */
118
+ export function legacyFlavorTokens(pack: FlavorPackV2, choices: DesignChoices): DesignTokenRow[] {
119
+ const rows = buildFlavorTokens(pack, { ...choices, seed: pack.seed, tint: 'neutral' });
120
+ const byName = new Map(rows.map(r => [r.name, r]));
121
+ for (const [name, old] of Object.entries(pack.legacy ?? {})) {
122
+ const row = byName.get(name);
123
+ if (!row) continue;
124
+ row.value = old.value;
125
+ if (old.dark !== undefined) row.values = { dark: old.dark };
126
+ else delete row.values;
127
+ }
94
128
  if (choices.seed && choices.seed.toLowerCase() !== pack.seed.toLowerCase()) {
95
- const bg = byName.get('bg');
96
- const inverseBg = byName.get('inverse-bg');
97
- for (const accent of deriveAccentTokens(choices.seed, {
98
- bg: bg?.value ?? '', bgDark: bg?.values?.dark,
99
- inverseBg: inverseBg?.value, inverseBgDark: inverseBg?.values?.dark,
100
- })) {
129
+ for (const accent of deriveAccentTokens(choices.seed, preTintAccentGrounds(pack.palette))) {
101
130
  const row = byName.get(accent.name);
102
131
  if (!row) continue;
103
132
  row.value = accent.value;
@@ -105,6 +134,5 @@ export function buildFlavorTokens(pack: FlavorPackV2, choices: DesignChoices): D
105
134
  else delete row.values;
106
135
  }
107
136
  }
108
-
109
137
  return rows;
110
138
  }
@@ -18,6 +18,9 @@ export * from './adopt';
18
18
  export * from './node-classes';
19
19
  export * from './empty-image';
20
20
  export * from './brand';
21
+ export * from './colour-eval';
22
+ export * from './brand-neutrals';
23
+ export * from './contrast-audit';
21
24
  export * from './flavor-tokens';
22
25
  export * from './flavor-seed';
23
26
  export * from './spring';
@@ -121,7 +121,7 @@ export function tokensToDarkVarMap(tokens: ReadonlyArray<DesignTokenLike> | null
121
121
  }
122
122
 
123
123
  const TOKEN_REF = /var\(\s*--iox-([\w-]+)\s*(?:,([^()]*))?\)/g;
124
- const PLACEHOLDER = /\{\{\s*([\w-]+)\s*\}\}/g;
124
+ const PLACEHOLDER = /\{\{\s*(~?)([\w-]+)\s*\}\}/g;
125
125
 
126
126
  /** A compiled colour mode — the contract's modes minus `auto`, which is a compile CHOICE, not values. */
127
127
  export type ResolvedMode = Exclude<DesignMode, 'auto'>;
@@ -163,6 +163,26 @@ export function resolveTokenValues(
163
163
  return out;
164
164
  }
165
165
 
166
+ /**
167
+ * Every token's value INSIDE a section wearing `schemeId`, in one mode — what CSS computes there: the
168
+ * scheme's overrides (`{{token}}` / `{{~token}}` placeholders substituted from the root), every other
169
+ * row as declared, and `var()` references resolved within the section (the §3c re-declaration rule
170
+ * makes that the section's own resolution). `default` (or an unknown id) is the root.
171
+ */
172
+ export function resolveSchemeTokenValues(
173
+ tokens: ReadonlyArray<DesignTokenLike> | null | undefined,
174
+ mode: ResolvedMode,
175
+ schemeId: string,
176
+ ): Record<string, string> {
177
+ const rows = usableTokens(tokens);
178
+ const set = schemeId === DEFAULT_SCHEME ? undefined : (SPEC_SCHEMES as Readonly<Record<string, { set: Readonly<Record<string, string>> }>>)[schemeId]?.set;
179
+ if (!set) return resolveTokenValues(rows, mode);
180
+ const root = resolveTokenValues(rows, mode);
181
+ const other = resolveTokenValues(rows, mode === DARK ? LIGHT : DARK);
182
+ const overrides = new Map(schemeEntries(set, root, other));
183
+ return resolveTokenValues(rows.map(t => ({ name: t.name, value: overrides.get(t.name) ?? modeValue(t, mode) })), LIGHT);
184
+ }
185
+
166
186
  /**
167
187
  * `value` with every `var(--iox-…)` replaced by that token's resolved root value in `mode` — what
168
188
  * the Style panel's Detach writes, so the node keeps exactly what it showed as a literal. An unknown
@@ -178,20 +198,25 @@ export function resolveTokenReferences(
178
198
  resolved[name] ?? (fallback !== undefined ? fallback.trim() : ref));
179
199
  }
180
200
 
181
- /** A scheme value with its `{{token}}` placeholders replaced by resolved root values — or null
182
- * when a placeholder names a token the set does not have (the declaration is then omitted). */
183
- function substitute(template: string, resolved: Readonly<Record<string, string>>): string | null {
201
+ type ResolvedValues = Readonly<Record<string, string>>;
202
+
203
+ /** A scheme value with its placeholders replaced — `{{token}}` by the root's resolved value in this
204
+ * mode, `{{~token}}` by its value in the OTHER mode (an inverse section is the other mode's ground,
205
+ * DS9) — or null when a placeholder names a token the set does not have (the declaration is then
206
+ * omitted). */
207
+ function substitute(template: string, resolved: ResolvedValues, opposite: ResolvedValues): string | null {
184
208
  let missing = false;
185
- const value = template.replace(PLACEHOLDER, (_, name: string) => {
186
- if (!(name in resolved)) missing = true;
187
- return resolved[name] ?? '';
209
+ const value = template.replace(PLACEHOLDER, (_, tilde: string, name: string) => {
210
+ const from = tilde ? opposite : resolved;
211
+ if (!(name in from)) missing = true;
212
+ return from[name] ?? '';
188
213
  });
189
214
  return missing ? null : value;
190
215
  }
191
216
 
192
- const schemeEntries = (set: Readonly<Record<string, string>>, resolved: Readonly<Record<string, string>>) =>
217
+ const schemeEntries = (set: Readonly<Record<string, string>>, resolved: ResolvedValues, opposite: ResolvedValues) =>
193
218
  Object.entries(set)
194
- .map(([name, template]): [string, string | null] => [name, substitute(template, resolved)])
219
+ .map(([name, template]): [string, string | null] => [name, substitute(template, resolved, opposite)])
195
220
  .filter((e): e is [string, string] => e[1] !== null);
196
221
 
197
222
  const hasPlaceholder = (value: string) => value.includes('{{');
@@ -209,12 +234,17 @@ const hasPlaceholder = (value: string) => value.includes('{{');
209
234
  * and CSS makes both invalid. The contract writes `{{accent}}` instead — the token's ROOT value,
210
235
  * resolved here at compile time (and again from dark values for the dark twin).
211
236
  */
212
- export function compileSchemeCss(tokens: ReadonlyArray<DesignTokenLike> | null | undefined, scope?: string): string {
237
+ export function compileSchemeCss(
238
+ tokens: ReadonlyArray<DesignTokenLike> | null | undefined,
239
+ scope?: string,
240
+ opposite?: ResolvedValues,
241
+ ): string {
213
242
  const rows = usableTokens(tokens);
214
243
  if (!rows.length) return '';
215
244
  const resolved = resolveTokenValues(rows, LIGHT);
245
+ const other = opposite ?? resolveTokenValues(rows, DARK);
216
246
  return nonDefaultSchemes().map(([id, set]) => rule(schemeSelector(id, scope), [
217
- ...schemeEntries(set, resolved),
247
+ ...schemeEntries(set, resolved, other),
218
248
  ...rows.filter(t => !(t.name in set) && t.value.includes(REFERENCE))
219
249
  .map((t): [string, string] => [t.name, t.value]),
220
250
  ])).filter(Boolean).join('\n');
@@ -231,9 +261,9 @@ function darkSchemeRules(rows: ReadonlyArray<DesignTokenLike>, scope: string | u
231
261
  });
232
262
  return nonDefaultSchemes().map(([id, set]) => {
233
263
  const swapped = Object.fromEntries(Object.entries(set).filter(([, v]) => hasPlaceholder(v)));
234
- const lightSwap = new Map(schemeEntries(swapped, light));
264
+ const lightSwap = new Map(schemeEntries(swapped, light, dark));
235
265
  return rule(schemeSelector(id, scope), [
236
- ...schemeEntries(swapped, dark).filter(([name, value]) => lightSwap.get(name) !== value),
266
+ ...schemeEntries(swapped, dark, light).filter(([name, value]) => lightSwap.get(name) !== value),
237
267
  ...redeclared.filter(t => !(t.name in set)).map((t): [string, string] => [t.name, darkOf(t) as string]),
238
268
  ], ' ');
239
269
  });
@@ -249,15 +279,18 @@ export function compileTokenCssBlocks(
249
279
  tokens: ReadonlyArray<DesignTokenLike> | null | undefined,
250
280
  opts: TokenCssOptions = {},
251
281
  ): TokenCssBlocks {
252
- const rows = rowsForMode(usableTokens(tokens), opts.mode);
282
+ const all = usableTokens(tokens);
283
+ const rows = rowsForMode(all, opts.mode);
253
284
  const root = opts.scope ?? ROOT;
285
+ // A forced mode flattens the rows to ONE mode; a `{{~token}}` still reads the other mode's value.
286
+ const opposite = resolveTokenValues(all, opts.mode === DARK ? LIGHT : DARK);
254
287
  const present = new Set(rows.map(t => t.name));
255
288
  const unset: Array<[string, string]> = opts.isolate
256
289
  ? [...specTokenNames()].filter(name => !present.has(name)).map((name): [string, string] => [name, 'initial'])
257
290
  : [];
258
291
  const base = [
259
292
  rule(root, [...unset, ...rows.map((t): [string, string] => [t.name, t.value])]),
260
- compileSchemeCss(rows, opts.scope),
293
+ compileSchemeCss(rows, opts.scope, opposite),
261
294
  ...ACCESSIBILITY_MEDIA.map(({ query, overrides }) =>
262
295
  media(query, [rule(root, presentOverrides(rows, overrides, false), ' ')])),
263
296
  ].filter(Boolean).join('\n');
@@ -28,3 +28,19 @@ iox-gallery.iox-es-media[data-iox-empty] > .iox-gallery {
28
28
  .iox-es-media[data-iox-empty] .iox-gallery__overlay {
29
29
  display: none;
30
30
  }
31
+
32
+ /* What a filled button HOLDS reads its ink (DS9, the contract's `inkScopes`, pinned by
33
+ ink-scopes.spec.ts). An Icon dropped into a Primary button wore the `icon` style — `icon-color`
34
+ = accent-text, painted on the accent fill: 1:1 in a Brand section. Inside a button every token a
35
+ child's colour reads becomes `currentColor` — the button's own ink — so the label and the icon read
36
+ alike; an author's literal on the child still wins. */
37
+ .iox-es-button-primary,
38
+ .iox-es-button-secondary,
39
+ .iox-es-button-ghost {
40
+ --iox-icon-color: currentColor;
41
+ --iox-heading-color: currentColor;
42
+ --iox-eyebrow-color: currentColor;
43
+ --iox-link-color: currentColor;
44
+ --iox-link-hover-color: currentColor;
45
+ --iox-text-muted: currentColor;
46
+ }
@@ -101,8 +101,8 @@
101
101
  * A Field wearing the `field` element style renders `iox-es iox-es-field`; everything below is
102
102
  * scoped to it, so a Field saved before DS2 renders exactly as above whatever tokens the org holds,
103
103
  * and seeding an org's tokens never restyles a live legacy form.
104
- * The first six rules ARE the spec's `fieldClassBaseline`, selectors and values verbatim (pinned by
105
- * iox-ui's field-baseline spec). Values are bare spec tokens: a marked Field only exists where the
104
+ * Every rule below IS the spec's `fieldClassBaseline`, selector and values verbatim (pinned by
105
+ * iox-ui's token-names spec). Values are bare spec tokens: a marked Field only exists where the
106
106
  * builder seeded them. */
107
107
  .iox-es-field .iox-field__control {
108
108
  background: var(--iox-field-bg);
@@ -117,7 +117,7 @@
117
117
  transition: border-color var(--iox-dur-base) var(--iox-ease-standard), box-shadow var(--iox-dur-base) var(--iox-ease-standard);
118
118
  }
119
119
  .iox-es-field .iox-field__control:focus-visible {
120
- border-color: var(--iox-accent);
120
+ border-color: var(--iox-accent-text);
121
121
  box-shadow: var(--iox-field-focus);
122
122
  outline: none;
123
123
  }
@@ -138,6 +138,14 @@
138
138
  .iox-es-field.iox-invalid .iox-field__control {
139
139
  border-color: var(--iox-danger);
140
140
  }
141
+ /* DS9 (contrast audit): the placeholder is themed (the UA's #757575 read 1:1 in a Brand section); the
142
+ native controls, the focus edge and the switch read `accent-text` — the accent GATED as text on every
143
+ ground — never the `accent` fill (Pop's orange on its yellow panel: 2.13:1); the off track is
144
+ `text-muted` under a `bg` thumb (the decorative `line` it wore read 1.06:1). WCAG 1.4.11: 3:1. */
145
+ .iox-es-field .iox-field__control::placeholder {
146
+ color: var(--iox-text-muted);
147
+ opacity: 1;
148
+ }
141
149
  /* Native-appearance controls (checkbox, radio, range — and the switch, a checkbox) draw themselves:
142
150
  the box baseline must not frame them. `:where()` keeps this at the baseline's specificity, so the
143
151
  switch rules below still paint the track. */
@@ -145,18 +153,16 @@
145
153
  border: none;
146
154
  padding: 0;
147
155
  background: transparent;
156
+ accent-color: var(--iox-accent-text);
148
157
  }
149
158
  .iox-es-field .iox-field__control--switch {
150
- background: var(--iox-line);
159
+ background: var(--iox-text-muted);
151
160
  transition: background var(--iox-dur-fast) var(--iox-ease-standard);
152
161
  }
153
162
  .iox-es-field .iox-field__control--switch::before {
154
- background: var(--iox-accent-ink);
163
+ background: var(--iox-bg);
155
164
  transition: transform var(--iox-dur-fast) var(--iox-ease-standard);
156
165
  }
157
166
  .iox-es-field .iox-field__control--switch:checked {
158
- background: var(--iox-accent);
159
- }
160
- .iox-es-field .iox-field__control--range {
161
- accent-color: var(--iox-accent);
167
+ background: var(--iox-accent-text);
162
168
  }