@multiplatform.one/theme 7.69.2 → 7.71.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.
Files changed (36) hide show
  1. package/package.json +5 -5
  2. package/src/audit/constraintAudit.ts +2 -0
  3. package/src/expo.spec.ts +4 -4
  4. package/src/expo.ts +11 -1
  5. package/src/theme/colorRules.ts +87 -0
  6. package/src/theme/createDefaultThemeConfig.spec.ts +8 -4
  7. package/src/theme/createDefaultThemeConfig.ts +27 -3
  8. package/src/theme/createThemes.ts +221 -56
  9. package/src/theme/dedupeInherited.spec.ts +160 -0
  10. package/src/theme/dedupeInherited.ts +117 -0
  11. package/src/theme/defaults/builderOptions.ts +1 -1
  12. package/src/theme/fillContrast.spec.ts +204 -0
  13. package/src/theme/fillContrast.ts +163 -0
  14. package/src/theme/index.ts +2 -0
  15. package/src/theme/oneThemeEngine.spec.ts +192 -0
  16. package/src/theme/presets.ts +37 -2
  17. package/src/theme/selectedState.spec.ts +19 -1
  18. package/src/theme/switchThumb.spec.ts +25 -14
  19. package/src/theme/v3TokenRoles.ts +51 -0
  20. package/types/audit/constraintAudit.d.ts.map +1 -1
  21. package/types/expo.d.ts.map +1 -1
  22. package/types/theme/colorRules.d.ts +39 -0
  23. package/types/theme/colorRules.d.ts.map +1 -1
  24. package/types/theme/createDefaultThemeConfig.d.ts +13 -2
  25. package/types/theme/createDefaultThemeConfig.d.ts.map +1 -1
  26. package/types/theme/createThemes.d.ts.map +1 -1
  27. package/types/theme/dedupeInherited.d.ts +54 -0
  28. package/types/theme/dedupeInherited.d.ts.map +1 -0
  29. package/types/theme/fillContrast.d.ts +41 -0
  30. package/types/theme/fillContrast.d.ts.map +1 -0
  31. package/types/theme/index.d.ts +2 -0
  32. package/types/theme/index.d.ts.map +1 -1
  33. package/types/theme/presets.d.ts +24 -0
  34. package/types/theme/presets.d.ts.map +1 -1
  35. package/types/theme/v3TokenRoles.d.ts +51 -0
  36. package/types/theme/v3TokenRoles.d.ts.map +1 -0
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@multiplatform.one/theme",
3
- "version": "7.69.2",
3
+ "version": "7.71.0",
4
4
  "description": "Tamagui theme system for multiplatform.one",
5
5
  "keywords": [
6
6
  "multiplatform",
@@ -72,8 +72,8 @@
72
72
  "access": "public"
73
73
  },
74
74
  "dependencies": {
75
- "@multiplatform.one/platform": "^7.69.2",
76
- "@multiplatform.one/store": "^7.69.2",
75
+ "@multiplatform.one/platform": "^7.71.0",
76
+ "@multiplatform.one/store": "^7.71.0",
77
77
  "@tamagui/animations-react-native": "2.7.6",
78
78
  "@tamagui/colors": "2.7.6",
79
79
  "@tamagui/config": "2.7.6",
@@ -89,8 +89,8 @@
89
89
  },
90
90
  "devDependencies": {
91
91
  "@expo/prebuild-config": "55.0.18",
92
- "@multiplatform.one/config": "7.69.2",
93
- "@multiplatform.one/test-utils": "7.69.2",
92
+ "@multiplatform.one/config": "7.71.0",
93
+ "@multiplatform.one/test-utils": "7.71.0",
94
94
  "@tamagui/animations-css": "2.7.6",
95
95
  "@tamagui/animations-reanimated": "2.7.6",
96
96
  "@tamagui/font-inter": "2.7.6",
@@ -211,6 +211,7 @@ const VALID_FONT_WEIGHT = new Set(Object.values(FONT_WEIGHT_KNOB));
211
211
  const FONT_FAMILY_KNOB: Record<string, string> = {
212
212
  inter: "sans-serif ($body/$heading)",
213
213
  georgia: "serif",
214
+ "ibm plex mono": "mono",
214
215
  "ui-monospace": "mono",
215
216
  monospace: "mono",
216
217
  "roboto slab": "slab",
@@ -429,6 +430,7 @@ export function runConstraintAudit(rootSelector?: string): AuditReport {
429
430
  const _FONT_FAMILY_KNOB: Record<string, string> = {
430
431
  inter: "sans-serif ($body/$heading)",
431
432
  georgia: "serif",
433
+ "ibm plex mono": "mono",
432
434
  "ui-monospace": "mono",
433
435
  monospace: "mono",
434
436
  "roboto slab": "slab",
package/src/expo.spec.ts CHANGED
@@ -65,8 +65,8 @@ describe("Expo theme colors", () => {
65
65
 
66
66
  it("rejects an unknown preset or missing scheme instead of baking in a fallback", () => {
67
67
  expect(() => createExpoThemeColors({ preset: "typo" })).toThrow(/preset.*typo/i);
68
- expect(() => createExpoThemeColors({ themes: { light: { background: "#fff" } } })).toThrow(
69
- /dark.*background/i,
68
+ expect(() => createExpoThemeColors({ themes: { light_blue: { background: "#fff" } } })).toThrow(
69
+ /dark_blue.*background/i,
70
70
  );
71
71
  });
72
72
 
@@ -75,9 +75,9 @@ describe("Expo theme colors", () => {
75
75
  (background) => {
76
76
  expect(() =>
77
77
  createExpoThemeColors({
78
- themes: { light: { background }, dark: { background: "#000" } },
78
+ themes: { light_blue: { background }, dark_blue: { background: "#000" } },
79
79
  }),
80
- ).toThrow(/light.*background/i);
80
+ ).toThrow(/light_blue.*background/i);
81
81
  },
82
82
  );
83
83
  });
package/src/expo.ts CHANGED
@@ -3,7 +3,15 @@ import { createThemesBuilder } from "./theme/createThemes";
3
3
  import { defaultAccentTheme } from "./theme/defaults/accent";
4
4
  import { defaultBaseTheme } from "./theme/defaults/base";
5
5
  import { defaultBuilderOptions } from "./theme/defaults/builderOptions";
6
- import { boldPreset, defaultPreset, densePreset, heroPreset, roomyPreset } from "./theme/presets";
6
+ import {
7
+ boldPreset,
8
+ consolePreset,
9
+ defaultPreset,
10
+ densePreset,
11
+ heroPreset,
12
+ roomyPreset,
13
+ storefrontPreset,
14
+ } from "./theme/presets";
7
15
 
8
16
  export { createThemesBuilder, defaultBaseTheme, defaultAccentTheme, defaultBuilderOptions };
9
17
 
@@ -20,6 +28,8 @@ const builtinPresets = {
20
28
  dense: densePreset,
21
29
  hero: heroPreset,
22
30
  roomy: roomyPreset,
31
+ console: consolePreset,
32
+ storefront: storefrontPreset,
23
33
  };
24
34
 
25
35
  export function createExpoThemeColors({
@@ -72,6 +72,93 @@ export function contrastRatio(l1: number, l2: number): number {
72
72
  return (lighter + 0.05) / (darker + 0.05);
73
73
  }
74
74
 
75
+ function apcaY(hex: string): number {
76
+ const [r, g, b] = [1, 3, 5].map((offset) => parseChannel(hex, offset));
77
+ const y = 0.2126729 * r ** 2.4 + 0.7151522 * g ** 2.4 + 0.072175 * b ** 2.4;
78
+ return y < 0.022 ? y + (0.022 - y) ** 1.414 : y;
79
+ }
80
+
81
+ /**
82
+ * APCA lightness contrast (SAPC 0.0.98G-4g) of `text` on `background`, as
83
+ * signed Lc: positive for dark text on a light fill, negative for light on
84
+ * dark. Callers compare the magnitude.
85
+ */
86
+ export function apcaContrast(text: string, background: string): number {
87
+ const textHex = normalizeToHex(text);
88
+ const backgroundHex = normalizeToHex(background);
89
+ if (!textHex || !backgroundHex) return 0;
90
+ const yText = apcaY(textHex);
91
+ const yBackground = apcaY(backgroundHex);
92
+ if (Math.abs(yBackground - yText) < 0.0005) return 0;
93
+ if (yBackground > yText) {
94
+ const sapc = (yBackground ** 0.56 - yText ** 0.57) * 1.14;
95
+ return sapc < 0.1 ? 0 : (sapc - 0.027) * 100;
96
+ }
97
+ const sapc = (yBackground ** 0.65 - yText ** 0.62) * 1.14;
98
+ return sapc > -0.1 ? 0 : (sapc + 0.027) * 100;
99
+ }
100
+
101
+ /** APCA |Lc| a label on a filled control must reach (LC-172). */
102
+ export const fillLabelLc = 60;
103
+
104
+ /**
105
+ * LC-172 / Apple HIG: the off Switch track is a system fill, rgb(120,120,128)
106
+ * laid over the ground at 0.16 alpha on a light ground and 0.32 on a dark one
107
+ * (what UISwitch paints). A ramp step may land anywhere from tertiary to
108
+ * systemFill on either side of that, the band below.
109
+ */
110
+ export const switchOffFillAlpha = { light: 0.16, dark: 0.32 } as const;
111
+ export const switchOffFillBand = { light: [0.08, 0.24], dark: [0.18, 0.4] } as const;
112
+
113
+ /** A ground is light when its relative luminance is past the midpoint. */
114
+ export function isLightGround(ground: string): boolean {
115
+ const hex = normalizeToHex(ground);
116
+ return hex !== null && relativeLuminance(hex) > 0.5;
117
+ }
118
+
119
+ /** HIG's system fill at `alpha` composited over `ground`, as an opaque hex colour. */
120
+ export function systemFillHex(ground: string, alpha: number): string | null {
121
+ const hex = normalizeToHex(ground);
122
+ if (!hex) return null;
123
+ const mixed = [120, 120, 128]
124
+ .map((fill, index) => {
125
+ const under = Number.parseInt(hex.slice(1 + index * 2, 3 + index * 2), 16);
126
+ return Math.round(fill * alpha + under * (1 - alpha))
127
+ .toString(16)
128
+ .padStart(2, "0");
129
+ })
130
+ .join("");
131
+ return `#${mixed}`;
132
+ }
133
+
134
+ /** HIG's system fill at `alpha` composited over `ground`, as relative luminance. */
135
+ export function systemFillLuminance(ground: string, alpha: number): number | null {
136
+ const hex = systemFillHex(ground, alpha);
137
+ return hex ? relativeLuminance(hex) : null;
138
+ }
139
+
140
+ /**
141
+ * FZ-7944: a control's plate stays this far off the raised ground an overlay
142
+ * paints ($color4 in dark), so it never dissolves into a sheet or card.
143
+ */
144
+ export const plateContrastRatio = 1.1;
145
+
146
+ /**
147
+ * Whether `track` sits in the HIG system-fill band over `ground`: never
148
+ * darker than systemFill on a light ground (a dark off track reads as on),
149
+ * and on a dark ground lifted off it without passing systemFill.
150
+ */
151
+ export function inSwitchOffBand(track: string, ground: string): boolean {
152
+ const hex = normalizeToHex(track);
153
+ if (!hex) return false;
154
+ const [low, high] = switchOffFillBand[isLightGround(ground) ? "light" : "dark"];
155
+ const a = systemFillLuminance(ground, low);
156
+ const b = systemFillLuminance(ground, high);
157
+ if (a === null || b === null) return false;
158
+ const lum = relativeLuminance(hex);
159
+ return lum >= Math.min(a, b) && lum <= Math.max(a, b);
160
+ }
161
+
75
162
  export const minContrastRatio = 3;
76
163
 
77
164
  /** WCAG AA contrast floor for normal-size body/label text. */
@@ -37,6 +37,7 @@ import { boldPreset, densePreset, heroPreset, roomyPreset } from "./presets";
37
37
  import { createThemesBuilder, type GetThemeProps } from "./createThemes";
38
38
  import { defaultBaseTheme, defaultAccentTheme } from "./defaults";
39
39
  import { setSizeRecipeInputs } from "./sizeRecipes";
40
+ import { resolveThemeThroughParents } from "./dedupeInherited";
40
41
 
41
42
  // Mirrors the shc app theme config: a consumer getTheme written against the
42
43
  // FULL base ramp (theme.colorN) with no outlineColor of its own. Inside
@@ -398,8 +399,9 @@ describe("the v5 theme matrix", () => {
398
399
  for (const scheme of ["light", "dark"] as const) {
399
400
  expect(themes[`${scheme}_gray`].color3).toBe(stockThemes[`${scheme}_gray`].color3);
400
401
  for (const suffix of ["active", "alt1", "alt2"]) {
401
- const child = themes[`${scheme}_gray_${suffix}`];
402
- expect(child).toBeDefined();
402
+ expect(themes[`${scheme}_gray_${suffix}`]).toBeDefined();
403
+ // The shipped record is deduped (LC-160); a child reads through its parents.
404
+ const child = resolveThemeThroughParents(themes, `${scheme}_gray_${suffix}`);
403
405
  for (const key of ["color", "background", "borderColor"]) {
404
406
  expect(child[key]).toMatch(/, 1\)$/);
405
407
  }
@@ -448,10 +450,12 @@ describe("the v5 theme matrix", () => {
448
450
 
449
451
  // FZ-7715 added one accent solid per hue and scheme (16 themes) so
450
452
  // `theme="accent"` follows a picked colour; the budget moved 260 -> 280 KB.
451
- it("ships 478 themes and the heading-scope category fonts in under 282 KB of CSS (the v4 matrix and stock spread shipped 913 KB)", async () => {
453
+ // FZ-7970 (LC-160) dedupes values another rule already puts on the element:
454
+ // 280,833 -> 263,241 bytes, and the budget ratchets down to 264 KB.
455
+ it("ships 478 themes and the heading-scope category fonts in under 264 KB of CSS (the v4 matrix and stock spread shipped 913 KB)", async () => {
452
456
  const { createTamagui: build } = await vi.importActual<typeof import("tamagui")>("tamagui");
453
457
  const css = build(config.tamagui).getCSS();
454
458
  expect(Object.keys(themes)).toHaveLength(478);
455
- expect(Buffer.byteLength(css, "utf8")).toBeLessThan(282_000);
459
+ expect(Buffer.byteLength(css, "utf8")).toBeLessThan(264_000);
456
460
  });
457
461
  });
@@ -22,6 +22,7 @@ import { type ThemeConfig, createThemeConfig } from "./shared";
22
22
  import { assertResolvableThemeValues } from "./themeValue";
23
23
  import { getGeneratedSizeRecipes, getSizeRecipeInputs, setSizeRecipeInputs } from "./sizeRecipes";
24
24
  import { type ThemeSubset, subsetThemes } from "./subsetThemes";
25
+ import { dedupeInheritedThemeValues } from "./dedupeInherited";
25
26
 
26
27
  /**
27
28
  * The Tamagui era this theme config resolves against (MPO-19 X13; rendered
@@ -57,8 +58,12 @@ export interface CreateDefaultThemeConfigOptions {
57
58
  builderOptions?: CreateThemesBuilderOptions;
58
59
  /** Override tamagui settings */
59
60
  settings?: Partial<NonNullable<CreateTamaguiProps["settings"]>>;
60
- /** Override any top-level tamagui config property */
61
- tamagui?: Partial<CreateTamaguiProps>;
61
+ /**
62
+ * Override any top-level tamagui config property except `themes` (LC-160:
63
+ * an app adds tokens through `builderOptions`, `additionalThemes` and
64
+ * presets, never prebuilt themes).
65
+ */
66
+ tamagui?: Partial<Omit<CreateTamaguiProps, "themes">>;
62
67
  /** Named theme presets for the preset system */
63
68
  presets?: Record<string, Preset>;
64
69
  /** Default preset name to use when no `mp.preset` cookie is set */
@@ -111,6 +116,23 @@ function composeGetTheme(
111
116
  };
112
117
  }
113
118
 
119
+ /**
120
+ * LC-160 ONE-THEME-ENGINE. Every theme an app paints comes out of this
121
+ * builder, so the holds (accent floor, selected state, named ink, hairline)
122
+ * reach it. A prebuilt `tamagui.themes` skips all of them, which is how the
123
+ * SHC console drifted onto its own matrix.
124
+ */
125
+ export function assertNoPrebuiltThemes(options: CreateDefaultThemeConfigOptions): void {
126
+ const tamagui = options.tamagui as { themes?: unknown } | undefined;
127
+ if (tamagui && "themes" in tamagui && tamagui.themes !== undefined) {
128
+ throw new Error(
129
+ "createDefaultThemeConfig: `tamagui.themes` is not accepted (LC-160 ONE-THEME-ENGINE). " +
130
+ "Add tokens through `builderOptions.getTheme`, palettes through `additionalThemes`, " +
131
+ "looks through `presets`, and narrow the matrix with `subset`.",
132
+ );
133
+ }
134
+ }
135
+
114
136
  /**
115
137
  * Builds Tamagui's v5 grey ramp, inverted-base accent and colour themes,
116
138
  * with the house accessibility floors. Fonts include Inter heading/body
@@ -124,6 +146,7 @@ function composeGetTheme(
124
146
  export function createDefaultThemeConfig(
125
147
  options: CreateDefaultThemeConfigOptions = {},
126
148
  ): ThemeConfig {
149
+ assertNoPrebuiltThemes(options);
127
150
  setSizeRecipeInputs(options.recipeInputs ?? {});
128
151
  const scale = generateScale(defaultScaleInputs);
129
152
  const base = options.base ?? defaultBaseTheme;
@@ -146,7 +169,8 @@ export function createDefaultThemeConfig(
146
169
  // theming. This optimization breaks builds served outside Tamagui's SSR
147
170
  // pipeline (e.g. the Frappe SPA) where runtime JS themes are required.
148
171
  // Always keep themes to ensure TamaguiProvider has valid theme data.
149
- const themes = options.subset ? subsetThemes(builtThemes, options.subset) : builtThemes;
172
+ const reachable = options.subset ? subsetThemes(builtThemes, options.subset) : builtThemes;
173
+ const themes = dedupeInheritedThemeValues(reachable);
150
174
  const fonts = withDerivedHeadingCategories({
151
175
  ...defaultConfig.fonts,
152
176
  ...defaultFonts,
@@ -6,12 +6,20 @@ import {
6
6
  import { defaultChildrenThemes, v5Templates } from "@tamagui/themes/v5";
7
7
  import {
8
8
  aaTextContrastRatio,
9
+ apcaContrast,
9
10
  contrastRatio,
11
+ fillLabelLc,
10
12
  holdInkFloor,
13
+ inSwitchOffBand,
14
+ isLightGround,
11
15
  minContrastRatio,
12
16
  normalizeToHex,
13
17
  pickReadableForeground,
18
+ plateContrastRatio,
14
19
  relativeLuminance,
20
+ switchOffFillAlpha,
21
+ systemFillHex,
22
+ systemFillLuminance,
15
23
  } from "./colorRules";
16
24
  import { pickSecondaryInk } from "./defaults/builderOptions";
17
25
  import { themeRampHues } from "./ramps";
@@ -350,6 +358,54 @@ function holdAccentFloor(
350
358
  return out;
351
359
  }
352
360
 
361
+ /**
362
+ * LC-05 ONE-EMPHASIS. A selected list or sidebar row paints the scope's
363
+ * `$color4`, and it must separate from the page it sits on. Radix's light
364
+ * blue, green, purple and yellow step 4 measure 1.10 to 1.14:1 on their own
365
+ * step 2 page, under LC-05's 1.15 floor, so the house blue preset's selected
366
+ * row did not read as selected. Walk each scope's step 4 toward step 5, then
367
+ * step 6, in twentieths until it clears the floor, and repaint every child
368
+ * theme that carries the same step 4. A scope that already holds stays.
369
+ * A `$borderColor` on that step moves with it, or it falls off the ramp and
370
+ * LC-148's hairline is no longer one step fainter (hairlineToken.spec).
371
+ */
372
+ const selectionSurfaceContrastRatio = 1.16;
373
+ const selectionSurfaceScope = /^(light|dark)(_[a-z]+)?$/;
374
+
375
+ function holdSelectionSurface(
376
+ themes: Record<string, Record<string, string>>,
377
+ ): Record<string, Record<string, string>> {
378
+ const out: Record<string, Record<string, string>> = { ...themes };
379
+ for (const [scopeName, scope] of Object.entries(themes)) {
380
+ if (!selectionSurfaceScope.test(scopeName) || /_(accent|active)$/.test(scopeName)) continue;
381
+ const page = luminanceOf(scope.background);
382
+ const [surface, next, last] = [scope.color4, scope.color5, scope.color6].map((color) =>
383
+ color ? normalizeToHex(color) : null,
384
+ );
385
+ if (page === null || !surface || !next || !last) continue;
386
+ if (contrastRatio(relativeLuminance(surface), page) >= selectionSurfaceContrastRatio) continue;
387
+ const candidates = [
388
+ ...Array.from({ length: 20 }, (_, index) => mixHex(surface, next, (index + 1) / 20)),
389
+ ...Array.from({ length: 20 }, (_, index) => mixHex(next, last, (index + 1) / 20)),
390
+ ];
391
+ const held = candidates.find(
392
+ (candidate) =>
393
+ contrastRatio(relativeLuminance(candidate), page) >= selectionSurfaceContrastRatio,
394
+ );
395
+ if (!held) continue;
396
+ for (const [name, theme] of Object.entries(themes)) {
397
+ if (name !== scopeName && !name.startsWith(`${scopeName}_`)) continue;
398
+ if (theme.color4 !== scope.color4) continue;
399
+ out[name] = {
400
+ ...out[name],
401
+ color4: held,
402
+ ...(theme.borderColor === scope.color4 ? { borderColor: held } : null),
403
+ };
404
+ }
405
+ }
406
+ return out;
407
+ }
408
+
353
409
  /**
354
410
  * LC-131 SELECTED-STATE. A selected choice part paints a soft accent tint with
355
411
  * accent ink. The tint is the accent step nearest the scheme's neutral
@@ -432,18 +488,40 @@ function selectedPaint(
432
488
  }
433
489
 
434
490
  /**
435
- * FZ-7158 WP-b2 / DF-06. The off Switch track is the first grey step that
436
- * holds 3:1 under the white thumb and 1.5:1 from the accent on track, and
437
- * 3:1 on the page and surface1 when a step can hold all four. Dark's accent
438
- * fill sits between gray9 and gray10, so dark falls back to gray7 and the
439
- * thumb carries the page contrast. Hover is one step toward the page, press
440
- * one step away. Runs after holdAccentFloor, which settles the on track.
441
- * Only the scheme roots carry the keys and every child inherits them: one
442
- * copy per theme overruns the CSS budget in createDefaultThemeConfig.spec.ts.
491
+ * LC-172 FILL-CONTRAST / Apple HIG. The off Switch track is a light system
492
+ * fill, never a dark grey that reads as on beside the accent on track: the
493
+ * grey ramp step nearest HIG's rgba(120,120,128) over the page, 0.16 alpha in
494
+ * light and 0.32 in dark, inside the system-fill band (`inSwitchOffBand`).
495
+ * Among in-band steps the nearest that stays 1.5:1 apart from the on track
496
+ * wins. In dark a Switch also sits on raised cells, sheets and cards
497
+ * ($color4), where FZ-7944 holds its plate 1.1:1 off that ground; no grey
498
+ * step clears it on every hue's raised ground, so dark also weighs HIG's
499
+ * system fill composited over the raised ground, as UISwitch paints it on a
500
+ * grouped cell. The white thumb carries its own edge with `thumbShadow`, as UISwitch's
501
+ * does. Hover is one step toward the page, press one step away. Runs after
502
+ * holdAccentFloor, which settles the on track. Only the scheme roots carry the
503
+ * keys and every child inherits them: one copy per theme overruns the CSS
504
+ * budget in createDefaultThemeConfig.spec.ts.
443
505
  */
444
- const switchOffSteps = [10, 9, 8, 7, 6, 5] as const;
445
506
  const switchStateContrastRatio = 1.5;
446
507
 
508
+ const switchRaisedAlphas = [0.16, 0.2, 0.24, 0.28, 0.32, 0.36, 0.4];
509
+ const switchRaisedAlphaStep = 0.04;
510
+
511
+ /** Dark's raised ground under a Switch: `$color4`, the elevated surface knob (FZ-7944). */
512
+ function raisedGrounds(
513
+ themes: Record<string, Record<string, string>>,
514
+ scheme: "light" | "dark",
515
+ ): string[] {
516
+ if (scheme === "light") return [];
517
+ const hues = Object.entries(themes)
518
+ .filter(([name]) => new RegExp(`^${scheme}_[a-z]+$`).test(name))
519
+ .filter(([name]) => !/_(accent|active)$/.test(name));
520
+ return [themes[scheme]?.color4, ...hues.map(([, theme]) => theme.color4)].filter(
521
+ (color): color is string => typeof color === "string",
522
+ );
523
+ }
524
+
447
525
  function holdSwitchTrack(
448
526
  themes: Record<string, Record<string, string>>,
449
527
  ): Record<string, Record<string, string>> {
@@ -451,42 +529,81 @@ function holdSwitchTrack(
451
529
  for (const scheme of ["light", "dark"] as const) {
452
530
  const base = themes[scheme];
453
531
  if (!base) continue;
454
- const paint = switchTrackPaint(base, base.accentBackground, [base.background, base.color3]);
532
+ const paint = switchTrackPaint(
533
+ base,
534
+ base.accentBackground,
535
+ base.background,
536
+ raisedGrounds(themes, scheme),
537
+ );
455
538
  if (paint) out[scheme] = { ...base, ...paint };
456
539
  }
457
540
  return out;
458
541
  }
459
542
 
543
+ interface SwitchTrackCandidate {
544
+ color: string;
545
+ lum: number;
546
+ hover: string;
547
+ press: string;
548
+ }
549
+
460
550
  /**
461
- * The off track for one on-fill: the first grey step in `steps` that holds
462
- * under the thumb and apart from `onFill`, on the page too when one can.
463
- * Hover is one step toward the page and press one away, clamped to the ramp.
551
+ * The off track for one on-fill over `ground`: the in-band candidate nearest
552
+ * HIG's system fill that holds 1.5:1 from `onFill`, else the nearest in-band
553
+ * one, else the nearest grey step outright when the ramp skips the band.
554
+ * Candidates are the grey steps and, over `raised[0]`, the system fill at each
555
+ * alpha; with `raised` grounds every in-band candidate must also hold the
556
+ * plate floor on each of them.
464
557
  */
465
558
  function switchTrackPaint(
466
559
  base: Record<string, string>,
467
560
  onFill: string | undefined,
468
- pageSurfaces: (string | undefined)[],
469
- steps: readonly number[] = switchOffSteps,
561
+ ground: string | undefined,
562
+ raised: string[] = [],
470
563
  ): Record<string, string> | undefined {
471
- const thumb = luminanceOf(base.switchThumb ?? "#ffffff");
472
564
  const on = luminanceOf(onFill);
473
- const surfaces = pageSurfaces.map(luminanceOf);
474
- if (thumb === null || on === null || surfaces.some((lum) => lum === null)) return undefined;
475
- const holds = (step: number, onPage: boolean) => {
476
- const lum = luminanceOf(base[`gray${step}`]);
477
- return (
478
- lum !== null &&
479
- contrastRatio(lum, thumb) >= minContrastRatio &&
480
- contrastRatio(lum, on) >= switchStateContrastRatio &&
481
- (!onPage || surfaces.every((surface) => contrastRatio(lum, surface!) >= minContrastRatio))
482
- );
483
- };
484
- const off = steps.find((step) => holds(step, true)) ?? steps.find((step) => holds(step, false));
485
- if (off === undefined) return undefined;
565
+ if (!ground || on === null) return undefined;
566
+ const alpha = switchOffFillAlpha[isLightGround(ground) ? "light" : "dark"];
567
+ const target = systemFillLuminance(ground, alpha);
568
+ if (target === null) return undefined;
569
+ const raisedLums = raised
570
+ .map((color) => luminanceOf(color))
571
+ .filter((lum): lum is number => lum !== null);
572
+ const greys: SwitchTrackCandidate[] = Array.from({ length: 12 }, (_, index) => index + 1)
573
+ .map((step) => ({
574
+ color: base[`gray${step}`],
575
+ lum: luminanceOf(base[`gray${step}`]),
576
+ hover: base[`gray${Math.max(step - 1, 1)}`],
577
+ press: base[`gray${Math.min(step + 1, 12)}`],
578
+ }))
579
+ .filter((entry): entry is SwitchTrackCandidate => entry.lum !== null);
580
+ const fills: SwitchTrackCandidate[] = raised[0]
581
+ ? switchRaisedAlphas.flatMap((fillAlpha) => {
582
+ const color = systemFillHex(raised[0], fillAlpha);
583
+ const hover = systemFillHex(raised[0], fillAlpha - switchRaisedAlphaStep);
584
+ const press = systemFillHex(raised[0], fillAlpha + switchRaisedAlphaStep);
585
+ const lum = luminanceOf(color ?? undefined);
586
+ return color && hover && press && lum !== null ? [{ color, lum, hover, press }] : [];
587
+ })
588
+ : [];
589
+ const byTarget = (a: SwitchTrackCandidate, b: SwitchTrackCandidate) =>
590
+ Math.abs(a.lum - target) - Math.abs(b.lum - target);
591
+ const steps = [...greys].sort(byTarget);
592
+ const inBand = [...greys, ...fills]
593
+ .filter((entry) => inSwitchOffBand(entry.color, ground))
594
+ .filter((entry) =>
595
+ raisedLums.every((lum) => contrastRatio(entry.lum, lum) >= plateContrastRatio),
596
+ )
597
+ .sort(byTarget);
598
+ const pick =
599
+ inBand.find((entry) => contrastRatio(entry.lum, on) >= switchStateContrastRatio) ??
600
+ inBand[0] ??
601
+ steps[0];
602
+ if (!pick) return undefined;
486
603
  return {
487
- switchTrackOff: base[`gray${off}`],
488
- switchTrackOffHover: base[`gray${off - 1}`],
489
- switchTrackOffPress: base[`gray${Math.min(off + 1, 12)}`],
604
+ switchTrackOff: pick.color,
605
+ switchTrackOffHover: pick.hover,
606
+ switchTrackOffPress: pick.press,
490
607
  };
491
608
  }
492
609
 
@@ -595,6 +712,49 @@ function walkToHoldingInk(
595
712
  return null;
596
713
  }
597
714
 
715
+ /**
716
+ * LC-172 FILL-CONTRAST. The step of a hue's ramp its accent solid fills with,
717
+ * and the label on it: the step nearest in luminance to `preferred` where a
718
+ * label clears both WCAG AA 4.5 and APCA Lc 60, held 3:1 on the page. `labels`
719
+ * runs lightest first, so a step both labels clear takes the white one.
720
+ * WCAG alone put a near-black label on the step-9 blue, red, green, orange and
721
+ * pink (APCA Lc 37-48): the "grey box with black letters" Clay rated down.
722
+ */
723
+ function holdFillLabel(
724
+ preferred: string,
725
+ steps: (string | undefined)[],
726
+ labels: string[],
727
+ paper: number,
728
+ ): { fill: string; label: string } | undefined {
729
+ const preferredLum = luminanceOf(preferred);
730
+ if (preferredLum === null) return undefined;
731
+ const labelOn = (step: string) =>
732
+ labels.find((label) => {
733
+ const lum = luminanceOf(label);
734
+ const stepLum = luminanceOf(step);
735
+ return (
736
+ lum !== null &&
737
+ stepLum !== null &&
738
+ contrastRatio(lum, stepLum) >= aaTextContrastRatio &&
739
+ Math.abs(apcaContrast(label, step)) >= fillLabelLc
740
+ );
741
+ });
742
+ let best: { fill: string; label: string } | undefined;
743
+ let bestDistance = Number.POSITIVE_INFINITY;
744
+ for (const step of [preferred, ...steps]) {
745
+ const lum = luminanceOf(step);
746
+ if (step === undefined || lum === null || contrastRatio(lum, paper) < minContrastRatio)
747
+ continue;
748
+ const label = labelOn(step);
749
+ const distance = Math.abs(lum - preferredLum);
750
+ if (label && distance < bestDistance) {
751
+ bestDistance = distance;
752
+ best = { fill: step, label };
753
+ }
754
+ }
755
+ return best;
756
+ }
757
+
598
758
  /**
599
759
  * FZ-7715. A colour theme is the accent. The house accent is the neutral
600
760
  * inverse, and the hue sub-themes (light_green, dark_pink, ...) inherited it,
@@ -620,46 +780,49 @@ function holdColorThemeAccent(
620
780
  const ink = luminanceOf(scope.color12);
621
781
  const surfaces = [scope.color1, scope.color2, scope.color3, scope.color4].map(luminanceOf);
622
782
  if (paper === null || ink === null || surfaces.some((lum) => lum === null)) continue;
623
- const fill = nearestHoldingStep(
624
- ramp[8],
625
- ramp.slice(0, 11),
626
- (lum) =>
627
- Math.max(contrastRatio(lum, paper), contrastRatio(lum, ink)) >= aaTextContrastRatio &&
628
- contrastRatio(lum, paper) >= minContrastRatio,
783
+ const labels = [scope.color1, scope.color12].sort(
784
+ (a, b) => (luminanceOf(b) as number) - (luminanceOf(a) as number),
629
785
  );
786
+ const solid = holdFillLabel(ramp[8], ramp.slice(0, 11), labels, paper);
630
787
  const holdsInk = (lum: number) =>
631
788
  surfaces.every((surface) => contrastRatio(lum, surface as number) >= aaTextContrastRatio);
789
+ const fill =
790
+ solid?.fill ??
791
+ nearestHoldingStep(
792
+ ramp[8],
793
+ ramp.slice(0, 11),
794
+ (lum) =>
795
+ Math.max(contrastRatio(lum, paper), contrastRatio(lum, ink)) >= aaTextContrastRatio &&
796
+ contrastRatio(lum, paper) >= minContrastRatio,
797
+ );
632
798
  const accentInk =
633
799
  walkToHoldingInk(ramp[10], ramp[11], holdsInk) ??
634
800
  nearestHoldingStep(ramp[10], ramp, holdsInk);
635
801
  const fillStep = ramp.indexOf(fill);
636
802
  const fillHover = ramp[Math.min(fillStep + 1, ramp.length - 1)];
637
- const onFill = holdInkFloor(fill, pickReadableForeground(fill, scope.color1, scope.color12));
803
+ const onFill =
804
+ solid?.label ??
805
+ holdInkFloor(fill, pickReadableForeground(fill, scope.color1, scope.color12));
638
806
  const accent: Record<string, string> = {
639
807
  accentBackground: fill,
640
808
  accentColor: accentInk,
641
809
  ...selectedPaint(scope, ramp, accentInk),
642
810
  };
643
- // A hue fill can sit on the root's off grey (light pink on gray10): walk
644
- // outward from the root's step to the nearest one that holds again.
645
- const rootOff = Number(
646
- Object.keys(base)
647
- .find((key) => /^gray\d+$/.test(key) && base[key] === base.switchTrackOff)
648
- ?.slice(4),
649
- );
811
+ // A hue fill can sit on the root's off grey: repaint inside the band.
650
812
  const rootOn = luminanceOf(base.switchTrackOff);
651
813
  const fillLum = luminanceOf(fill);
652
814
  const rootHolds =
653
- rootOn !== null &&
654
- fillLum !== null &&
815
+ rootOn === null ||
816
+ fillLum === null ||
655
817
  contrastRatio(rootOn, fillLum) >= switchStateContrastRatio;
656
- const outward = Array.from({ length: 10 }, (_, index) => index + 3).sort(
657
- (a, b) => Math.abs(a - rootOff) - Math.abs(b - rootOff) || b - a,
658
- );
659
- const track =
660
- rootHolds || Number.isNaN(rootOff)
661
- ? undefined
662
- : switchTrackPaint(base, fill, [scope.background, scope.color3], outward);
818
+ const track = rootHolds
819
+ ? undefined
820
+ : switchTrackPaint(
821
+ base,
822
+ fill,
823
+ scope.background,
824
+ isLightGround(scope.background) ? [] : [scope.color4],
825
+ );
663
826
  for (const [name, theme] of Object.entries(themes)) {
664
827
  if (name === scopeName) {
665
828
  out[name] = { ...theme, ...accent, ...track };
@@ -855,7 +1018,9 @@ export function createThemesBuilder(
855
1018
  holdSyntaxFloor(
856
1019
  holdNamedInkFloor(
857
1020
  holdSelectedState(
858
- holdAccentFloor(neutralizeSemanticEntryText(neutralizeTintText(initialTheme))),
1021
+ holdAccentFloor(
1022
+ holdSelectionSurface(neutralizeSemanticEntryText(neutralizeTintText(initialTheme))),
1023
+ ),
859
1024
  ),
860
1025
  ),
861
1026
  ),