@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.
Files changed (42) hide show
  1. package/CHANGELOG.md +91 -23
  2. package/NOTICE +9 -0
  3. package/README.md +297 -154
  4. package/package.json +49 -50
  5. package/skills/sdk-rn-theme/SKILL.md +45 -30
  6. package/src/color/categorical.ts +33 -0
  7. package/src/color/contrast.ts +230 -0
  8. package/src/color/parse.ts +120 -0
  9. package/src/color/roles.ts +262 -0
  10. package/src/index.ts +23 -17
  11. package/src/integration/navigationTheme.ts +51 -0
  12. package/src/presets/byTone.ts +28 -0
  13. package/src/presets/layout.ts +38 -0
  14. package/src/presets/themePresets.ts +101 -0
  15. package/src/theme/buildThemes.ts +178 -0
  16. package/src/theme/errors.ts +34 -0
  17. package/src/theme/overrides.ts +142 -0
  18. package/src/theme/validateThemeConfig.ts +171 -0
  19. package/src/tokens/defaults.ts +103 -46
  20. package/src/tokens/typography.ts +144 -64
  21. package/src/types/brand.ts +16 -0
  22. package/src/types/colors.ts +80 -11
  23. package/src/types/index.ts +44 -5
  24. package/src/types/theme.ts +161 -0
  25. package/src/types/tokens.ts +47 -5
  26. package/src/types/typography.ts +46 -10
  27. package/src/unistyles/createUnistylesConfig.ts +165 -177
  28. package/src/unistyles/themeMode.ts +28 -0
  29. package/src/vendor/mcu/LICENSE +191 -0
  30. package/src/vendor/mcu/hct/cam16.ts +419 -0
  31. package/src/vendor/mcu/hct/hct.ts +216 -0
  32. package/src/vendor/mcu/hct/hct_solver.ts +557 -0
  33. package/src/vendor/mcu/hct/viewing_conditions.ts +121 -0
  34. package/src/vendor/mcu/index.ts +5 -0
  35. package/src/vendor/mcu/palettes/tonal_palette.ts +172 -0
  36. package/src/vendor/mcu/utils/color_utils.ts +302 -0
  37. package/src/vendor/mcu/utils/math_utils.ts +148 -0
  38. package/src/types/style.ts +0 -26
  39. package/src/unistyles/types.ts +0 -24
  40. package/src/unistyles/validateThemeConfig.ts +0 -154
  41. package/src/utils/colorGeneration.ts +0 -87
  42. package/src/utils/colorMath.ts +0 -55
package/package.json CHANGED
@@ -1,50 +1,49 @@
1
- {
2
- "name": "@chipmobilesdk/rn-theme",
3
- "version": "0.2.1",
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/tokens",
18
- "src/types",
19
- "src/unistyles",
20
- "src/utils",
21
- "skills",
22
- "README.md",
23
- "CHANGELOG.md"
24
- ],
25
- "sideEffects": false,
26
- "scripts": {
27
- "typecheck": "tsc -p tsconfig.json --noEmit",
28
- "pack:dry-run": "npm pack --dry-run"
29
- },
30
- "dependencies": {
31
- "tinycolor2": "^1.6.0"
32
- },
33
- "devDependencies": {
34
- "@types/tinycolor2": "^1.4.6"
35
- },
36
- "peerDependencies": {
37
- "react": ">=19",
38
- "react-native": ">=0.85",
39
- "react-native-unistyles": ">=3.2"
40
- },
41
- "peerDependenciesMeta": {
42
- "react-native-unistyles": {
43
- "optional": false
44
- }
45
- },
46
- "publishConfig": {
47
- "access": "public",
48
- "registry": "https://registry.npmjs.org/"
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, color/typography tokens, dark mode. Use when setting up theming, adding a design token, wiring light/dark, or debugging theme setup order."
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.2.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
- Generates light and dark palettes from brand colors and produces a Unistyles config.
14
- This is the single source of truth for color, typography, spacing and radius tokens.
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 in a new app
19
- - Adding or renaming a design token
20
- - Wiring light/dark mode or a theme start strategy
21
- - A screen renders unstyled, or `useStyles` returns an unexpected theme
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-specific theming — that is `sdk-rn-chart` (`ChartThemeProvider`)
26
- - Per-component one-off styles that do not belong in the token set
27
+ - Chart rendering — that is `sdk-rn-chart` (feed it `theme.categorical`)
28
+ - UI components — this package ships none
27
29
 
28
- ## Setup order matters
30
+ ## Rules that prevent real bugs
29
31
 
30
- Unistyles must be configured **before** the first component that consumes a style
31
- renders. Getting this wrong produces an app that renders with no styling and no error.
32
- Read the `## Setup Order` and `## Theme Start Strategy` sections of the README before
33
- changing entry-point wiring.
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 (v0.2.0)
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
- - `generateLightPalette(brand)` / `generateDarkPalette(brand)` — derive full palettes
40
- - `createUnistylesConfig(...)` — build the Unistyles configuration object
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
- Types: `BrandColors`, `ThemeColors`, `TypographyConfig`, `TypographyToken`, `Typography`,
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
- | Full export list | `## Public Runtime Exports`, `## Public Type Exports` |
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
- | Peer dependency rules | `## Compatibility`, `## Dependency Classification` |
59
- | Upgrading across versions | `## Semver and Migration` |
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
+ }