@chipmobilesdk/rn-theme 0.2.1 → 1.0.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/CHANGELOG.md +91 -23
- package/NOTICE +9 -0
- package/README.md +297 -154
- package/package.json +49 -50
- package/skills/sdk-rn-theme/SKILL.md +45 -30
- package/src/color/categorical.ts +33 -0
- package/src/color/contrast.ts +230 -0
- package/src/color/parse.ts +120 -0
- package/src/color/roles.ts +262 -0
- package/src/index.ts +23 -17
- package/src/integration/navigationTheme.ts +51 -0
- package/src/presets/byTone.ts +28 -0
- package/src/presets/layout.ts +38 -0
- package/src/presets/themePresets.ts +101 -0
- package/src/theme/buildThemes.ts +178 -0
- package/src/theme/errors.ts +34 -0
- package/src/theme/overrides.ts +142 -0
- package/src/theme/validateThemeConfig.ts +171 -0
- package/src/tokens/defaults.ts +103 -46
- package/src/tokens/typography.ts +144 -64
- package/src/types/brand.ts +16 -0
- package/src/types/colors.ts +80 -11
- package/src/types/index.ts +44 -5
- package/src/types/theme.ts +161 -0
- package/src/types/tokens.ts +47 -5
- package/src/types/typography.ts +46 -10
- package/src/unistyles/createUnistylesConfig.ts +165 -177
- package/src/unistyles/themeMode.ts +28 -0
- package/src/vendor/mcu/LICENSE +191 -0
- package/src/vendor/mcu/hct/cam16.ts +419 -0
- package/src/vendor/mcu/hct/hct.ts +216 -0
- package/src/vendor/mcu/hct/hct_solver.ts +557 -0
- package/src/vendor/mcu/hct/viewing_conditions.ts +121 -0
- package/src/vendor/mcu/index.ts +5 -0
- package/src/vendor/mcu/palettes/tonal_palette.ts +172 -0
- package/src/vendor/mcu/utils/color_utils.ts +302 -0
- package/src/vendor/mcu/utils/math_utils.ts +148 -0
- package/src/types/style.ts +0 -26
- package/src/unistyles/types.ts +0 -24
- package/src/unistyles/validateThemeConfig.ts +0 -154
- package/src/utils/colorGeneration.ts +0 -87
- package/src/utils/colorMath.ts +0 -55
package/package.json
CHANGED
|
@@ -1,50 +1,49 @@
|
|
|
1
|
-
{
|
|
2
|
-
"name": "@chipmobilesdk/rn-theme",
|
|
3
|
-
"version": "0.
|
|
4
|
-
"private": false,
|
|
5
|
-
"license": "UNLICENSED",
|
|
6
|
-
"main": "src/index.ts",
|
|
7
|
-
"types": "src/index.ts",
|
|
8
|
-
"exports": {
|
|
9
|
-
".": {
|
|
10
|
-
"types": "./src/index.ts",
|
|
11
|
-
"react-native": "./src/index.ts",
|
|
12
|
-
"default": "./src/index.ts"
|
|
13
|
-
}
|
|
14
|
-
},
|
|
15
|
-
"files": [
|
|
16
|
-
"src/index.ts",
|
|
17
|
-
"src/
|
|
18
|
-
"src/
|
|
19
|
-
"src/
|
|
20
|
-
"src/
|
|
21
|
-
"
|
|
22
|
-
"
|
|
23
|
-
"
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
"
|
|
28
|
-
"
|
|
29
|
-
|
|
30
|
-
"
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
"react": ">=
|
|
38
|
-
"react-native": ">=
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
"
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
}
|
|
1
|
+
{
|
|
2
|
+
"name": "@chipmobilesdk/rn-theme",
|
|
3
|
+
"version": "1.0.0",
|
|
4
|
+
"private": false,
|
|
5
|
+
"license": "UNLICENSED",
|
|
6
|
+
"main": "src/index.ts",
|
|
7
|
+
"types": "src/index.ts",
|
|
8
|
+
"exports": {
|
|
9
|
+
".": {
|
|
10
|
+
"types": "./src/index.ts",
|
|
11
|
+
"react-native": "./src/index.ts",
|
|
12
|
+
"default": "./src/index.ts"
|
|
13
|
+
}
|
|
14
|
+
},
|
|
15
|
+
"files": [
|
|
16
|
+
"src/index.ts",
|
|
17
|
+
"src/color",
|
|
18
|
+
"src/integration",
|
|
19
|
+
"src/presets",
|
|
20
|
+
"src/theme",
|
|
21
|
+
"src/tokens",
|
|
22
|
+
"src/types",
|
|
23
|
+
"src/unistyles",
|
|
24
|
+
"src/vendor",
|
|
25
|
+
"skills",
|
|
26
|
+
"README.md",
|
|
27
|
+
"CHANGELOG.md",
|
|
28
|
+
"NOTICE"
|
|
29
|
+
],
|
|
30
|
+
"sideEffects": false,
|
|
31
|
+
"scripts": {
|
|
32
|
+
"typecheck": "tsc -p tsconfig.json --noEmit",
|
|
33
|
+
"pack:dry-run": "npm pack --dry-run"
|
|
34
|
+
},
|
|
35
|
+
"peerDependencies": {
|
|
36
|
+
"react": ">=19",
|
|
37
|
+
"react-native": ">=0.85",
|
|
38
|
+
"react-native-unistyles": ">=3.2"
|
|
39
|
+
},
|
|
40
|
+
"peerDependenciesMeta": {
|
|
41
|
+
"react-native-unistyles": {
|
|
42
|
+
"optional": false
|
|
43
|
+
}
|
|
44
|
+
},
|
|
45
|
+
"publishConfig": {
|
|
46
|
+
"access": "public",
|
|
47
|
+
"registry": "https://registry.npmjs.org/"
|
|
48
|
+
}
|
|
49
|
+
}
|
|
@@ -1,59 +1,74 @@
|
|
|
1
1
|
---
|
|
2
2
|
name: sdk-rn-theme
|
|
3
|
-
description: "Theming for @chipmobilesdk/rn-theme: brand palettes, Unistyles config,
|
|
3
|
+
description: "Theming for @chipmobilesdk/rn-theme: accessible brand palettes, Unistyles config, colour/typography/spacing tokens, tones and byTone variants, layout and text presets, app token extension, dark mode and theme mode. Use when setting up theming, writing or shortening a stylesheet, adding a design token, wiring light/dark, or debugging Unistyles setup, stale styles or type errors."
|
|
4
4
|
metadata:
|
|
5
5
|
package: "@chipmobilesdk/rn-theme"
|
|
6
|
-
packageVersion: "0.
|
|
6
|
+
packageVersion: "1.0.0"
|
|
7
7
|
user-invocable: true
|
|
8
8
|
disable-model-invocation: false
|
|
9
9
|
---
|
|
10
10
|
|
|
11
11
|
# @chipmobilesdk/rn-theme
|
|
12
12
|
|
|
13
|
-
|
|
14
|
-
|
|
13
|
+
Builds light/dark (and named) themes from three brand colours with guaranteed WCAG contrast,
|
|
14
|
+
a full token set, and style presets, and produces the `StyleSheet.configure` object for
|
|
15
|
+
Unistyles 3. Single source of truth for visual tokens; app tokens join through `extend`.
|
|
15
16
|
|
|
16
17
|
## Use this when
|
|
17
18
|
|
|
18
|
-
- Bootstrapping theming
|
|
19
|
-
-
|
|
20
|
-
- Wiring light/dark
|
|
21
|
-
- A
|
|
19
|
+
- Bootstrapping theming, or adding an app-specific token, tone or theme
|
|
20
|
+
- Writing a stylesheet — check presets before hand-writing flex, text, border or box styles
|
|
21
|
+
- Wiring light/dark, a user theme preference, or a React Navigation theme
|
|
22
|
+
- A style does not update on theme/variant change, or a screen renders unstyled
|
|
23
|
+
- Style arrays, variants or dynamic functions produce TypeScript errors
|
|
22
24
|
|
|
23
25
|
## Do not use this for
|
|
24
26
|
|
|
25
|
-
- Chart
|
|
26
|
-
-
|
|
27
|
+
- Chart rendering — that is `sdk-rn-chart` (feed it `theme.categorical`)
|
|
28
|
+
- UI components — this package ships none
|
|
27
29
|
|
|
28
|
-
##
|
|
30
|
+
## Rules that prevent real bugs
|
|
29
31
|
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
32
|
+
- **Never cast or alias the theme inside `StyleSheet.create`** (`const theme = appTheme(raw)`):
|
|
33
|
+
the Unistyles Babel plugin then records no dependencies and the styles stop updating on
|
|
34
|
+
theme change. Declare app tokens with `extend` so `theme` is already typed.
|
|
35
|
+
- **Variants**: keep a literal `variants` key and call `byTone(theme, …)` inline under it.
|
|
36
|
+
Use one tone-colour style combined by array (`[styles.title, styles.toneText]`) instead of
|
|
37
|
+
repeating variants per text style.
|
|
38
|
+
- **Muted text is `onSurfaceVariant`** (or `text.*Muted`), never `disabled` — `disabled` is
|
|
39
|
+
a contrast-exempt state colour. Dividers use `outlineVariant`; interactive boundaries
|
|
40
|
+
`outline`. There is no `color.border`.
|
|
41
|
+
- **Weights**: spread `theme.font.bold` etc.; a literal `fontWeight` breaks custom fonts on
|
|
42
|
+
Android.
|
|
43
|
+
- Pass the config inline (or `satisfies ThemeConfig`); a `ThemeConfig` annotation erases
|
|
44
|
+
extension and theme-name types.
|
|
45
|
+
- Configure Unistyles **before** the first themed component renders; the app (not the
|
|
46
|
+
package) augments `UnistylesThemes` / `UnistylesBreakpoints` from `typeof config`.
|
|
47
|
+
- Merge Unistyles styles with arrays, never object spread of Unistyles entries.
|
|
34
48
|
|
|
35
|
-
## Public API (
|
|
49
|
+
## Public API (v1.0.0)
|
|
36
50
|
|
|
37
|
-
Runtime:
|
|
51
|
+
Runtime: `createUnistylesConfig`, `buildThemes`, `applyThemeMode`, `byTone`, `layout`,
|
|
52
|
+
`presets`, `auditTheme`, `contrastRatio`, `withAlpha`, `toNavigationTheme`,
|
|
53
|
+
`DEFAULT_BREAKPOINTS`, `ThemeConfigError`, `ThemeContrastError`.
|
|
38
54
|
|
|
39
|
-
|
|
40
|
-
|
|
55
|
+
Theme groups: `color`, `tone`, `typography`, `font`, `fontWeight`, `spacing`, `radius`,
|
|
56
|
+
`borderWidth`, `size`, `opacity`, `motion`, `shadow`, `zIndex`, `maxFontScale`,
|
|
57
|
+
`categorical`, presets `text`, `stack`, `inline`, `border`, `box`, plus `extend` groups.
|
|
41
58
|
|
|
42
|
-
|
|
43
|
-
`StyleConfig`, `StyleTheme`, `DeepPartial`, `UnistylesThemeFromBrand`
|
|
44
|
-
|
|
45
|
-
Anything not exported from `src/index.ts` is private and may change without a major bump —
|
|
46
|
-
see `## Private Modules` and `## Semver and Migration` in the README.
|
|
59
|
+
Anything not exported from the package root is private.
|
|
47
60
|
|
|
48
61
|
## Where the detail lives
|
|
49
62
|
|
|
50
|
-
Read only the section you need from
|
|
51
|
-
`node_modules/@chipmobilesdk/rn-theme/README.md`:
|
|
63
|
+
Read only the section you need from `node_modules/@chipmobilesdk/rn-theme/README.md`:
|
|
52
64
|
|
|
53
65
|
| Need | Section |
|
|
54
66
|
|---|---|
|
|
55
|
-
| Entry-point wiring | `## Setup Order`, `## Theme Start Strategy` |
|
|
56
|
-
|
|
|
67
|
+
| Entry-point wiring, augmentation | `## Setup Order`, `## Theme Start Strategy` |
|
|
68
|
+
| Which colour role to use; contrast guarantees | `## Colour System` |
|
|
69
|
+
| Token names and values; custom fonts; font scale | `## Tokens` |
|
|
70
|
+
| Shortening a stylesheet; plugin rules | `## Style Presets` |
|
|
71
|
+
| App tokens, custom tones, named themes | `## Extending the Theme` |
|
|
57
72
|
| Config rejected at runtime | `## Configuration Validation` |
|
|
58
|
-
|
|
|
59
|
-
| Upgrading
|
|
73
|
+
| Style arrays, variants, dynamic-style type errors | `## Unistyles 3 Style Authoring and TypeScript Diagnostics` |
|
|
74
|
+
| Upgrading from 0.2.x | `## Migrating from 0.2.x`, then `CHANGELOG.md` `## 1.0.0` |
|
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
import { Hct } from '../vendor/mcu';
|
|
2
|
+
import type { ColorScheme } from '../types/colors';
|
|
3
|
+
import { argbFromColor, hexFromArgb } from './parse';
|
|
4
|
+
|
|
5
|
+
const SERIES_COUNT = 8;
|
|
6
|
+
const SERIES_CHROMA = 48;
|
|
7
|
+
/** 3 × 45°: consecutive series land far apart on the hue wheel. */
|
|
8
|
+
const HUE_STEP = 135;
|
|
9
|
+
|
|
10
|
+
/**
|
|
11
|
+
* Alternating tones. Every tone clears 3:1 against `surface` (98 / 6), and the
|
|
12
|
+
* 15-unit L* step keeps neighbours apart even when colour vision cannot
|
|
13
|
+
* separate their hues.
|
|
14
|
+
*/
|
|
15
|
+
const SERIES_TONES: Record<ColorScheme, [number, number]> = {
|
|
16
|
+
light: [40, 55],
|
|
17
|
+
dark: [80, 60],
|
|
18
|
+
};
|
|
19
|
+
|
|
20
|
+
/**
|
|
21
|
+
* Categorical series colours for charts, starting at the primary hue
|
|
22
|
+
* (spec US-11). Status colours stay free for status.
|
|
23
|
+
*/
|
|
24
|
+
export function buildCategorical(primary: string, scheme: ColorScheme): string[] {
|
|
25
|
+
const startHue = Hct.fromInt(argbFromColor(primary)).hue;
|
|
26
|
+
const tones = SERIES_TONES[scheme];
|
|
27
|
+
|
|
28
|
+
return Array.from({ length: SERIES_COUNT }, (_, index) =>
|
|
29
|
+
hexFromArgb(
|
|
30
|
+
Hct.from((startHue + index * HUE_STEP) % 360, SERIES_CHROMA, tones[index % 2]).toInt(),
|
|
31
|
+
),
|
|
32
|
+
);
|
|
33
|
+
}
|
|
@@ -0,0 +1,230 @@
|
|
|
1
|
+
import { parseColor, type Rgba } from './parse';
|
|
2
|
+
|
|
3
|
+
/** WCAG 2.x minimum ratio for text (1.4.3). */
|
|
4
|
+
export const TEXT_CONTRAST = 4.5;
|
|
5
|
+
/** WCAG 2.x minimum ratio for non-text boundaries (1.4.11). */
|
|
6
|
+
export const NON_TEXT_CONTRAST = 3;
|
|
7
|
+
|
|
8
|
+
const WHITE: Rgba = { r: 255, g: 255, b: 255, a: 1 };
|
|
9
|
+
const LUMINANCE_OFFSET = 0.05;
|
|
10
|
+
|
|
11
|
+
export interface ContrastFailure {
|
|
12
|
+
/** Theme path of the foreground value, e.g. `color.onPrimary`. */
|
|
13
|
+
foreground: string;
|
|
14
|
+
/** Theme path of the background value, e.g. `color.primary`. */
|
|
15
|
+
background: string;
|
|
16
|
+
ratio: number;
|
|
17
|
+
target: number;
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
interface ContrastPair {
|
|
21
|
+
foreground: string;
|
|
22
|
+
background: string;
|
|
23
|
+
target: number;
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
const ACCENT_ROLES = [
|
|
27
|
+
'primary',
|
|
28
|
+
'secondary',
|
|
29
|
+
'tertiary',
|
|
30
|
+
'error',
|
|
31
|
+
'success',
|
|
32
|
+
'warning',
|
|
33
|
+
'info',
|
|
34
|
+
] as const;
|
|
35
|
+
const SEMANTIC_ROLES = ['error', 'success', 'warning', 'info'] as const;
|
|
36
|
+
const BASE_BACKGROUNDS = ['color.background', 'color.surface'] as const;
|
|
37
|
+
const SURFACE_TIERS = [
|
|
38
|
+
'surfaceContainerLowest',
|
|
39
|
+
'surfaceContainerLow',
|
|
40
|
+
'surfaceContainer',
|
|
41
|
+
'surfaceContainerHigh',
|
|
42
|
+
'surfaceContainerHighest',
|
|
43
|
+
] as const;
|
|
44
|
+
|
|
45
|
+
function capitalize(value: string): string {
|
|
46
|
+
return value.charAt(0).toUpperCase() + value.slice(1);
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
function onBase(foreground: string, target = TEXT_CONTRAST): ContrastPair[] {
|
|
50
|
+
return BASE_BACKGROUNDS.map(background => ({ foreground, background, target }));
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
/**
|
|
54
|
+
* Every pair the package guarantees, expressed as theme paths. Paths missing
|
|
55
|
+
* from a theme (e.g. presets an older theme does not have) are skipped.
|
|
56
|
+
*/
|
|
57
|
+
function guaranteedPairs(theme: object): ContrastPair[] {
|
|
58
|
+
const pairs: ContrastPair[] = [];
|
|
59
|
+
|
|
60
|
+
for (const role of SEMANTIC_ROLES) {
|
|
61
|
+
pairs.push(...onBase(`color.${role}`));
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
for (const role of ACCENT_ROLES) {
|
|
65
|
+
const on = `on${capitalize(role)}`;
|
|
66
|
+
pairs.push(
|
|
67
|
+
{ foreground: `color.${on}`, background: `color.${role}`, target: TEXT_CONTRAST },
|
|
68
|
+
{
|
|
69
|
+
foreground: `color.${on}Container`,
|
|
70
|
+
background: `color.${role}Container`,
|
|
71
|
+
target: TEXT_CONTRAST,
|
|
72
|
+
},
|
|
73
|
+
);
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
pairs.push(
|
|
77
|
+
{ foreground: 'color.onBackground', background: 'color.background', target: TEXT_CONTRAST },
|
|
78
|
+
{ foreground: 'color.onSurface', background: 'color.surface', target: TEXT_CONTRAST },
|
|
79
|
+
{
|
|
80
|
+
foreground: 'color.onSurfaceVariant',
|
|
81
|
+
background: 'color.surfaceVariant',
|
|
82
|
+
target: TEXT_CONTRAST,
|
|
83
|
+
},
|
|
84
|
+
...onBase('color.onSurfaceVariant'),
|
|
85
|
+
{
|
|
86
|
+
foreground: 'color.inverseOnSurface',
|
|
87
|
+
background: 'color.inverseSurface',
|
|
88
|
+
target: TEXT_CONTRAST,
|
|
89
|
+
},
|
|
90
|
+
{
|
|
91
|
+
foreground: 'color.inversePrimary',
|
|
92
|
+
background: 'color.inverseSurface',
|
|
93
|
+
target: TEXT_CONTRAST,
|
|
94
|
+
},
|
|
95
|
+
...SURFACE_TIERS.map(tier => ({
|
|
96
|
+
foreground: 'color.onSurface',
|
|
97
|
+
background: `color.${tier}`,
|
|
98
|
+
target: TEXT_CONTRAST,
|
|
99
|
+
})),
|
|
100
|
+
...onBase('color.outline', NON_TEXT_CONTRAST),
|
|
101
|
+
);
|
|
102
|
+
|
|
103
|
+
const tones = readPath(theme, 'tone');
|
|
104
|
+
|
|
105
|
+
if (isRecord(tones)) {
|
|
106
|
+
for (const name of Object.keys(tones)) {
|
|
107
|
+
const tone = `tone.${name}`;
|
|
108
|
+
pairs.push(
|
|
109
|
+
...onBase(`${tone}.fg`),
|
|
110
|
+
{ foreground: `${tone}.onContainer`, background: `${tone}.container`, target: TEXT_CONTRAST },
|
|
111
|
+
{ foreground: `${tone}.onFill`, background: `${tone}.fill`, target: TEXT_CONTRAST },
|
|
112
|
+
{ foreground: `${tone}.border`, background: 'color.background', target: NON_TEXT_CONTRAST },
|
|
113
|
+
);
|
|
114
|
+
}
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
const text = readPath(theme, 'text');
|
|
118
|
+
|
|
119
|
+
if (isRecord(text)) {
|
|
120
|
+
for (const name of Object.keys(text)) {
|
|
121
|
+
// `text.disabled` deliberately uses the contrast-exempt disabled colour.
|
|
122
|
+
if (name !== 'disabled') {
|
|
123
|
+
pairs.push(...onBase(`text.${name}.color`));
|
|
124
|
+
}
|
|
125
|
+
}
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
const categorical = readPath(theme, 'categorical');
|
|
129
|
+
|
|
130
|
+
if (Array.isArray(categorical)) {
|
|
131
|
+
categorical.forEach((_, index) => {
|
|
132
|
+
pairs.push({
|
|
133
|
+
foreground: `categorical.${index}`,
|
|
134
|
+
background: 'color.surface',
|
|
135
|
+
target: NON_TEXT_CONTRAST,
|
|
136
|
+
});
|
|
137
|
+
});
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
return pairs;
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
function isRecord(value: unknown): value is Record<string, unknown> {
|
|
144
|
+
return typeof value === 'object' && value !== null && !Array.isArray(value);
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
function readPath(source: unknown, path: string): unknown {
|
|
148
|
+
return path.split('.').reduce<unknown>((node, key) => {
|
|
149
|
+
if (Array.isArray(node)) {
|
|
150
|
+
return node[Number(key)];
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
return isRecord(node) ? node[key] : undefined;
|
|
154
|
+
}, source);
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
function channelLuminance(channel: number): number {
|
|
158
|
+
const value = channel / 255;
|
|
159
|
+
|
|
160
|
+
return value <= 0.04045 ? value / 12.92 : ((value + 0.055) / 1.055) ** 2.4;
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
function relativeLuminance({ r, g, b }: Rgba): number {
|
|
164
|
+
return (
|
|
165
|
+
0.2126 * channelLuminance(r) +
|
|
166
|
+
0.7152 * channelLuminance(g) +
|
|
167
|
+
0.0722 * channelLuminance(b)
|
|
168
|
+
);
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
function composite(top: Rgba, bottom: Rgba): Rgba {
|
|
172
|
+
const mix = (upper: number, lower: number) => upper * top.a + lower * (1 - top.a);
|
|
173
|
+
|
|
174
|
+
return { r: mix(top.r, bottom.r), g: mix(top.g, bottom.g), b: mix(top.b, bottom.b), a: 1 };
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
/**
|
|
178
|
+
* WCAG contrast ratio of two colour strings. Translucent colours are
|
|
179
|
+
* composited first: the background over `base` (default white), then the
|
|
180
|
+
* foreground over the result.
|
|
181
|
+
*/
|
|
182
|
+
export function contrastRatio(foreground: string, background: string, base?: string): number {
|
|
183
|
+
const fg = parseColor(foreground);
|
|
184
|
+
const bg = parseColor(background);
|
|
185
|
+
const baseColor = (base != null ? parseColor(base) : null) ?? WHITE;
|
|
186
|
+
|
|
187
|
+
if (!fg || !bg) {
|
|
188
|
+
throw new Error(`contrastRatio: unsupported colour "${!fg ? foreground : background}".`);
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
const opaqueBackground = composite(bg, composite(baseColor, WHITE));
|
|
192
|
+
const opaqueForeground = composite(fg, opaqueBackground);
|
|
193
|
+
const lighter = Math.max(relativeLuminance(opaqueForeground), relativeLuminance(opaqueBackground));
|
|
194
|
+
const darker = Math.min(relativeLuminance(opaqueForeground), relativeLuminance(opaqueBackground));
|
|
195
|
+
|
|
196
|
+
return (lighter + LUMINANCE_OFFSET) / (darker + LUMINANCE_OFFSET);
|
|
197
|
+
}
|
|
198
|
+
|
|
199
|
+
/**
|
|
200
|
+
* Checks every contrast pair the package guarantees against the given theme
|
|
201
|
+
* and returns the ones below target. An empty list means the theme passes.
|
|
202
|
+
* `color.disabled` is never audited: inactive components are exempt from
|
|
203
|
+
* WCAG 1.4.3.
|
|
204
|
+
*/
|
|
205
|
+
export function auditTheme(theme: object): ContrastFailure[] {
|
|
206
|
+
const base = readPath(theme, 'color.background');
|
|
207
|
+
const failures: ContrastFailure[] = [];
|
|
208
|
+
|
|
209
|
+
for (const pair of guaranteedPairs(theme)) {
|
|
210
|
+
const foreground = readPath(theme, pair.foreground);
|
|
211
|
+
const background = readPath(theme, pair.background);
|
|
212
|
+
|
|
213
|
+
if (typeof foreground !== 'string' || typeof background !== 'string') {
|
|
214
|
+
continue;
|
|
215
|
+
}
|
|
216
|
+
|
|
217
|
+
const ratio = contrastRatio(
|
|
218
|
+
foreground,
|
|
219
|
+
background,
|
|
220
|
+
typeof base === 'string' ? base : undefined,
|
|
221
|
+
);
|
|
222
|
+
|
|
223
|
+
if (ratio < pair.target) {
|
|
224
|
+
// Floored so a reported ratio never reads as passing (4.499 → 4.49).
|
|
225
|
+
failures.push({ ...pair, ratio: Math.floor(ratio * 100) / 100 });
|
|
226
|
+
}
|
|
227
|
+
}
|
|
228
|
+
|
|
229
|
+
return failures;
|
|
230
|
+
}
|
|
@@ -0,0 +1,120 @@
|
|
|
1
|
+
import { argbFromRgb } from '../vendor/mcu';
|
|
2
|
+
|
|
3
|
+
/** A colour channel set: red/green/blue 0–255, alpha 0–1. */
|
|
4
|
+
export interface Rgba {
|
|
5
|
+
r: number;
|
|
6
|
+
g: number;
|
|
7
|
+
b: number;
|
|
8
|
+
a: number;
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
const HEX_PATTERN = /^#([0-9a-f]{3}|[0-9a-f]{4}|[0-9a-f]{6}|[0-9a-f]{8})$/i;
|
|
12
|
+
const RGB_PATTERN =
|
|
13
|
+
/^rgba?\(\s*(\d{1,3})\s*,\s*(\d{1,3})\s*,\s*(\d{1,3})\s*(?:,\s*(\d*\.?\d+)\s*)?\)$/i;
|
|
14
|
+
const CHANNEL_MAX = 255;
|
|
15
|
+
|
|
16
|
+
/**
|
|
17
|
+
* Parses the colour formats the package accepts: `#RGB`, `#RGBA`, `#RRGGBB`,
|
|
18
|
+
* `#RRGGBBAA`, `rgb(r, g, b)` and `rgba(r, g, b, a)`. Returns `null` for
|
|
19
|
+
* anything else, including named colours and `hsl()`.
|
|
20
|
+
*/
|
|
21
|
+
export function parseColor(value: unknown): Rgba | null {
|
|
22
|
+
if (typeof value !== 'string') {
|
|
23
|
+
return null;
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
const color = value.trim();
|
|
27
|
+
const hex = HEX_PATTERN.exec(color);
|
|
28
|
+
|
|
29
|
+
if (hex) {
|
|
30
|
+
return parseHex(hex[1]);
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
const rgb = RGB_PATTERN.exec(color);
|
|
34
|
+
|
|
35
|
+
if (!rgb) {
|
|
36
|
+
return null;
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
const [r, g, b] = [rgb[1], rgb[2], rgb[3]].map(Number);
|
|
40
|
+
const a = rgb[4] == null ? 1 : Number(rgb[4]);
|
|
41
|
+
|
|
42
|
+
if ([r, g, b].some(channel => channel > CHANNEL_MAX) || a > 1) {
|
|
43
|
+
return null;
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
return { r, g, b, a };
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
function parseHex(digits: string): Rgba {
|
|
50
|
+
const full =
|
|
51
|
+
digits.length <= 4
|
|
52
|
+
? digits
|
|
53
|
+
.split('')
|
|
54
|
+
.map(digit => digit + digit)
|
|
55
|
+
.join('')
|
|
56
|
+
: digits;
|
|
57
|
+
const channel = (index: number) =>
|
|
58
|
+
parseInt(full.slice(index * 2, index * 2 + 2), 16);
|
|
59
|
+
|
|
60
|
+
return {
|
|
61
|
+
r: channel(0),
|
|
62
|
+
g: channel(1),
|
|
63
|
+
b: channel(2),
|
|
64
|
+
a: full.length === 8 ? channel(3) / CHANNEL_MAX : 1,
|
|
65
|
+
};
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
export function isValidColor(value: unknown): value is string {
|
|
69
|
+
return parseColor(value) !== null;
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
export function isOpaqueColor(value: unknown): value is string {
|
|
73
|
+
return parseColor(value)?.a === 1;
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
/** ARGB integer of an opaque colour string; throws for anything else. */
|
|
77
|
+
export function argbFromColor(value: string): number {
|
|
78
|
+
const rgba = parseColor(value);
|
|
79
|
+
|
|
80
|
+
if (!rgba || rgba.a !== 1) {
|
|
81
|
+
throw new Error(`Expected an opaque colour, received "${value}".`);
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
return argbFromRgb(rgba.r, rgba.g, rgba.b);
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
function toHexPair(channel: number): string {
|
|
88
|
+
return Math.round(channel).toString(16).padStart(2, '0').toUpperCase();
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
export function hexFromArgb(argb: number): string {
|
|
92
|
+
// eslint-disable-next-line no-bitwise -- unpacking the ARGB integer's channels
|
|
93
|
+
const [r, g, b] = [(argb >> 16) & 0xff, (argb >> 8) & 0xff, argb & 0xff];
|
|
94
|
+
|
|
95
|
+
return `#${toHexPair(r)}${toHexPair(g)}${toHexPair(b)}`;
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
export function formatColor({ r, g, b, a }: Rgba): string {
|
|
99
|
+
const rgb = `#${toHexPair(r)}${toHexPair(g)}${toHexPair(b)}`;
|
|
100
|
+
|
|
101
|
+
return a >= 1 ? rgb : `${rgb}${toHexPair(a * CHANNEL_MAX)}`;
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
/**
|
|
105
|
+
* Applies an opacity to a colour, multiplying any alpha it already has.
|
|
106
|
+
* Returns `#RRGGBBAA` (or `#RRGGBB` when the result is opaque).
|
|
107
|
+
*/
|
|
108
|
+
export function withAlpha(color: string, alpha: number): string {
|
|
109
|
+
const rgba = parseColor(color);
|
|
110
|
+
|
|
111
|
+
if (!rgba) {
|
|
112
|
+
throw new Error(`withAlpha: "${color}" is not a supported colour.`);
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
if (!Number.isFinite(alpha) || alpha < 0 || alpha > 1) {
|
|
116
|
+
throw new Error(`withAlpha: alpha must be between 0 and 1, received ${alpha}.`);
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
return formatColor({ ...rgba, a: rgba.a * alpha });
|
|
120
|
+
}
|