react-native-terra-ui 0.7.2 → 0.8.1
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/CHANGELOG.md +34 -0
- package/README.md +1 -1
- package/lib/module/components/avatar/Avatar.js +20 -20
- package/lib/module/components/avatar/Avatar.js.map +1 -1
- package/lib/module/components/button/Button.js +11 -11
- package/lib/module/components/button/Button.js.map +1 -1
- package/lib/module/components/chip/Chip.js +32 -32
- package/lib/module/components/chip/Chip.js.map +1 -1
- package/lib/module/components/empty-state/EmptyState.js +3 -3
- package/lib/module/components/empty-state/EmptyState.js.map +1 -1
- package/lib/module/components/header/variants/LargeTitleHeader/index.js +30 -32
- package/lib/module/components/header/variants/LargeTitleHeader/index.js.map +1 -1
- package/lib/module/components/header/variants/TitleHeader/index.js +26 -18
- package/lib/module/components/header/variants/TitleHeader/index.js.map +1 -1
- package/lib/module/components/icon/Icon.js +3 -3
- package/lib/module/components/page-indicator/hooks/use-page-indicator-colors.js +2 -2
- package/lib/module/components/screen/Screen.js +63 -34
- package/lib/module/components/screen/Screen.js.map +1 -1
- package/lib/module/components/screen/ScreenContext.js +28 -9
- package/lib/module/components/screen/ScreenContext.js.map +1 -1
- package/lib/module/components/screen/ScreenFlatList.js +4 -162
- package/lib/module/components/screen/ScreenFlatList.js.map +1 -1
- package/lib/module/components/screen/ScreenList.js +81 -97
- package/lib/module/components/screen/ScreenList.js.map +1 -1
- package/lib/module/components/screen/ScreenScrollView.js +64 -115
- package/lib/module/components/screen/ScreenScrollView.js.map +1 -1
- package/lib/module/components/screen/hooks/use-screen-ref.js +18 -0
- package/lib/module/components/screen/hooks/use-screen-ref.js.map +1 -0
- package/lib/module/components/screen/hooks/use-screen-scroll-handler.js +19 -0
- package/lib/module/components/screen/hooks/use-screen-scroll-handler.js.map +1 -0
- package/lib/module/components/screen/index.js.map +1 -1
- package/lib/module/components/screen/parts/ScreenFrame.js +47 -0
- package/lib/module/components/screen/parts/ScreenFrame.js.map +1 -0
- package/lib/module/components/screen/parts/ScreenHeaderAppearance.js +43 -0
- package/lib/module/components/screen/parts/ScreenHeaderAppearance.js.map +1 -0
- package/lib/module/components/screen/parts/ScreenPlaceholder.js +11 -13
- package/lib/module/components/screen/parts/ScreenPlaceholder.js.map +1 -1
- package/lib/module/components/screen/parts/ScreenSafeArea.android.js +27 -0
- package/lib/module/components/screen/parts/ScreenSafeArea.android.js.map +1 -0
- package/lib/module/components/screen/parts/ScreenSafeArea.js +26 -0
- package/lib/module/components/screen/parts/ScreenSafeArea.js.map +1 -0
- package/lib/module/components/screen/utils.js +18 -9
- package/lib/module/components/screen/utils.js.map +1 -1
- package/lib/module/components/spinner/Spinner.js +2 -2
- package/lib/module/components/text/Text.js +1 -1
- package/lib/module/components/toast/Toast.js +13 -13
- package/lib/module/components/toast/Toast.js.map +1 -1
- package/lib/module/components/toolbar/Toolbar.js +2 -2
- package/lib/module/context/ThemeProvider.js +22 -39
- package/lib/module/context/ThemeProvider.js.map +1 -1
- package/lib/module/context/index.js +1 -1
- package/lib/module/context/index.js.map +1 -1
- package/lib/module/theme/breakpoints.js +1 -1
- package/lib/module/theme/define-config.js +21 -0
- package/lib/module/theme/define-config.js.map +1 -0
- package/lib/module/theme/diagnostics.js +13 -0
- package/lib/module/theme/diagnostics.js.map +1 -0
- package/lib/module/theme/errors.js +15 -0
- package/lib/module/theme/errors.js.map +1 -0
- package/lib/module/theme/index.js +7 -1
- package/lib/module/theme/index.js.map +1 -1
- package/lib/module/theme/legacy-tokens.js +119 -0
- package/lib/module/theme/legacy-tokens.js.map +1 -0
- package/lib/module/theme/registry.js +122 -130
- package/lib/module/theme/registry.js.map +1 -1
- package/lib/module/theme/resolve-config.js +346 -0
- package/lib/module/theme/resolve-config.js.map +1 -0
- package/lib/module/theme/runtime.js +57 -12
- package/lib/module/theme/runtime.js.map +1 -1
- package/lib/module/theme/screen-margin.js +6 -14
- package/lib/module/theme/screen-margin.js.map +1 -1
- package/lib/module/theme/screen-padding.js +17 -0
- package/lib/module/theme/screen-padding.js.map +1 -0
- package/lib/module/theme/selection-store.js +75 -0
- package/lib/module/theme/selection-store.js.map +1 -0
- package/lib/module/theme/theme.js +81 -36
- package/lib/module/theme/theme.js.map +1 -1
- package/lib/module/theme/tokens/canonical-color-keys.js +102 -0
- package/lib/module/theme/tokens/canonical-color-keys.js.map +1 -0
- package/lib/module/theme/tokens/dark.js +72 -88
- package/lib/module/theme/tokens/dark.js.map +1 -1
- package/lib/module/theme/tokens/light.js +69 -83
- package/lib/module/theme/tokens/light.js.map +1 -1
- package/lib/module/theme/tokens/primitives.js +14 -14
- package/lib/module/theme/tokens/primitives.js.map +1 -1
- package/lib/module/theme/tokens/validate.js +238 -0
- package/lib/module/theme/tokens/validate.js.map +1 -0
- package/lib/module/theme/unistyles-adapter.js +77 -0
- package/lib/module/theme/unistyles-adapter.js.map +1 -0
- package/lib/module/utils/accent-generator.js +476 -0
- package/lib/module/utils/accent-generator.js.map +1 -0
- package/lib/module/utils/accent-utils.js +52 -35
- package/lib/module/utils/accent-utils.js.map +1 -1
- package/lib/module/utils/color-utils.js +224 -7
- package/lib/module/utils/color-utils.js.map +1 -1
- package/lib/module/utils/deep-merge.js +1 -1
- package/lib/module/utils/deep-merge.js.map +1 -1
- package/lib/module/utils/resolve-theme-color.js +2 -2
- package/lib/typescript/src/components/chip/types.d.ts +1 -1
- package/lib/typescript/src/components/empty-state/EmptyState.d.ts +1 -1
- package/lib/typescript/src/components/empty-state/EmptyState.d.ts.map +1 -1
- package/lib/typescript/src/components/header/variants/LargeTitleHeader/index.d.ts +6 -5
- package/lib/typescript/src/components/header/variants/LargeTitleHeader/index.d.ts.map +1 -1
- package/lib/typescript/src/components/header/variants/TitleHeader/index.d.ts +11 -5
- package/lib/typescript/src/components/header/variants/TitleHeader/index.d.ts.map +1 -1
- package/lib/typescript/src/components/icon/Icon.d.ts +2 -2
- package/lib/typescript/src/components/screen/Screen.d.ts +45 -16
- package/lib/typescript/src/components/screen/Screen.d.ts.map +1 -1
- package/lib/typescript/src/components/screen/ScreenContext.d.ts +42 -26
- package/lib/typescript/src/components/screen/ScreenContext.d.ts.map +1 -1
- package/lib/typescript/src/components/screen/ScreenFlatList.d.ts +5 -46
- package/lib/typescript/src/components/screen/ScreenFlatList.d.ts.map +1 -1
- package/lib/typescript/src/components/screen/ScreenList.d.ts +62 -50
- package/lib/typescript/src/components/screen/ScreenList.d.ts.map +1 -1
- package/lib/typescript/src/components/screen/ScreenScrollView.d.ts +18 -58
- package/lib/typescript/src/components/screen/ScreenScrollView.d.ts.map +1 -1
- package/lib/typescript/src/components/screen/hooks/use-screen-ref.d.ts +5 -0
- package/lib/typescript/src/components/screen/hooks/use-screen-ref.d.ts.map +1 -0
- package/lib/typescript/src/components/screen/hooks/use-screen-scroll-handler.d.ts +53 -0
- package/lib/typescript/src/components/screen/hooks/use-screen-scroll-handler.d.ts.map +1 -0
- package/lib/typescript/src/components/screen/index.d.ts +2 -2
- package/lib/typescript/src/components/screen/index.d.ts.map +1 -1
- package/lib/typescript/src/components/screen/parts/ScreenFrame.d.ts +6 -0
- package/lib/typescript/src/components/screen/parts/ScreenFrame.d.ts.map +1 -0
- package/lib/typescript/src/components/screen/parts/ScreenHeaderAppearance.d.ts +21 -0
- package/lib/typescript/src/components/screen/parts/ScreenHeaderAppearance.d.ts.map +1 -0
- package/lib/typescript/src/components/screen/parts/ScreenPlaceholder.d.ts +6 -8
- package/lib/typescript/src/components/screen/parts/ScreenPlaceholder.d.ts.map +1 -1
- package/lib/typescript/src/components/screen/parts/ScreenSafeArea.android.d.ts +7 -0
- package/lib/typescript/src/components/screen/parts/ScreenSafeArea.android.d.ts.map +1 -0
- package/lib/typescript/src/components/screen/parts/ScreenSafeArea.d.ts +6 -0
- package/lib/typescript/src/components/screen/parts/ScreenSafeArea.d.ts.map +1 -0
- package/lib/typescript/src/components/screen/types.d.ts +27 -32
- package/lib/typescript/src/components/screen/types.d.ts.map +1 -1
- package/lib/typescript/src/components/screen/utils.d.ts +9 -8
- package/lib/typescript/src/components/screen/utils.d.ts.map +1 -1
- package/lib/typescript/src/components/text/Text.d.ts +1 -1
- package/lib/typescript/src/components/text/Text.d.ts.map +1 -1
- package/lib/typescript/src/components/toolbar/Toolbar.d.ts +2 -2
- package/lib/typescript/src/context/ThemeProvider.d.ts +29 -12
- package/lib/typescript/src/context/ThemeProvider.d.ts.map +1 -1
- package/lib/typescript/src/context/index.d.ts +1 -1
- package/lib/typescript/src/context/index.d.ts.map +1 -1
- package/lib/typescript/src/theme/breakpoints.d.ts +1 -1
- package/lib/typescript/src/theme/define-config.d.ts +23 -0
- package/lib/typescript/src/theme/define-config.d.ts.map +1 -0
- package/lib/typescript/src/theme/diagnostics.d.ts +4 -0
- package/lib/typescript/src/theme/diagnostics.d.ts.map +1 -0
- package/lib/typescript/src/theme/errors.d.ts +10 -0
- package/lib/typescript/src/theme/errors.d.ts.map +1 -0
- package/lib/typescript/src/theme/index.d.ts +10 -2
- package/lib/typescript/src/theme/index.d.ts.map +1 -1
- package/lib/typescript/src/theme/legacy-tokens.d.ts +187 -0
- package/lib/typescript/src/theme/legacy-tokens.d.ts.map +1 -0
- package/lib/typescript/src/theme/registry.d.ts +12 -17
- package/lib/typescript/src/theme/registry.d.ts.map +1 -1
- package/lib/typescript/src/theme/resolve-config.d.ts +99 -0
- package/lib/typescript/src/theme/resolve-config.d.ts.map +1 -0
- package/lib/typescript/src/theme/runtime.d.ts +25 -4
- package/lib/typescript/src/theme/runtime.d.ts.map +1 -1
- package/lib/typescript/src/theme/screen-margin.d.ts +6 -14
- package/lib/typescript/src/theme/screen-margin.d.ts.map +1 -1
- package/lib/typescript/src/theme/screen-padding.d.ts +15 -0
- package/lib/typescript/src/theme/screen-padding.d.ts.map +1 -0
- package/lib/typescript/src/theme/selection-store.d.ts +41 -0
- package/lib/typescript/src/theme/selection-store.d.ts.map +1 -0
- package/lib/typescript/src/theme/theme.d.ts +54 -1
- package/lib/typescript/src/theme/theme.d.ts.map +1 -1
- package/lib/typescript/src/theme/tokens/canonical-color-keys.d.ts +95 -0
- package/lib/typescript/src/theme/tokens/canonical-color-keys.d.ts.map +1 -0
- package/lib/typescript/src/theme/tokens/dark.d.ts +56 -63
- package/lib/typescript/src/theme/tokens/dark.d.ts.map +1 -1
- package/lib/typescript/src/theme/tokens/light.d.ts +56 -71
- package/lib/typescript/src/theme/tokens/light.d.ts.map +1 -1
- package/lib/typescript/src/theme/tokens/primitives.d.ts +11 -10
- package/lib/typescript/src/theme/tokens/primitives.d.ts.map +1 -1
- package/lib/typescript/src/theme/tokens/validate.d.ts +34 -0
- package/lib/typescript/src/theme/tokens/validate.d.ts.map +1 -0
- package/lib/typescript/src/theme/types.d.ts +166 -86
- package/lib/typescript/src/theme/types.d.ts.map +1 -1
- package/lib/typescript/src/theme/unistyles-adapter.d.ts +33 -0
- package/lib/typescript/src/theme/unistyles-adapter.d.ts.map +1 -0
- package/lib/typescript/src/utils/accent-generator.d.ts +103 -0
- package/lib/typescript/src/utils/accent-generator.d.ts.map +1 -0
- package/lib/typescript/src/utils/accent-utils.d.ts +15 -2
- package/lib/typescript/src/utils/accent-utils.d.ts.map +1 -1
- package/lib/typescript/src/utils/color-utils.d.ts +59 -2
- package/lib/typescript/src/utils/color-utils.d.ts.map +1 -1
- package/lib/typescript/src/utils/deep-merge.d.ts +1 -0
- package/lib/typescript/src/utils/deep-merge.d.ts.map +1 -1
- package/lib/typescript/src/utils/resolve-theme-color.d.ts +2 -2
- package/package.json +1 -1
- package/src/components/avatar/Avatar.tsx +20 -20
- package/src/components/button/Button.tsx +11 -11
- package/src/components/chip/Chip.tsx +32 -32
- package/src/components/chip/types.ts +1 -1
- package/src/components/empty-state/EmptyState.tsx +4 -4
- package/src/components/header/variants/LargeTitleHeader/index.tsx +31 -30
- package/src/components/header/variants/TitleHeader/index.tsx +33 -22
- package/src/components/icon/Icon.tsx +4 -4
- package/src/components/page-indicator/hooks/use-page-indicator-colors.ts +2 -2
- package/src/components/screen/Screen.tsx +89 -43
- package/src/components/screen/ScreenContext.tsx +48 -28
- package/src/components/screen/ScreenFlatList.tsx +5 -191
- package/src/components/screen/ScreenList.tsx +197 -175
- package/src/components/screen/ScreenScrollView.tsx +92 -167
- package/src/components/screen/hooks/use-screen-ref.ts +24 -0
- package/src/components/screen/hooks/use-screen-scroll-handler.ts +31 -0
- package/src/components/screen/index.ts +8 -2
- package/src/components/screen/parts/ScreenFrame.tsx +49 -0
- package/src/components/screen/parts/ScreenHeaderAppearance.tsx +52 -0
- package/src/components/screen/parts/ScreenPlaceholder.tsx +16 -16
- package/src/components/screen/parts/ScreenSafeArea.android.tsx +22 -0
- package/src/components/screen/parts/ScreenSafeArea.tsx +20 -0
- package/src/components/screen/types.ts +29 -32
- package/src/components/screen/utils.ts +24 -11
- package/src/components/spinner/Spinner.tsx +2 -2
- package/src/components/text/Text.tsx +2 -2
- package/src/components/toast/Toast.tsx +13 -13
- package/src/components/toolbar/Toolbar.tsx +4 -4
- package/src/context/ThemeProvider.tsx +47 -51
- package/src/context/index.ts +1 -1
- package/src/theme/breakpoints.ts +1 -1
- package/src/theme/define-config.ts +27 -0
- package/src/theme/diagnostics.ts +11 -0
- package/src/theme/errors.ts +14 -0
- package/src/theme/index.ts +40 -1
- package/src/theme/legacy-tokens.ts +227 -0
- package/src/theme/registry.ts +119 -137
- package/src/theme/resolve-config.ts +406 -0
- package/src/theme/runtime.ts +52 -14
- package/src/theme/screen-margin.ts +6 -25
- package/src/theme/screen-padding.ts +26 -0
- package/src/theme/selection-store.ts +76 -0
- package/src/theme/theme.ts +104 -39
- package/src/theme/tokens/canonical-color-keys.ts +107 -0
- package/src/theme/tokens/dark.ts +91 -98
- package/src/theme/tokens/light.ts +88 -93
- package/src/theme/tokens/primitives.ts +14 -14
- package/src/theme/tokens/validate.ts +271 -0
- package/src/theme/types.ts +184 -95
- package/src/theme/unistyles-adapter.ts +75 -0
- package/src/utils/accent-generator.ts +530 -0
- package/src/utils/accent-utils.ts +66 -39
- package/src/utils/color-utils.ts +225 -8
- package/src/utils/deep-merge.ts +1 -1
- package/src/utils/resolve-theme-color.ts +2 -2
- package/lib/module/components/screen/parts/BottomSafeArea.js +0 -62
- package/lib/module/components/screen/parts/BottomSafeArea.js.map +0 -1
- package/lib/typescript/src/components/screen/parts/BottomSafeArea.d.ts +0 -30
- package/lib/typescript/src/components/screen/parts/BottomSafeArea.d.ts.map +0 -1
- package/src/components/screen/parts/BottomSafeArea.tsx +0 -59
|
@@ -0,0 +1,530 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Pure, deterministic accent generation from a single seed color.
|
|
3
|
+
*
|
|
4
|
+
* Consumed by the resolver (`theme/resolve-config.ts`) for startup, runtime
|
|
5
|
+
* accent changes and `createTerraThemes`, and by the `normalizeAccent`
|
|
6
|
+
* compatibility wrapper. Must not import the registry, provider, Unistyles,
|
|
7
|
+
* or renderers — only `#theme/types` and the colour maths in `./color-utils`.
|
|
8
|
+
*
|
|
9
|
+
* ## Algorithm
|
|
10
|
+
*
|
|
11
|
+
* Every role is derived in OKLCH (Björn Ottosson's OKLab, in-house
|
|
12
|
+
* implementation in `./color-utils`): the seed's hue is kept for every role,
|
|
13
|
+
* chroma is capped per role and reduced further by sRGB gamut mapping, and
|
|
14
|
+
* perceptual lightness is *solved* per role against the scheme's actual
|
|
15
|
+
* ordinary surfaces so that WCAG 2.x targets are met (4.5:1 for text and
|
|
16
|
+
* solid/soft labels, 3:1 for the accent border). Contrast is evaluated with
|
|
17
|
+
* sRGB alpha compositing over each of the four surfaces and, for solid
|
|
18
|
+
* controls, additionally with the pressed opacity applied to the whole control.
|
|
19
|
+
*
|
|
20
|
+
* - `accent.solid.bg` keeps the seed exactly when black or white text reads on
|
|
21
|
+
* it at both opacities; otherwise the fill is moved the smallest lightness
|
|
22
|
+
* distance (ties prefer darker in light, lighter in dark) until a readable
|
|
23
|
+
* label exists — so the fill MAY differ from the seed. Pin `accent.solid.bg`
|
|
24
|
+
* through overrides for an exact fill.
|
|
25
|
+
* - `accent.solid.fg` is a tinted near-black or near-white (pure black/white
|
|
26
|
+
* when the tint would not reach 4.5:1) picked by contrast, not brightness.
|
|
27
|
+
* - Hover/pressed fills darken in light and lighten in dark (mirroring the
|
|
28
|
+
* built-in tokens); the direction flips only where following the scheme
|
|
29
|
+
* would break the label contrast or leave the lightness range.
|
|
30
|
+
* - `accent.soft.bg` is a light tint in light / dark tint in dark, placed
|
|
31
|
+
* relative to the surfaces; `accent.soft.fg` is solved on all three soft
|
|
32
|
+
* states. `accent.text` (and `text.link`, the existing shorthand) is solved
|
|
33
|
+
* on every surface; `accent.border` is solved to 3:1 on every surface.
|
|
34
|
+
* - Disabled fills are desaturated and low-contrast-but-visible, in the
|
|
35
|
+
* spirit of the default `slate.200`/`slate.700`-like tokens.
|
|
36
|
+
*
|
|
37
|
+
* Unsatisfiable pairs (e.g. one very light and one very dark surface) are
|
|
38
|
+
* reported as `unsatisfied` diagnostics with the best deterministic colour
|
|
39
|
+
* kept; unparseable surfaces are excluded from solving and reported as
|
|
40
|
+
* `unsupported`. Nothing ever falls back to white silently.
|
|
41
|
+
*
|
|
42
|
+
*/
|
|
43
|
+
import type { CanonicalThemeColor, Scheme } from "#theme/types";
|
|
44
|
+
import {
|
|
45
|
+
compositedContrast,
|
|
46
|
+
type OKLCH,
|
|
47
|
+
oklchToRgb,
|
|
48
|
+
parseColor,
|
|
49
|
+
type RGB,
|
|
50
|
+
type RGBA,
|
|
51
|
+
rgbToHex,
|
|
52
|
+
rgbToOklch,
|
|
53
|
+
} from "./color-utils";
|
|
54
|
+
|
|
55
|
+
/** Every canonical role a seed generates. `text.link` is kept for the existing shorthand behaviour. */
|
|
56
|
+
export type GeneratedAccentRole =
|
|
57
|
+
| "accent.text"
|
|
58
|
+
| "accent.border"
|
|
59
|
+
| "accent.solid.bg"
|
|
60
|
+
| "accent.solid.bg.hover"
|
|
61
|
+
| "accent.solid.bg.pressed"
|
|
62
|
+
| "accent.solid.bg.disabled"
|
|
63
|
+
| "accent.solid.fg"
|
|
64
|
+
| "accent.soft.bg"
|
|
65
|
+
| "accent.soft.bg.hover"
|
|
66
|
+
| "accent.soft.bg.pressed"
|
|
67
|
+
| "accent.soft.bg.disabled"
|
|
68
|
+
| "accent.soft.fg"
|
|
69
|
+
| "text.link";
|
|
70
|
+
|
|
71
|
+
export const GENERATED_ACCENT_ROLES: readonly GeneratedAccentRole[] = [
|
|
72
|
+
"accent.text",
|
|
73
|
+
"accent.border",
|
|
74
|
+
"accent.solid.bg",
|
|
75
|
+
"accent.solid.bg.hover",
|
|
76
|
+
"accent.solid.bg.pressed",
|
|
77
|
+
"accent.solid.bg.disabled",
|
|
78
|
+
"accent.solid.fg",
|
|
79
|
+
"accent.soft.bg",
|
|
80
|
+
"accent.soft.bg.hover",
|
|
81
|
+
"accent.soft.bg.pressed",
|
|
82
|
+
"accent.soft.bg.disabled",
|
|
83
|
+
"accent.soft.fg",
|
|
84
|
+
"text.link",
|
|
85
|
+
];
|
|
86
|
+
|
|
87
|
+
export type AccentSurfaceRole = "surface.canvas" | "surface.default" | "surface.raised" | "surface.sunken";
|
|
88
|
+
|
|
89
|
+
/** The four ordinary surfaces every accent pair is evaluated over, in a fixed order. */
|
|
90
|
+
export const ACCENT_SURFACE_ROLES: readonly AccentSurfaceRole[] = [
|
|
91
|
+
"surface.canvas",
|
|
92
|
+
"surface.default",
|
|
93
|
+
"surface.raised",
|
|
94
|
+
"surface.sunken",
|
|
95
|
+
];
|
|
96
|
+
|
|
97
|
+
/**
|
|
98
|
+
* The resolved scheme context a generator needs: the scheme's actual ordinary
|
|
99
|
+
* surfaces (after defaults, shared and scheme patches) and the opacity that
|
|
100
|
+
* components apply to a whole pressed control.
|
|
101
|
+
*/
|
|
102
|
+
export interface AccentGenerationContext {
|
|
103
|
+
scheme: Scheme;
|
|
104
|
+
surfaces: Record<AccentSurfaceRole, string>;
|
|
105
|
+
pressedOpacity: number;
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
/**
|
|
109
|
+
* `unsatisfied` — the pair was evaluated and fell below `required`.
|
|
110
|
+
* `unsupported` — a colour could not be evaluated (non-sRGB/opaque format);
|
|
111
|
+
* `ratio` is `null` and no accessibility claim is made.
|
|
112
|
+
*/
|
|
113
|
+
export type ContrastDiagnosticKind = "unsatisfied" | "unsupported";
|
|
114
|
+
|
|
115
|
+
export interface ContrastDiagnostic {
|
|
116
|
+
kind: ContrastDiagnosticKind;
|
|
117
|
+
/** Foreground role checked, e.g. `accent.solid.fg` or `accent.text`. */
|
|
118
|
+
role: string;
|
|
119
|
+
/** Background role the foreground sits on, e.g. `accent.solid.bg` or `surface.canvas`. */
|
|
120
|
+
background: string;
|
|
121
|
+
/** Ordinary surface the pair was composited over. */
|
|
122
|
+
surface: AccentSurfaceRole;
|
|
123
|
+
/** Opacity applied to the whole control for this check (1 when none). */
|
|
124
|
+
opacity: number;
|
|
125
|
+
ratio: number | null;
|
|
126
|
+
required: number;
|
|
127
|
+
message: string;
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
export interface AccentGenerationResult {
|
|
131
|
+
colors: Record<GeneratedAccentRole, string>;
|
|
132
|
+
/** Pairs the generator could not satisfy or evaluate in the supported domain. */
|
|
133
|
+
diagnostics: ContrastDiagnostic[];
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
/** Whether `value` is an opaque six-digit sRGB hex seed (`#rrggbb`, case-insensitive). */
|
|
137
|
+
export function isSupportedAccentSeed(value: unknown): value is string {
|
|
138
|
+
return typeof value === "string" && /^#[0-9a-f]{6}$/i.test(value.trim());
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
// ─── Targets and tuning ───────────────────────────────────────────────────────
|
|
142
|
+
|
|
143
|
+
/** WCAG 2.x minimum for normal text and solid/soft labels. */
|
|
144
|
+
export const ACCENT_TEXT_CONTRAST = 4.5;
|
|
145
|
+
/** WCAG 2.x minimum for a meaningful accent boundary on an ordinary surface. */
|
|
146
|
+
export const ACCENT_BORDER_CONTRAST = 3;
|
|
147
|
+
|
|
148
|
+
/** Lightness resolution of the per-role solver (OKLCH L units). */
|
|
149
|
+
const L_STEP = 0.005;
|
|
150
|
+
|
|
151
|
+
type Direction = 1 | -1;
|
|
152
|
+
|
|
153
|
+
interface SchemeTuning {
|
|
154
|
+
/** Scheme direction for hover/pressed and for role adjustments: darker in light, lighter in dark. */
|
|
155
|
+
dir: Direction;
|
|
156
|
+
/** Lightness step between the base fill and hover, and hover and pressed. */
|
|
157
|
+
solidStep: number;
|
|
158
|
+
/** Soft background lightness relative to the surfaces, with clamps. */
|
|
159
|
+
softBase: { offset: number; min: number; max: number };
|
|
160
|
+
/** Lightness step between soft base/hover/pressed (signed). */
|
|
161
|
+
softStep: number;
|
|
162
|
+
/** Chroma caps: soft tints stay pastel; disabled fills are near-neutral. */
|
|
163
|
+
softChroma: number;
|
|
164
|
+
solidDisabled: { l: number; c: number };
|
|
165
|
+
softDisabled: { l: number; c: number };
|
|
166
|
+
/** Tinted label lightness for the dark and light label polarity. */
|
|
167
|
+
darkLabelL: number;
|
|
168
|
+
lightLabelL: number;
|
|
169
|
+
/** Used to solve when no surface in the context can be evaluated. */
|
|
170
|
+
fallbackSurface: RGB;
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
const TUNING: Record<Scheme, SchemeTuning> = {
|
|
174
|
+
light: {
|
|
175
|
+
dir: -1,
|
|
176
|
+
solidStep: 0.06,
|
|
177
|
+
softBase: { offset: 0.01, min: 0.86, max: 0.965 },
|
|
178
|
+
softStep: -0.04,
|
|
179
|
+
softChroma: 0.08,
|
|
180
|
+
solidDisabled: { l: 0.93, c: 0.02 },
|
|
181
|
+
softDisabled: { l: 0.97, c: 0.01 },
|
|
182
|
+
darkLabelL: 0.15,
|
|
183
|
+
lightLabelL: 0.985,
|
|
184
|
+
fallbackSurface: { r: 255, g: 255, b: 255 },
|
|
185
|
+
},
|
|
186
|
+
dark: {
|
|
187
|
+
dir: 1,
|
|
188
|
+
solidStep: 0.06,
|
|
189
|
+
softBase: { offset: 0.03, min: 0.24, max: 0.5 },
|
|
190
|
+
softStep: 0.06,
|
|
191
|
+
softChroma: 0.06,
|
|
192
|
+
solidDisabled: { l: 0.4, c: 0.04 },
|
|
193
|
+
softDisabled: { l: 0.22, c: 0.01 },
|
|
194
|
+
darkLabelL: 0.15,
|
|
195
|
+
lightLabelL: 0.985,
|
|
196
|
+
fallbackSurface: { r: 0, g: 0, b: 0 },
|
|
197
|
+
},
|
|
198
|
+
};
|
|
199
|
+
|
|
200
|
+
const LABEL_CHROMA = 0.03;
|
|
201
|
+
|
|
202
|
+
// ─── Internal helpers ─────────────────────────────────────────────────────────
|
|
203
|
+
|
|
204
|
+
const opaque = (rgb: RGB): RGBA => ({ ...rgb, a: 1 });
|
|
205
|
+
|
|
206
|
+
/** OKLCH → sRGB rounded to the 0-255 grid, so scores match the emitted hex exactly. */
|
|
207
|
+
const toRgb = (color: OKLCH): RGB => {
|
|
208
|
+
const rgb = oklchToRgb(color);
|
|
209
|
+
return { r: Math.round(rgb.r), g: Math.round(rgb.g), b: Math.round(rgb.b) };
|
|
210
|
+
};
|
|
211
|
+
|
|
212
|
+
/** A pressed opacity outside (0, 1] is not a usable multiplier; evaluate at 1 instead. */
|
|
213
|
+
function normalizeOpacity(value: number): number {
|
|
214
|
+
return Number.isFinite(value) && value > 0 && value <= 1 ? value : 1;
|
|
215
|
+
}
|
|
216
|
+
|
|
217
|
+
/** Parses a surface as an opaque sRGB colour; translucent or unparseable surfaces are not evaluable. */
|
|
218
|
+
function parseSurface(value: string): RGB | null {
|
|
219
|
+
const parsed = parseColor(value);
|
|
220
|
+
return parsed && parsed.a >= 1 ? parsed : null;
|
|
221
|
+
}
|
|
222
|
+
|
|
223
|
+
const clamp01 = (n: number): number => Math.max(0, Math.min(1, n));
|
|
224
|
+
|
|
225
|
+
/** Rounds to the solver grid so equal inputs always visit identical candidates. */
|
|
226
|
+
const snap = (l: number): number => Math.round(l / L_STEP) * L_STEP;
|
|
227
|
+
|
|
228
|
+
/**
|
|
229
|
+
* Yields candidate lightness values starting at `start`: only in `dir` when
|
|
230
|
+
* `both` is false, otherwise alternating outward with `dir` visited first at
|
|
231
|
+
* each distance. Stops once every remaining candidate is outside [0, 1].
|
|
232
|
+
*/
|
|
233
|
+
function* lightnessCandidates(start: number, dir: Direction, both: boolean): Generator<number> {
|
|
234
|
+
const origin = clamp01(snap(start));
|
|
235
|
+
yield origin;
|
|
236
|
+
for (let k = 1; k <= Math.ceil(1 / L_STEP); k += 1) {
|
|
237
|
+
const forward = origin + dir * k * L_STEP;
|
|
238
|
+
const backward = origin - dir * k * L_STEP;
|
|
239
|
+
const forwardOk = forward >= -1e-9 && forward <= 1 + 1e-9;
|
|
240
|
+
const backwardOk = both && backward >= -1e-9 && backward <= 1 + 1e-9;
|
|
241
|
+
if (!forwardOk && !backwardOk) return;
|
|
242
|
+
if (forwardOk) yield clamp01(forward);
|
|
243
|
+
if (backwardOk) yield clamp01(backward);
|
|
244
|
+
}
|
|
245
|
+
}
|
|
246
|
+
|
|
247
|
+
interface Solved {
|
|
248
|
+
l: number;
|
|
249
|
+
rgb: RGB;
|
|
250
|
+
satisfied: boolean;
|
|
251
|
+
}
|
|
252
|
+
|
|
253
|
+
/**
|
|
254
|
+
* Finds the first candidate colour (hue/chroma fixed, lightness varied) whose
|
|
255
|
+
* `score` — min(ratio / required) over every pair it must satisfy — reaches 1.
|
|
256
|
+
* When nothing reaches 1, returns the best-scoring candidate (deterministic).
|
|
257
|
+
*/
|
|
258
|
+
function solveLightness(
|
|
259
|
+
base: Pick<OKLCH, "c" | "h">,
|
|
260
|
+
start: number,
|
|
261
|
+
dir: Direction,
|
|
262
|
+
both: boolean,
|
|
263
|
+
score: (rgb: RGB) => number
|
|
264
|
+
): Solved {
|
|
265
|
+
let best: Solved | null = null;
|
|
266
|
+
let bestScore = Number.NEGATIVE_INFINITY;
|
|
267
|
+
for (const l of lightnessCandidates(start, dir, both)) {
|
|
268
|
+
const rgb = toRgb({ l, c: base.c, h: base.h });
|
|
269
|
+
const s = score(rgb);
|
|
270
|
+
if (s >= 1) return { l, rgb, satisfied: true };
|
|
271
|
+
if (s > bestScore) {
|
|
272
|
+
bestScore = s;
|
|
273
|
+
best = { l, rgb, satisfied: false };
|
|
274
|
+
}
|
|
275
|
+
}
|
|
276
|
+
return (
|
|
277
|
+
best ?? { l: clamp01(start), rgb: toRgb({ l: clamp01(start), c: base.c, h: base.h }), satisfied: false }
|
|
278
|
+
);
|
|
279
|
+
}
|
|
280
|
+
|
|
281
|
+
const min = (values: number[]): number =>
|
|
282
|
+
values.reduce((acc, v) => Math.min(acc, v), Number.POSITIVE_INFINITY);
|
|
283
|
+
|
|
284
|
+
/** min over surfaces of contrast(fg on bg over surface) at each opacity, normalised by `required`. */
|
|
285
|
+
function pairScore(fg: RGBA, bgs: RGBA[], surfaces: RGB[], opacities: number[], required: number): number {
|
|
286
|
+
const ratios: number[] = [];
|
|
287
|
+
for (const bg of bgs) {
|
|
288
|
+
for (const surface of surfaces) {
|
|
289
|
+
for (const opacity of opacities) ratios.push(compositedContrast(fg, bg, surface, opacity) / required);
|
|
290
|
+
}
|
|
291
|
+
}
|
|
292
|
+
return min(ratios);
|
|
293
|
+
}
|
|
294
|
+
|
|
295
|
+
/** Contrast of a standalone colour (text/border) against each surface, normalised by `required`. */
|
|
296
|
+
function surfaceScore(color: RGB, surfaces: RGB[], required: number): number {
|
|
297
|
+
return min(
|
|
298
|
+
surfaces.map((surface) => compositedContrast(opaque(color), opaque(surface), surface, 1) / required)
|
|
299
|
+
);
|
|
300
|
+
}
|
|
301
|
+
|
|
302
|
+
// ─── Generation ───────────────────────────────────────────────────────────────
|
|
303
|
+
|
|
304
|
+
/**
|
|
305
|
+
* Generates every {@link GeneratedAccentRole} for `seed` in the given scheme
|
|
306
|
+
* context. Pure and deterministic: same inputs → same output. Callers must
|
|
307
|
+
* validate the seed with {@link isSupportedAccentSeed} first.
|
|
308
|
+
*/
|
|
309
|
+
export function generateAccentColors(seed: string, context: AccentGenerationContext): AccentGenerationResult {
|
|
310
|
+
if (!isSupportedAccentSeed(seed)) {
|
|
311
|
+
throw new Error(
|
|
312
|
+
`generateAccentColors: unsupported seed ${JSON.stringify(seed)}; expected an opaque #rrggbb sRGB hex.`
|
|
313
|
+
);
|
|
314
|
+
}
|
|
315
|
+
const seedHex = seed.trim().toLowerCase();
|
|
316
|
+
const seedRgb = parseColor(seedHex) as RGBA;
|
|
317
|
+
const seedLch = rgbToOklch(seedRgb);
|
|
318
|
+
const tuning = TUNING[context.scheme];
|
|
319
|
+
const { dir } = tuning;
|
|
320
|
+
const pressedOpacity = normalizeOpacity(context.pressedOpacity);
|
|
321
|
+
const opacities = pressedOpacity < 1 ? [1, pressedOpacity] : [1];
|
|
322
|
+
|
|
323
|
+
const evaluable = ACCENT_SURFACE_ROLES.map((role) => parseSurface(context.surfaces[role])).filter(
|
|
324
|
+
(surface): surface is RGB => surface !== null
|
|
325
|
+
);
|
|
326
|
+
const surfaces = evaluable.length > 0 ? evaluable : [tuning.fallbackSurface];
|
|
327
|
+
const surfaceLs = surfaces.map((surface) => rgbToOklch(surface).l);
|
|
328
|
+
|
|
329
|
+
const hue = { c: seedLch.c, h: seedLch.h };
|
|
330
|
+
const black: RGBA = { r: 0, g: 0, b: 0, a: 1 };
|
|
331
|
+
const white: RGBA = { r: 255, g: 255, b: 255, a: 1 };
|
|
332
|
+
|
|
333
|
+
// ── Solid fill: keep the seed when black or white reads on it at every opacity.
|
|
334
|
+
const fillScore = (fill: RGB, fg: RGBA): number =>
|
|
335
|
+
pairScore(fg, [opaque(fill)], surfaces, opacities, ACCENT_TEXT_CONTRAST);
|
|
336
|
+
const readable = (fill: RGB): boolean => fillScore(fill, black) >= 1 || fillScore(fill, white) >= 1;
|
|
337
|
+
|
|
338
|
+
let fillRgb: RGB = seedRgb;
|
|
339
|
+
let fillL = seedLch.l;
|
|
340
|
+
if (!readable(seedRgb)) {
|
|
341
|
+
const solved = solveLightness(hue, seedLch.l, dir, true, (rgb) =>
|
|
342
|
+
Math.max(fillScore(rgb, black), fillScore(rgb, white))
|
|
343
|
+
);
|
|
344
|
+
fillRgb = solved.rgb;
|
|
345
|
+
fillL = solved.l;
|
|
346
|
+
}
|
|
347
|
+
const fillHex = fillRgb === seedRgb ? seedHex : rgbToHex(fillRgb);
|
|
348
|
+
|
|
349
|
+
// Label polarity by contrast, never by perceived brightness.
|
|
350
|
+
const polarity: "dark" | "light" =
|
|
351
|
+
fillScore(fillRgb, black) >= fillScore(fillRgb, white) ? "dark" : "light";
|
|
352
|
+
const pureLabel = polarity === "dark" ? black : white;
|
|
353
|
+
|
|
354
|
+
// ── Solid states: follow the scheme, flip when that would break the label or leave [0, 1].
|
|
355
|
+
const statesFor = (direction: Direction): RGB[] =>
|
|
356
|
+
[1, 2].map((k) => toRgb({ l: clamp01(fillL + direction * k * tuning.solidStep), c: hue.c, h: hue.h }));
|
|
357
|
+
const statesFit = (direction: Direction): boolean => {
|
|
358
|
+
const far = fillL + direction * 2 * tuning.solidStep;
|
|
359
|
+
return far >= 0.06 && far <= 0.94;
|
|
360
|
+
};
|
|
361
|
+
const statesReadable = (states: RGB[]): boolean =>
|
|
362
|
+
pairScore(pureLabel, states.map(opaque), surfaces, opacities, ACCENT_TEXT_CONTRAST) >= 1;
|
|
363
|
+
|
|
364
|
+
const statesOk = (direction: Direction): boolean =>
|
|
365
|
+
statesFit(direction) && statesReadable(statesFor(direction));
|
|
366
|
+
const flipped = -dir as Direction;
|
|
367
|
+
let stateDir: Direction = dir;
|
|
368
|
+
if (!statesOk(dir)) {
|
|
369
|
+
// Prefer a direction that keeps the label readable; otherwise at least stay in range.
|
|
370
|
+
if (statesOk(flipped) || (!statesFit(dir) && statesFit(flipped))) stateDir = flipped;
|
|
371
|
+
}
|
|
372
|
+
const [hoverRgb, pressedRgb] = statesFor(stateDir) as [RGB, RGB];
|
|
373
|
+
const solidBgs = [fillRgb, hoverRgb, pressedRgb].map(opaque);
|
|
374
|
+
|
|
375
|
+
// ── Solid label: tinted near-black/near-white when it still reads everywhere, else pure.
|
|
376
|
+
const tinted = opaque(
|
|
377
|
+
toRgb({
|
|
378
|
+
l: polarity === "dark" ? tuning.darkLabelL : tuning.lightLabelL,
|
|
379
|
+
c: Math.min(seedLch.c, LABEL_CHROMA),
|
|
380
|
+
h: seedLch.h,
|
|
381
|
+
})
|
|
382
|
+
);
|
|
383
|
+
const solidFg =
|
|
384
|
+
pairScore(tinted, solidBgs, surfaces, opacities, ACCENT_TEXT_CONTRAST) >= 1 ? tinted : pureLabel;
|
|
385
|
+
|
|
386
|
+
// ── Standalone text and border against every surface.
|
|
387
|
+
const text = solveLightness(hue, seedLch.l, dir, false, (rgb) =>
|
|
388
|
+
surfaceScore(rgb, surfaces, ACCENT_TEXT_CONTRAST)
|
|
389
|
+
);
|
|
390
|
+
const border = solveLightness(hue, seedLch.l, dir, false, (rgb) =>
|
|
391
|
+
surfaceScore(rgb, surfaces, ACCENT_BORDER_CONTRAST)
|
|
392
|
+
);
|
|
393
|
+
|
|
394
|
+
// ── Soft tint relative to the surfaces, with its own solved foreground.
|
|
395
|
+
const softAnchor = dir === -1 ? min(surfaceLs) : Math.max(...surfaceLs);
|
|
396
|
+
const softL = Math.max(
|
|
397
|
+
tuning.softBase.min,
|
|
398
|
+
Math.min(tuning.softBase.max, softAnchor + tuning.softBase.offset)
|
|
399
|
+
);
|
|
400
|
+
const softChroma = Math.min(seedLch.c, tuning.softChroma);
|
|
401
|
+
const softBgs = [0, 1, 2].map((k) =>
|
|
402
|
+
toRgb({ l: clamp01(softL + k * tuning.softStep), c: softChroma, h: seedLch.h })
|
|
403
|
+
);
|
|
404
|
+
const softFg = solveLightness(hue, text.l, dir, false, (rgb) =>
|
|
405
|
+
pairScore(opaque(rgb), softBgs.map(opaque), surfaces, [1], ACCENT_TEXT_CONTRAST)
|
|
406
|
+
);
|
|
407
|
+
|
|
408
|
+
// ── Disabled: desaturated, low-contrast-but-visible.
|
|
409
|
+
const solidDisabled = toRgb({
|
|
410
|
+
l: tuning.solidDisabled.l,
|
|
411
|
+
c: Math.min(seedLch.c, tuning.solidDisabled.c),
|
|
412
|
+
h: seedLch.h,
|
|
413
|
+
});
|
|
414
|
+
const softDisabled = toRgb({
|
|
415
|
+
l: tuning.softDisabled.l,
|
|
416
|
+
c: Math.min(seedLch.c, tuning.softDisabled.c),
|
|
417
|
+
h: seedLch.h,
|
|
418
|
+
});
|
|
419
|
+
|
|
420
|
+
const textHex = rgbToHex(text.rgb);
|
|
421
|
+
const colors: Record<GeneratedAccentRole, string> = {
|
|
422
|
+
"accent.text": textHex,
|
|
423
|
+
"accent.border": rgbToHex(border.rgb),
|
|
424
|
+
"accent.solid.bg": fillHex,
|
|
425
|
+
"accent.solid.bg.hover": rgbToHex(hoverRgb),
|
|
426
|
+
"accent.solid.bg.pressed": rgbToHex(pressedRgb),
|
|
427
|
+
"accent.solid.bg.disabled": rgbToHex(solidDisabled),
|
|
428
|
+
"accent.solid.fg": rgbToHex(solidFg),
|
|
429
|
+
"accent.soft.bg": rgbToHex(softBgs[0] as RGB),
|
|
430
|
+
"accent.soft.bg.hover": rgbToHex(softBgs[1] as RGB),
|
|
431
|
+
"accent.soft.bg.pressed": rgbToHex(softBgs[2] as RGB),
|
|
432
|
+
"accent.soft.bg.disabled": rgbToHex(softDisabled),
|
|
433
|
+
"accent.soft.fg": rgbToHex(softFg.rgb),
|
|
434
|
+
"text.link": textHex,
|
|
435
|
+
};
|
|
436
|
+
|
|
437
|
+
return { colors, diagnostics: checkAccentContrast(colors, context) };
|
|
438
|
+
}
|
|
439
|
+
|
|
440
|
+
// ─── Diagnostics ──────────────────────────────────────────────────────────────
|
|
441
|
+
|
|
442
|
+
interface ContrastPair {
|
|
443
|
+
role: GeneratedAccentRole;
|
|
444
|
+
/** A generated role used as background, or the surface itself. */
|
|
445
|
+
background: GeneratedAccentRole | "surface";
|
|
446
|
+
opacity: number;
|
|
447
|
+
required: number;
|
|
448
|
+
}
|
|
449
|
+
|
|
450
|
+
function contrastPairs(pressedOpacity: number): ContrastPair[] {
|
|
451
|
+
const pairs: ContrastPair[] = [];
|
|
452
|
+
const opacities = pressedOpacity < 1 ? [1, pressedOpacity] : [1];
|
|
453
|
+
for (const background of ["accent.solid.bg", "accent.solid.bg.hover", "accent.solid.bg.pressed"] as const) {
|
|
454
|
+
for (const opacity of opacities) {
|
|
455
|
+
pairs.push({ role: "accent.solid.fg", background, opacity, required: ACCENT_TEXT_CONTRAST });
|
|
456
|
+
}
|
|
457
|
+
}
|
|
458
|
+
for (const background of ["accent.soft.bg", "accent.soft.bg.hover", "accent.soft.bg.pressed"] as const) {
|
|
459
|
+
pairs.push({ role: "accent.soft.fg", background, opacity: 1, required: ACCENT_TEXT_CONTRAST });
|
|
460
|
+
}
|
|
461
|
+
pairs.push({ role: "accent.text", background: "surface", opacity: 1, required: ACCENT_TEXT_CONTRAST });
|
|
462
|
+
pairs.push({ role: "text.link", background: "surface", opacity: 1, required: ACCENT_TEXT_CONTRAST });
|
|
463
|
+
pairs.push({ role: "accent.border", background: "surface", opacity: 1, required: ACCENT_BORDER_CONTRAST });
|
|
464
|
+
return pairs;
|
|
465
|
+
}
|
|
466
|
+
|
|
467
|
+
const show = (value: unknown): string => (typeof value === "string" ? JSON.stringify(value) : String(value));
|
|
468
|
+
|
|
469
|
+
/**
|
|
470
|
+
* Evaluates the final accent roles (after explicit overrides) against their
|
|
471
|
+
* intended backgrounds in the supported domain and returns the failing or
|
|
472
|
+
* unevaluable pairs. Never repairs a colour.
|
|
473
|
+
*/
|
|
474
|
+
export function checkAccentContrast(
|
|
475
|
+
colors: Pick<CanonicalThemeColor, GeneratedAccentRole>,
|
|
476
|
+
context: AccentGenerationContext
|
|
477
|
+
): ContrastDiagnostic[] {
|
|
478
|
+
const diagnostics: ContrastDiagnostic[] = [];
|
|
479
|
+
const pairs = contrastPairs(normalizeOpacity(context.pressedOpacity));
|
|
480
|
+
const values = colors as unknown as Record<string, unknown>;
|
|
481
|
+
|
|
482
|
+
for (const surfaceRole of ACCENT_SURFACE_ROLES) {
|
|
483
|
+
const surfaceValue = context.surfaces[surfaceRole];
|
|
484
|
+
const surface = parseSurface(surfaceValue);
|
|
485
|
+
|
|
486
|
+
for (const pair of pairs) {
|
|
487
|
+
const backgroundRole = pair.background === "surface" ? surfaceRole : pair.background;
|
|
488
|
+
const where = `${pair.role} on ${backgroundRole} over ${surfaceRole} at opacity ${pair.opacity}`;
|
|
489
|
+
const fgValue = values[pair.role];
|
|
490
|
+
const fg = parseColor(fgValue);
|
|
491
|
+
const bgValue = pair.background === "surface" ? surfaceValue : values[pair.background];
|
|
492
|
+
const bg = pair.background === "surface" ? (surface ? opaque(surface) : null) : parseColor(bgValue);
|
|
493
|
+
|
|
494
|
+
const unsupported: string[] = [];
|
|
495
|
+
if (!surface) unsupported.push(`${surfaceRole} ${show(surfaceValue)} is not an opaque sRGB colour`);
|
|
496
|
+
if (!fg) unsupported.push(`${pair.role} ${show(fgValue)} is not an sRGB colour`);
|
|
497
|
+
if (!bg && pair.background !== "surface") {
|
|
498
|
+
unsupported.push(`${pair.background} ${show(bgValue)} is not an sRGB colour`);
|
|
499
|
+
}
|
|
500
|
+
if (!surface || !fg || !bg) {
|
|
501
|
+
diagnostics.push({
|
|
502
|
+
kind: "unsupported",
|
|
503
|
+
role: pair.role,
|
|
504
|
+
background: backgroundRole,
|
|
505
|
+
surface: surfaceRole,
|
|
506
|
+
opacity: pair.opacity,
|
|
507
|
+
ratio: null,
|
|
508
|
+
required: pair.required,
|
|
509
|
+
message: `${where} cannot be evaluated: ${unsupported.join("; ")}.`,
|
|
510
|
+
});
|
|
511
|
+
continue;
|
|
512
|
+
}
|
|
513
|
+
|
|
514
|
+
const ratio = compositedContrast(fg, bg, surface, pair.opacity);
|
|
515
|
+
if (ratio < pair.required) {
|
|
516
|
+
diagnostics.push({
|
|
517
|
+
kind: "unsatisfied",
|
|
518
|
+
role: pair.role,
|
|
519
|
+
background: backgroundRole,
|
|
520
|
+
surface: surfaceRole,
|
|
521
|
+
opacity: pair.opacity,
|
|
522
|
+
ratio,
|
|
523
|
+
required: pair.required,
|
|
524
|
+
message: `${where} is ${ratio.toFixed(2)}:1, below the ${pair.required}:1 target.`,
|
|
525
|
+
});
|
|
526
|
+
}
|
|
527
|
+
}
|
|
528
|
+
}
|
|
529
|
+
return diagnostics;
|
|
530
|
+
}
|
|
@@ -1,49 +1,76 @@
|
|
|
1
|
-
import
|
|
2
|
-
import {
|
|
1
|
+
import { normalizeTokenOverride } from "#theme/legacy-tokens";
|
|
2
|
+
import { normalizeAccentScheme } from "#theme/resolve-config";
|
|
3
|
+
import { defaultDarkTheme, defaultLightTheme } from "#theme/theme";
|
|
4
|
+
import type {
|
|
5
|
+
Accent,
|
|
6
|
+
AccentSchemeInput,
|
|
7
|
+
NormalizedAccent,
|
|
8
|
+
Scheme,
|
|
9
|
+
TerraTheme,
|
|
10
|
+
ThemeColor,
|
|
11
|
+
} from "#theme/types";
|
|
12
|
+
import {
|
|
13
|
+
ACCENT_SURFACE_ROLES,
|
|
14
|
+
type AccentGenerationContext,
|
|
15
|
+
type AccentSurfaceRole,
|
|
16
|
+
generateAccentColors,
|
|
17
|
+
} from "./accent-generator";
|
|
3
18
|
import type { DeepPartial } from "./deep-merge";
|
|
4
19
|
|
|
5
|
-
|
|
6
|
-
|
|
7
|
-
|
|
20
|
+
/**
|
|
21
|
+
* The generation context of a built-in default theme: its four ordinary
|
|
22
|
+
* surfaces and pressed opacity. Used by the {@link normalizeAccent}
|
|
23
|
+
* compatibility wrapper, which has no access to shared/scheme patches.
|
|
24
|
+
*/
|
|
25
|
+
export function defaultAccentContext(scheme: Scheme): AccentGenerationContext {
|
|
26
|
+
const theme = scheme === "light" ? defaultLightTheme : defaultDarkTheme;
|
|
27
|
+
const surfaces = {} as Record<AccentSurfaceRole, string>;
|
|
28
|
+
for (const role of ACCENT_SURFACE_ROLES) surfaces[role] = theme.color[role];
|
|
29
|
+
return { scheme, surfaces, pressedOpacity: theme.opacity.pressed };
|
|
30
|
+
}
|
|
8
31
|
|
|
9
32
|
/**
|
|
10
|
-
*
|
|
11
|
-
*
|
|
12
|
-
* -
|
|
13
|
-
* - subtle: soft translucent tint of the hue with the hue as foreground,
|
|
14
|
-
* - text.accent / text.link / text.on-accent.
|
|
15
|
-
*
|
|
16
|
-
* On `light` the hover/active darken; on `dark` they lighten.
|
|
33
|
+
* Generated accent colours for `seed` with `overrides` applied last. Within
|
|
34
|
+
* the override patch canonical names beat their legacy aliases, while an
|
|
35
|
+
* alias-only key still beats the generated canonical value.
|
|
17
36
|
*/
|
|
18
|
-
function
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
const color = {
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
"action.bg.primary.subtle.hover": withAlpha(hue, isLight ? 0.18 : 0.28),
|
|
32
|
-
"action.bg.primary.subtle.active": withAlpha(hue, isLight ? 0.24 : 0.36),
|
|
33
|
-
"text.accent": hue,
|
|
34
|
-
"text.link": hue,
|
|
35
|
-
"text.on-accent": fg,
|
|
36
|
-
};
|
|
37
|
+
function seedToColorPatch(
|
|
38
|
+
seed: string,
|
|
39
|
+
overrides: Partial<ThemeColor> | undefined,
|
|
40
|
+
scheme: Scheme
|
|
41
|
+
): DeepPartial<TerraTheme> {
|
|
42
|
+
const generated = generateAccentColors(seed, defaultAccentContext(scheme)).colors;
|
|
43
|
+
const color: Record<string, string> = { ...generated };
|
|
44
|
+
const normalized = normalizeTokenOverride({ color: overrides })?.color;
|
|
45
|
+
if (normalized) {
|
|
46
|
+
for (const [key, value] of Object.entries(normalized)) {
|
|
47
|
+
if (value !== undefined) color[key] = value as string;
|
|
48
|
+
}
|
|
49
|
+
}
|
|
37
50
|
return { color } as unknown as DeepPartial<TerraTheme>;
|
|
38
51
|
}
|
|
39
52
|
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
if (
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
dark: hueToPartial(accent.dark, "dark"),
|
|
46
|
-
};
|
|
53
|
+
function normalizeSchemeInput(input: AccentSchemeInput | undefined, scheme: Scheme): DeepPartial<TerraTheme> {
|
|
54
|
+
const normalized = normalizeAccentScheme(input, `accents.<accent>.${scheme}`);
|
|
55
|
+
if (normalized.kind === "none") return {};
|
|
56
|
+
if (normalized.kind === "seed") {
|
|
57
|
+
return seedToColorPatch(normalized.seed, normalized.overrides, scheme);
|
|
47
58
|
}
|
|
48
|
-
|
|
59
|
+
// Preserve the compatibility helper's identity contract for full patches;
|
|
60
|
+
// the resolver itself uses the sanitized copy in `normalized.patch`.
|
|
61
|
+
return input as DeepPartial<TerraTheme>;
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
/**
|
|
65
|
+
* Compatibility wrapper: normalizes any accent input into per-scheme
|
|
66
|
+
* partial-theme overrides. Strings and `{ seed, overrides }` are generated
|
|
67
|
+
* against the DEFAULT themes' surfaces and pressed opacity (the resolver
|
|
68
|
+
* pipeline uses the actual resolved surfaces instead); full theme patches
|
|
69
|
+
* pass through unchanged. Pure and independent of the registry.
|
|
70
|
+
*/
|
|
71
|
+
export function normalizeAccent(accent: Accent): NormalizedAccent {
|
|
72
|
+
return {
|
|
73
|
+
light: normalizeSchemeInput(accent.light, "light"),
|
|
74
|
+
dark: normalizeSchemeInput(accent.dark, "dark"),
|
|
75
|
+
};
|
|
49
76
|
}
|