@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.
- package/package.json +5 -5
- package/src/audit/constraintAudit.ts +2 -0
- package/src/expo.spec.ts +4 -4
- package/src/expo.ts +11 -1
- package/src/theme/colorRules.ts +87 -0
- package/src/theme/createDefaultThemeConfig.spec.ts +8 -4
- package/src/theme/createDefaultThemeConfig.ts +27 -3
- package/src/theme/createThemes.ts +221 -56
- package/src/theme/dedupeInherited.spec.ts +160 -0
- package/src/theme/dedupeInherited.ts +117 -0
- package/src/theme/defaults/builderOptions.ts +1 -1
- package/src/theme/fillContrast.spec.ts +204 -0
- package/src/theme/fillContrast.ts +163 -0
- package/src/theme/index.ts +2 -0
- package/src/theme/oneThemeEngine.spec.ts +192 -0
- package/src/theme/presets.ts +37 -2
- package/src/theme/selectedState.spec.ts +19 -1
- package/src/theme/switchThumb.spec.ts +25 -14
- package/src/theme/v3TokenRoles.ts +51 -0
- package/types/audit/constraintAudit.d.ts.map +1 -1
- package/types/expo.d.ts.map +1 -1
- package/types/theme/colorRules.d.ts +39 -0
- package/types/theme/colorRules.d.ts.map +1 -1
- package/types/theme/createDefaultThemeConfig.d.ts +13 -2
- package/types/theme/createDefaultThemeConfig.d.ts.map +1 -1
- package/types/theme/createThemes.d.ts.map +1 -1
- package/types/theme/dedupeInherited.d.ts +54 -0
- package/types/theme/dedupeInherited.d.ts.map +1 -0
- package/types/theme/fillContrast.d.ts +41 -0
- package/types/theme/fillContrast.d.ts.map +1 -0
- package/types/theme/index.d.ts +2 -0
- package/types/theme/index.d.ts.map +1 -1
- package/types/theme/presets.d.ts +24 -0
- package/types/theme/presets.d.ts.map +1 -1
- package/types/theme/v3TokenRoles.d.ts +51 -0
- 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.
|
|
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.
|
|
76
|
-
"@multiplatform.one/store": "^7.
|
|
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.
|
|
93
|
-
"@multiplatform.one/test-utils": "7.
|
|
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: {
|
|
69
|
-
/
|
|
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: {
|
|
78
|
+
themes: { light_blue: { background }, dark_blue: { background: "#000" } },
|
|
79
79
|
}),
|
|
80
|
-
).toThrow(/
|
|
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 {
|
|
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({
|
package/src/theme/colorRules.ts
CHANGED
|
@@ -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
|
-
|
|
402
|
-
|
|
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
|
-
|
|
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(
|
|
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
|
-
/**
|
|
61
|
-
|
|
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
|
|
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
|
-
*
|
|
436
|
-
*
|
|
437
|
-
*
|
|
438
|
-
*
|
|
439
|
-
*
|
|
440
|
-
*
|
|
441
|
-
*
|
|
442
|
-
*
|
|
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(
|
|
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
|
|
462
|
-
*
|
|
463
|
-
*
|
|
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
|
-
|
|
469
|
-
|
|
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
|
-
|
|
474
|
-
|
|
475
|
-
const
|
|
476
|
-
|
|
477
|
-
|
|
478
|
-
|
|
479
|
-
|
|
480
|
-
|
|
481
|
-
|
|
482
|
-
|
|
483
|
-
|
|
484
|
-
|
|
485
|
-
|
|
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:
|
|
488
|
-
switchTrackOffHover:
|
|
489
|
-
switchTrackOffPress:
|
|
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
|
|
624
|
-
|
|
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 =
|
|
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
|
|
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
|
|
654
|
-
fillLum
|
|
815
|
+
rootOn === null ||
|
|
816
|
+
fillLum === null ||
|
|
655
817
|
contrastRatio(rootOn, fillLum) >= switchStateContrastRatio;
|
|
656
|
-
const
|
|
657
|
-
|
|
658
|
-
|
|
659
|
-
|
|
660
|
-
|
|
661
|
-
|
|
662
|
-
|
|
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(
|
|
1021
|
+
holdAccentFloor(
|
|
1022
|
+
holdSelectionSurface(neutralizeSemanticEntryText(neutralizeTintText(initialTheme))),
|
|
1023
|
+
),
|
|
859
1024
|
),
|
|
860
1025
|
),
|
|
861
1026
|
),
|