@chipmobilesdk/rn-theme 0.2.2 → 1.0.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.
Files changed (42) hide show
  1. package/CHANGELOG.md +100 -23
  2. package/NOTICE +9 -0
  3. package/README.md +277 -187
  4. package/package.json +49 -50
  5. package/skills/sdk-rn-theme/SKILL.md +44 -48
  6. package/src/color/categorical.ts +33 -0
  7. package/src/color/contrast.ts +233 -0
  8. package/src/color/parse.ts +119 -0
  9. package/src/color/roles.ts +262 -0
  10. package/src/index.ts +23 -20
  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 +186 -0
  16. package/src/theme/errors.ts +34 -0
  17. package/src/theme/overrides.ts +147 -0
  18. package/src/theme/validateThemeConfig.ts +177 -0
  19. package/src/tokens/defaults.ts +103 -46
  20. package/src/tokens/typography.ts +147 -64
  21. package/src/types/brand.ts +16 -0
  22. package/src/types/colors.ts +68 -11
  23. package/src/types/index.ts +44 -5
  24. package/src/types/theme.ts +163 -0
  25. package/src/types/tokens.ts +47 -5
  26. package/src/types/typography.ts +44 -10
  27. package/src/unistyles/createUnistylesConfig.ts +169 -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.2",
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.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/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:check": "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,77 +1,73 @@
1
1
  ---
2
2
  name: sdk-rn-theme
3
- description: "Theming for @chipmobilesdk/rn-theme: brand palettes, Unistyles config, color/typography tokens, dark mode, style composition, variants, and dynamic-style diagnostics. Use when setting up theming, adding a design token, wiring light/dark, or debugging Unistyles setup and type errors."
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"
7
6
  user-invocable: true
8
7
  disable-model-invocation: false
9
8
  ---
10
9
 
11
10
  # @chipmobilesdk/rn-theme
12
11
 
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.
12
+ Builds light/dark (and named) themes from three brand colours with guaranteed WCAG contrast,
13
+ a full token set, and style presets, and produces the `StyleSheet.configure` object for
14
+ Unistyles 3. Single source of truth for visual tokens; app tokens join through `extend`.
15
15
 
16
16
  ## Use this when
17
17
 
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
22
- - Style arrays, variants, or dynamic functions produce unexpected TypeScript errors
18
+ - Bootstrapping theming, or adding an app-specific token, tone or theme
19
+ - Writing a stylesheet — check presets before hand-writing flex, text, border or box styles
20
+ - Wiring light/dark, a user theme preference, or a React Navigation theme
21
+ - A style does not update on theme/variant change, or a screen renders unstyled
22
+ - Style arrays, variants or dynamic functions produce TypeScript errors
23
23
 
24
24
  ## Do not use this for
25
25
 
26
- - Chart-specific theming — that is `sdk-rn-chart` (`ChartThemeProvider`)
27
- - Per-component one-off styles that do not belong in the token set
26
+ - Chart rendering — that is `sdk-rn-chart` (feed it `theme.categorical`)
27
+ - UI components — this package ships none
28
28
 
29
- ## Setup order matters
29
+ ## Rules that prevent real bugs
30
30
 
31
- Unistyles must be configured **before** the first component that consumes a style
32
- renders. Getting this wrong produces an app that renders with no styling and no error.
33
- Read the `## Setup Order` and `## Theme Start Strategy` sections of the README before
34
- changing entry-point wiring.
31
+ - **Never cast or alias the theme inside `StyleSheet.create`** (`const theme = appTheme(raw)`):
32
+ the Unistyles Babel plugin then records no dependencies and the styles stop updating on
33
+ theme change. Declare app tokens with `extend` so `theme` is already typed.
34
+ - **Variants**: keep a literal `variants` key and call `byTone(theme, …)` inline under it.
35
+ Use one tone-colour style combined by array (`[styles.title, styles.toneText]`) instead of
36
+ repeating variants per text style.
37
+ - **Muted text is `onSurfaceVariant`** (or `text.*Muted`), never `disabled` — `disabled` is
38
+ a contrast-exempt state colour. Dividers use `outlineVariant`; interactive boundaries
39
+ `outline`. There is no `color.border`.
40
+ - **Weights**: spread `theme.font.bold` etc.; a literal `fontWeight` breaks custom fonts on
41
+ Android.
42
+ - Pass the config inline (or `satisfies ThemeConfig`); a `ThemeConfig` annotation erases
43
+ extension and theme-name types.
44
+ - Configure Unistyles **before** the first themed component renders; the app (not the
45
+ package) augments `UnistylesThemes` / `UnistylesBreakpoints` from `typeof config`.
46
+ - Merge Unistyles styles with arrays, never object spread of Unistyles entries.
35
47
 
36
- ## Unistyles 3 authoring guardrails
48
+ ## Public API
37
49
 
38
- - Merge independent styles with array syntax: `[styles.base, styles.extra]`. Do not
39
- object-spread Unistyles entries. Variants are for finite semantic states such as
40
- `selected`, `disabled`, or `size`, not a replacement for every array.
41
- - Call dynamic style entries before passing them to a `style` prop, and use only
42
- serializable arguments. Mixing static and dynamic entries is supported; if TypeScript
43
- widens sibling entries into an object/function union, split the dynamic entries into a
44
- separate stylesheet instead of casting.
45
- - When an error is reported at many JSX usages, temporarily move the stylesheet above
46
- the component or isolate suspect entries so the invalid initializer is diagnosed.
47
- Treat this as a debugging technique, not a runtime ordering requirement.
50
+ Runtime: `createUnistylesConfig`, `buildThemes`, `applyThemeMode`, `byTone`, `layout`,
51
+ `presets`, `auditTheme`, `contrastRatio`, `withAlpha`, `toNavigationTheme`,
52
+ `DEFAULT_BREAKPOINTS`, `ThemeConfigError`, `ThemeContrastError`.
48
53
 
49
- Read `## Unistyles 3 Style Authoring and TypeScript Diagnostics` in the README for the
50
- reasoning, examples, and upstream documentation links.
54
+ Theme groups: `color`, `tone`, `typography`, `font`, `fontWeight`, `spacing`, `radius`,
55
+ `borderWidth`, `size`, `opacity`, `motion`, `shadow`, `zIndex`, `maxFontScale`,
56
+ `categorical`, presets `text`, `stack`, `inline`, `border`, `box`, plus `extend` groups.
51
57
 
52
- ## Public API (v0.2.0)
53
-
54
- Runtime:
55
-
56
- - `generateLightPalette(brand)` / `generateDarkPalette(brand)` — derive full palettes
57
- - `createUnistylesConfig(...)` — build the Unistyles configuration object
58
-
59
- Types: `BrandColors`, `ThemeColors`, `TypographyConfig`, `TypographyToken`, `Typography`,
60
- `StyleConfig`, `StyleTheme`, `DeepPartial`, `UnistylesThemeFromBrand`
61
-
62
- Anything not exported from `src/index.ts` is private and may change without a major bump —
63
- see `## Private Modules` and `## Semver and Migration` in the README.
58
+ Anything not exported from the package root is private.
64
59
 
65
60
  ## Where the detail lives
66
61
 
67
- Read only the section you need from
68
- `node_modules/@chipmobilesdk/rn-theme/README.md`:
62
+ Read only the section you need from `node_modules/@chipmobilesdk/rn-theme/README.md`:
69
63
 
70
64
  | Need | Section |
71
65
  |---|---|
72
- | Entry-point wiring | `## Setup Order`, `## Theme Start Strategy` |
73
- | Full export list | `## Public Runtime Exports`, `## Public Type Exports` |
66
+ | Entry-point wiring, augmentation | `## Setup Order`, `## Theme Start Strategy` |
67
+ | Which colour role to use; contrast guarantees | `## Colour System` |
68
+ | Token names and values; custom fonts; font scale | `## Tokens` |
69
+ | Shortening a stylesheet; plugin rules | `## Style Presets` |
70
+ | App tokens, custom tones, named themes | `## Extending the Theme` |
74
71
  | Config rejected at runtime | `## Configuration Validation` |
75
- | Peer dependency rules | `## Compatibility`, `## Dependency Classification` |
76
- | Style arrays, variants, or dynamic-style type errors | `## Unistyles 3 Style Authoring and TypeScript Diagnostics` |
77
- | Upgrading across versions | `## Semver and Migration` |
72
+ | Style arrays, variants, dynamic-style type errors | `## Unistyles 3 Style Authoring and TypeScript Diagnostics` |
73
+ | 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,233 @@
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
+ {
111
+ foreground: `${tone}.onContainer`,
112
+ background: `${tone}.container`,
113
+ target: TEXT_CONTRAST,
114
+ },
115
+ { foreground: `${tone}.onFill`, background: `${tone}.fill`, target: TEXT_CONTRAST },
116
+ { foreground: `${tone}.border`, background: 'color.background', target: NON_TEXT_CONTRAST },
117
+ );
118
+ }
119
+ }
120
+
121
+ const text = readPath(theme, 'text');
122
+
123
+ if (isRecord(text)) {
124
+ for (const name of Object.keys(text)) {
125
+ // `text.disabled` deliberately uses the contrast-exempt disabled colour.
126
+ if (name !== 'disabled') {
127
+ pairs.push(...onBase(`text.${name}.color`));
128
+ }
129
+ }
130
+ }
131
+
132
+ const categorical = readPath(theme, 'categorical');
133
+
134
+ if (Array.isArray(categorical)) {
135
+ categorical.forEach((_, index) => {
136
+ pairs.push({
137
+ foreground: `categorical.${index}`,
138
+ background: 'color.surface',
139
+ target: NON_TEXT_CONTRAST,
140
+ });
141
+ });
142
+ }
143
+
144
+ return pairs;
145
+ }
146
+
147
+ function isRecord(value: unknown): value is Record<string, unknown> {
148
+ return typeof value === 'object' && value !== null && !Array.isArray(value);
149
+ }
150
+
151
+ function readPath(source: unknown, path: string): unknown {
152
+ return path.split('.').reduce<unknown>((node, key) => {
153
+ if (Array.isArray(node)) {
154
+ return node[Number(key)];
155
+ }
156
+
157
+ return isRecord(node) ? node[key] : undefined;
158
+ }, source);
159
+ }
160
+
161
+ function channelLuminance(channel: number): number {
162
+ const value = channel / 255;
163
+
164
+ return value <= 0.04045 ? value / 12.92 : ((value + 0.055) / 1.055) ** 2.4;
165
+ }
166
+
167
+ function relativeLuminance({ r, g, b }: Rgba): number {
168
+ return 0.2126 * channelLuminance(r) + 0.7152 * channelLuminance(g) + 0.0722 * channelLuminance(b);
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(
194
+ relativeLuminance(opaqueForeground),
195
+ relativeLuminance(opaqueBackground),
196
+ );
197
+ const darker = Math.min(relativeLuminance(opaqueForeground), relativeLuminance(opaqueBackground));
198
+
199
+ return (lighter + LUMINANCE_OFFSET) / (darker + LUMINANCE_OFFSET);
200
+ }
201
+
202
+ /**
203
+ * Checks every contrast pair the package guarantees against the given theme
204
+ * and returns the ones below target. An empty list means the theme passes.
205
+ * `color.disabled` is never audited: inactive components are exempt from
206
+ * WCAG 1.4.3.
207
+ */
208
+ export function auditTheme(theme: object): ContrastFailure[] {
209
+ const base = readPath(theme, 'color.background');
210
+ const failures: ContrastFailure[] = [];
211
+
212
+ for (const pair of guaranteedPairs(theme)) {
213
+ const foreground = readPath(theme, pair.foreground);
214
+ const background = readPath(theme, pair.background);
215
+
216
+ if (typeof foreground !== 'string' || typeof background !== 'string') {
217
+ continue;
218
+ }
219
+
220
+ const ratio = contrastRatio(
221
+ foreground,
222
+ background,
223
+ typeof base === 'string' ? base : undefined,
224
+ );
225
+
226
+ if (ratio < pair.target) {
227
+ // Floored so a reported ratio never reads as passing (4.499 → 4.49).
228
+ failures.push({ ...pair, ratio: Math.floor(ratio * 100) / 100 });
229
+ }
230
+ }
231
+
232
+ return failures;
233
+ }
@@ -0,0 +1,119 @@
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) => parseInt(full.slice(index * 2, index * 2 + 2), 16);
58
+
59
+ return {
60
+ r: channel(0),
61
+ g: channel(1),
62
+ b: channel(2),
63
+ a: full.length === 8 ? channel(3) / CHANNEL_MAX : 1,
64
+ };
65
+ }
66
+
67
+ export function isValidColor(value: unknown): value is string {
68
+ return parseColor(value) !== null;
69
+ }
70
+
71
+ export function isOpaqueColor(value: unknown): value is string {
72
+ return parseColor(value)?.a === 1;
73
+ }
74
+
75
+ /** ARGB integer of an opaque colour string; throws for anything else. */
76
+ export function argbFromColor(value: string): number {
77
+ const rgba = parseColor(value);
78
+
79
+ if (!rgba || rgba.a !== 1) {
80
+ throw new Error(`Expected an opaque colour, received "${value}".`);
81
+ }
82
+
83
+ return argbFromRgb(rgba.r, rgba.g, rgba.b);
84
+ }
85
+
86
+ function toHexPair(channel: number): string {
87
+ return Math.round(channel).toString(16).padStart(2, '0').toUpperCase();
88
+ }
89
+
90
+ export function hexFromArgb(argb: number): string {
91
+ // eslint-disable-next-line no-bitwise -- unpacking the ARGB integer's channels
92
+ const [r, g, b] = [(argb >> 16) & 0xff, (argb >> 8) & 0xff, argb & 0xff];
93
+
94
+ return `#${toHexPair(r)}${toHexPair(g)}${toHexPair(b)}`;
95
+ }
96
+
97
+ export function formatColor({ r, g, b, a }: Rgba): string {
98
+ const rgb = `#${toHexPair(r)}${toHexPair(g)}${toHexPair(b)}`;
99
+
100
+ return a >= 1 ? rgb : `${rgb}${toHexPair(a * CHANNEL_MAX)}`;
101
+ }
102
+
103
+ /**
104
+ * Applies an opacity to a colour, multiplying any alpha it already has.
105
+ * Returns `#RRGGBBAA` (or `#RRGGBB` when the result is opaque).
106
+ */
107
+ export function withAlpha(color: string, alpha: number): string {
108
+ const rgba = parseColor(color);
109
+
110
+ if (!rgba) {
111
+ throw new Error(`withAlpha: "${color}" is not a supported colour.`);
112
+ }
113
+
114
+ if (!Number.isFinite(alpha) || alpha < 0 || alpha > 1) {
115
+ throw new Error(`withAlpha: alpha must be between 0 and 1, received ${alpha}.`);
116
+ }
117
+
118
+ return formatColor({ ...rgba, a: rgba.a * alpha });
119
+ }