@chipmobilesdk/rn-theme 1.0.1 → 1.2.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 +76 -1
- package/README.md +23 -12
- package/package.json +5 -2
- package/skills/sdk-rn-theme/SKILL.md +2 -1
- package/src/color/roles.ts +3 -1
- package/src/index.ts +1 -0
- package/src/theme/buildThemes.ts +117 -30
- package/src/theme/errors.ts +18 -5
- package/src/theme/validateThemeConfig.ts +28 -2
- package/src/tokens/defaults.ts +17 -11
- package/src/unistyles/createUnistylesConfig.ts +4 -1
- package/src/unistyles/themeMode.ts +28 -5
- package/src/vendor/mcu/index.ts +1 -1
package/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,80 @@
|
|
|
1
1
|
# @chipmobilesdk/rn-theme
|
|
2
2
|
|
|
3
|
+
## 1.2.0
|
|
4
|
+
|
|
5
|
+
### Minor Changes
|
|
6
|
+
|
|
7
|
+
- An override that names a group the theme does not have is refused with a `ThemeConfigError` instead of being dropped. `overrides.light.radii` or `colors` had no effect and said nothing, which is exactly what the documented contract promised it would not do. The list of groups is derived from the generated theme, so a token group added later cannot be left out of it. ([#19](https://github.com/operationd8979/chip-mobile-sdk/pull/19))
|
|
8
|
+
|
|
9
|
+
A colour override now reaches everything derived from it. `color` is merged before `shadow` and `tone.neutral` are built, so pinning `color.onSurface` or `color.shadow` changes the neutral tone and the shadows too — they used to keep the generated value while the text presets followed the override, leaving a theme that disagreed with itself. The accent tones and `categorical` still come from the tonal palettes, which the README and the migration table now say.
|
|
10
|
+
|
|
11
|
+
A custom tone may not take a standard tone's name (`neutral`, `primary`, `info`, `success`, `warning`, `error`), and an `extend` that returns a group the theme already has (`spacing`, say) is refused: both used to replace the real thing at runtime, unvalidated, taking the contrast guarantees with them.
|
|
12
|
+
|
|
13
|
+
`createUnistylesConfig(undefined)` reports a `ThemeConfigError` with a path rather than a `TypeError`. `color.disabled` is derived from `opacity.disabledContent` rather than a second copy of the figure, and the vendored colour utilities no longer export a function nothing uses.
|
|
14
|
+
|
|
15
|
+
## 1.1.0
|
|
16
|
+
|
|
17
|
+
### Minor Changes
|
|
18
|
+
|
|
19
|
+
- One diagnostic stream: install a sink with `installSdkSink` from
|
|
20
|
+
`@chipmobilesdk/rn-core` and it receives what every package observes (events,
|
|
21
|
+
failures, warnings) as `SdkDiagnostic`s, redacted. Listeners are notified through
|
|
22
|
+
rn-core, so a listener that throws no longer stops the others nor fails the call that
|
|
23
|
+
notified, and its error is reported rather than swallowed. ([#17](https://github.com/operationd8979/chip-mobile-sdk/pull/17))
|
|
24
|
+
|
|
25
|
+
- rn-ads: a banner's auto-refresh is a new cycle, so its `loaded` and `impression`
|
|
26
|
+
events are no longer deduplicated away; the dedupe memory is bounded. A throwing
|
|
27
|
+
status, consent or placement listener is reported as an `internal_error` event.
|
|
28
|
+
- rn-i18n: new `onDiagnostic` config receives every diagnostic (`I18nDiagnostic`,
|
|
29
|
+
with a stable `code`); without it, warnings go to `console.warn` in development
|
|
30
|
+
builds only. The warn-once memory is bounded.
|
|
31
|
+
- rn-chart: now depends on rn-core for its diagnostics.
|
|
32
|
+
- rn-payment: status, entitlement and pending listeners report a throw to the logger
|
|
33
|
+
instead of swallowing it.
|
|
34
|
+
|
|
35
|
+
- The package's errors extend `SdkError` from the new `@chipmobilesdk/rn-core`
|
|
36
|
+
dependency: `isSdkError` recognises them alongside every other package's, and each
|
|
37
|
+
carries `pkg`, `code` and `retryable`. Names and codes are unchanged. ([#17](https://github.com/operationd8979/chip-mobile-sdk/pull/17))
|
|
38
|
+
|
|
39
|
+
- rn-ai and rn-camera: `retryable` is the new name of `recoverable`, which stays as a
|
|
40
|
+
deprecated alias.
|
|
41
|
+
- rn-auth: `retryable` is true for `network-unavailable`. `redact` also removes URLs,
|
|
42
|
+
e-mail addresses and UUIDs.
|
|
43
|
+
- rn-ads: `redact` also removes JWTs and e-mail addresses, and reduces an `AdsError`
|
|
44
|
+
to its code.
|
|
45
|
+
- rn-ai: an error message quoted from the gateway is pattern-redacted.
|
|
46
|
+
- rn-camera: `redact` also removes JWTs, e-mail addresses and UUIDs.
|
|
47
|
+
- rn-theme: `ThemeConfigError` has `code: 'invalid_config'` and `ThemeContrastError`
|
|
48
|
+
`code: 'contrast'` (new `ThemeErrorCode` type).
|
|
49
|
+
- rn-i18n: `I18nConfigError` has `code: 'invalid_config'` and `UnitConversionError`
|
|
50
|
+
`code: 'unit_conversion'` (new `I18nErrorCode` type).
|
|
51
|
+
|
|
52
|
+
- Native peers load through `lazyPeer` from `@chipmobilesdk/rn-core`, on first use,
|
|
53
|
+
and a missing one is each package's own typed error. ([#17](https://github.com/operationd8979/chip-mobile-sdk/pull/17))
|
|
54
|
+
|
|
55
|
+
- rn-auth: creating `auth` no longer loads react-native-app-auth or
|
|
56
|
+
react-native-keychain; each loads on first use. New entrypoints
|
|
57
|
+
`@chipmobilesdk/rn-auth/app-auth` and `/keychain`. The default platform comes from
|
|
58
|
+
rn-core.
|
|
59
|
+
- rn-camera: a preview whose VisionCamera feed cannot load fails the session with
|
|
60
|
+
`cameraUnavailable` (new `controller.reportSurfaceUnavailable()`), instead of
|
|
61
|
+
showing black with no error. A missing VisionCamera module is `cameraUnavailable`.
|
|
62
|
+
- rn-notification: `createMmkvStore` reports a fallback to memory as
|
|
63
|
+
`STATE_STORE_DEGRADED` (to the SDK sinks and a new `onDegraded` option) and
|
|
64
|
+
exposes `durable`.
|
|
65
|
+
- rn-payment: an iOS app validates its catalog against the iOS product ids; the
|
|
66
|
+
default platform used to be Android everywhere. A missing react-native-iap is
|
|
67
|
+
`PAYMENT_UNAVAILABLE`.
|
|
68
|
+
- rn-theme: `buildThemes` runs without React Native (new `hairlineWidth` option) and
|
|
69
|
+
importing the package no longer loads Unistyles; `applyThemeMode` loads it on
|
|
70
|
+
first use.
|
|
71
|
+
- rn-ads, rn-local-db: the same loading, with the same error codes as before.
|
|
72
|
+
|
|
73
|
+
### Patch Changes
|
|
74
|
+
|
|
75
|
+
- Updated dependencies ([#17](https://github.com/operationd8979/chip-mobile-sdk/pull/17), [#17](https://github.com/operationd8979/chip-mobile-sdk/pull/17), [#17](https://github.com/operationd8979/chip-mobile-sdk/pull/17)):
|
|
76
|
+
- @chipmobilesdk/rn-core@0.1.0
|
|
77
|
+
|
|
3
78
|
## 1.0.1
|
|
4
79
|
|
|
5
80
|
### Patch Changes
|
|
@@ -56,7 +131,7 @@ extension, and style presets. See `specs/014-rn-theme-v1/spec.md` in the source
|
|
|
56
131
|
| `color.disabled` used as muted text | `color.onSurfaceVariant` or `text.bodyMuted` / `text.labelMuted` |
|
|
57
132
|
| `color.disabled` for inactive UI | unchanged meaning; now `onSurface` at 38 % opacity, contrast-exempt |
|
|
58
133
|
| `color.error`/`success`/`warning`/`info` as fills | `color.errorContainer` … with `onErrorContainer` …, or `tone.*.fill` / `onFill` |
|
|
59
|
-
| `color.primary` equal to the brand hex | tone-mapped; override `color.primary` to pin a hex |
|
|
134
|
+
| `color.primary` equal to the brand hex | tone-mapped; override `color.primary` to pin a hex (the accent tones and `categorical` stay on the palette) |
|
|
60
135
|
| `shadow.md.shadowColor` … `elevation` | `shadow.md.boxShadow` |
|
|
61
136
|
| App tokens spread into the theme + cast | `extend: ({ theme }) => ({ ... })` |
|
|
62
137
|
| `overrides` adding unknown keys | rejected — use `extend` |
|
package/README.md
CHANGED
|
@@ -9,7 +9,6 @@ Native apps, configured for `react-native-unistyles` 3.
|
|
|
9
9
|
|
|
10
10
|
- **Version**: see [CHANGELOG](CHANGELOG.md)
|
|
11
11
|
- **Entrypoint**: `@chipmobilesdk/rn-theme`
|
|
12
|
-
- **Demo**: `src/screens/ExampleScreen.tsx`
|
|
13
12
|
|
|
14
13
|
## Install
|
|
15
14
|
|
|
@@ -23,8 +22,9 @@ runtime dependency, no native code, and renders no components.
|
|
|
23
22
|
## Compatibility
|
|
24
23
|
|
|
25
24
|
Validated in the ChipMobileSdk demo against React `19.2.3`, React Native `0.85.3`,
|
|
26
|
-
React Native Unistyles `^3.2.5`, TypeScript `^5.8.3` and Node.js `>=22.11.0
|
|
27
|
-
iOS
|
|
25
|
+
React Native Unistyles `^3.2.5`, TypeScript `^5.8.3` and Node.js `>=22.11.0`. **Android is
|
|
26
|
+
device-validated; iOS is pending** (the package has no native code, and shadows use React
|
|
27
|
+
Native's cross-platform `boxShadow` on the New Architecture).
|
|
28
28
|
|
|
29
29
|
## Setup Order
|
|
30
30
|
|
|
@@ -90,6 +90,7 @@ applyThemeMode('dark'); // fixed; survives later OS scheme changes
|
|
|
90
90
|
|
|
91
91
|
`applyThemeMode` switches adaptive themes off before setting a manual theme — in the other
|
|
92
92
|
order the next OS change overrides the user's choice. Persisting the preference is the app's.
|
|
93
|
+
Unistyles is loaded the first time a mode is applied, never when the package is imported.
|
|
93
94
|
|
|
94
95
|
## Colour System
|
|
95
96
|
|
|
@@ -215,12 +216,24 @@ createUnistylesConfig({
|
|
|
215
216
|
});
|
|
216
217
|
```
|
|
217
218
|
|
|
218
|
-
Extensions may not reuse a base group name
|
|
219
|
-
|
|
219
|
+
Extensions may not reuse a base group name, and a custom tone may not take a standard
|
|
220
|
+
tone's name (`neutral`, `primary`, `info`, `success`, `warning`, `error`); both are
|
|
221
|
+
refused with a `ThemeConfigError`. Override keys must already exist, at every level — a
|
|
222
|
+
group the theme does not have, such as `radii` or `colors`, is refused rather than
|
|
223
|
+
ignored. New tokens belong in `extend`.
|
|
224
|
+
|
|
225
|
+
An override of `color.X` reaches everything derived from it: the text and surface presets,
|
|
226
|
+
`shadow`, and `tone.neutral`, which is built from the neutral roles. The accent tones
|
|
227
|
+
(`tone.primary` and the rest) and `categorical` come from the tonal palettes, so pinning
|
|
228
|
+
`color.primary` to a hex does not change them — override `tone.primary` or the brand seed
|
|
229
|
+
for that.
|
|
220
230
|
|
|
221
231
|
## Integrations
|
|
222
232
|
|
|
223
|
-
- `buildThemes(config)` — the same themes without Unistyles, e.g. for tests
|
|
233
|
+
- `buildThemes(config, options?)` — the same themes without Unistyles, e.g. for tests, other
|
|
234
|
+
libraries or a token export. It needs no React Native either: pass `platform` and
|
|
235
|
+
`hairlineWidth`, or they default to the running platform and
|
|
236
|
+
`StyleSheet.hairlineWidth` (1 where React Native is not loaded).
|
|
224
237
|
- `toNavigationTheme(theme, scheme)` — a React Navigation theme (no navigation dependency).
|
|
225
238
|
|
|
226
239
|
## Public Runtime Exports
|
|
@@ -244,6 +257,10 @@ Only `@chipmobilesdk/rn-theme` is public. Deep imports (`.../src/...`) are unsup
|
|
|
244
257
|
|
|
245
258
|
## Configuration Validation
|
|
246
259
|
|
|
260
|
+
Both errors extend `SdkError` from `@chipmobilesdk/rn-core` and carry a `code`
|
|
261
|
+
(`ThemeErrorCode`): `invalid_config` for `ThemeConfigError`, `contrast` for
|
|
262
|
+
`ThemeContrastError`.
|
|
263
|
+
|
|
247
264
|
Invalid input throws `ThemeConfigError` whose `path` names the field, e.g.
|
|
248
265
|
`overrides.dark.color.error`. Checked: brand and seed colours (opaque, supported format),
|
|
249
266
|
tone colours, typography numbers and font maps, named themes, override keys (must exist),
|
|
@@ -288,12 +305,6 @@ See the mapping table in `CHANGELOG.md` (`## 1.0.0`). In short: rename `typograp
|
|
|
288
305
|
augmentation (`UnistylesThemeFromBrand`) with the one in *Setup Order*; move app tokens from
|
|
289
306
|
spreads and casts into `extend`.
|
|
290
307
|
|
|
291
|
-
## Release Validation
|
|
292
|
-
|
|
293
|
-
```bash
|
|
294
|
-
npm run validate
|
|
295
|
-
```
|
|
296
|
-
|
|
297
308
|
## Semver and Migration
|
|
298
309
|
|
|
299
310
|
- Patch: internal fix with no public shape or behaviour change.
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@chipmobilesdk/rn-theme",
|
|
3
|
-
"version": "1.0
|
|
3
|
+
"version": "1.2.0",
|
|
4
4
|
"private": false,
|
|
5
5
|
"license": "UNLICENSED",
|
|
6
6
|
"main": "src/index.ts",
|
|
@@ -29,9 +29,12 @@
|
|
|
29
29
|
],
|
|
30
30
|
"sideEffects": false,
|
|
31
31
|
"scripts": {
|
|
32
|
-
"typecheck": "tsc -p tsconfig.json
|
|
32
|
+
"typecheck": "tsc -p tsconfig.json && tsc -p tsconfig.test.json",
|
|
33
33
|
"pack:check": "npm pack --dry-run"
|
|
34
34
|
},
|
|
35
|
+
"dependencies": {
|
|
36
|
+
"@chipmobilesdk/rn-core": "^0.1.0"
|
|
37
|
+
},
|
|
35
38
|
"peerDependencies": {
|
|
36
39
|
"react": ">=19",
|
|
37
40
|
"react-native": ">=0.85",
|
|
@@ -49,7 +49,8 @@ Unistyles 3. Single source of truth for visual tokens; app tokens join through `
|
|
|
49
49
|
|
|
50
50
|
Runtime: `createUnistylesConfig`, `buildThemes`, `applyThemeMode`, `byTone`, `layout`,
|
|
51
51
|
`presets`, `auditTheme`, `contrastRatio`, `withAlpha`, `toNavigationTheme`,
|
|
52
|
-
`DEFAULT_BREAKPOINTS`, `ThemeConfigError`, `ThemeContrastError
|
|
52
|
+
`DEFAULT_BREAKPOINTS`, `ThemeConfigError`, `ThemeContrastError` (both rn-core SDK errors with a
|
|
53
|
+
`ThemeErrorCode`).
|
|
53
54
|
|
|
54
55
|
Theme groups: `color`, `tone`, `typography`, `font`, `fontWeight`, `spacing`, `radius`,
|
|
55
56
|
`borderWidth`, `size`, `opacity`, `motion`, `shadow`, `zIndex`, `maxFontScale`,
|
package/src/color/roles.ts
CHANGED
|
@@ -7,6 +7,7 @@ import type {
|
|
|
7
7
|
StandardToneName,
|
|
8
8
|
ToneColors,
|
|
9
9
|
} from '../types/colors';
|
|
10
|
+
import { DEFAULT_OPACITY } from '../tokens/defaults';
|
|
10
11
|
import { argbFromColor, hexFromArgb, withAlpha } from './parse';
|
|
11
12
|
|
|
12
13
|
/**
|
|
@@ -27,7 +28,8 @@ const MAX_NEUTRAL_SEED_CHROMA = 16;
|
|
|
27
28
|
const NEUTRAL_VARIANT_EXTRA_CHROMA = 4;
|
|
28
29
|
|
|
29
30
|
/** Material 3 opacities applied to `onSurface` and the scrim. */
|
|
30
|
-
|
|
31
|
+
/** The same figure as `opacity.disabledContent`, from its one definition (THM-07). */
|
|
32
|
+
export const DISABLED_CONTENT_OPACITY = DEFAULT_OPACITY.disabledContent;
|
|
31
33
|
const SCRIM_OPACITY = 0.32;
|
|
32
34
|
|
|
33
35
|
/**
|
package/src/index.ts
CHANGED
|
@@ -2,6 +2,7 @@
|
|
|
2
2
|
export { buildThemes } from './theme/buildThemes';
|
|
3
3
|
export type { BuildThemesOptions } from './theme/buildThemes';
|
|
4
4
|
export { ThemeConfigError, ThemeContrastError } from './theme/errors';
|
|
5
|
+
export type { ThemeErrorCode } from './theme/errors';
|
|
5
6
|
|
|
6
7
|
// ─── Unistyles ──────────────────────────────────────────────────────────────
|
|
7
8
|
export { createUnistylesConfig } from './unistyles/createUnistylesConfig';
|
package/src/theme/buildThemes.ts
CHANGED
|
@@ -1,4 +1,6 @@
|
|
|
1
|
-
import {
|
|
1
|
+
import { createDiagnosticChannel } from '@chipmobilesdk/rn-core';
|
|
2
|
+
|
|
3
|
+
import { currentPlatform, lazyPeer } from '@chipmobilesdk/rn-core';
|
|
2
4
|
import { buildCategorical } from '../color/categorical';
|
|
3
5
|
import { auditTheme } from '../color/contrast';
|
|
4
6
|
import { buildColorRoles, buildPalettes, buildTones } from '../color/roles';
|
|
@@ -25,32 +27,44 @@ import type {
|
|
|
25
27
|
Themes,
|
|
26
28
|
} from '../types/theme';
|
|
27
29
|
import type { FontToken, FontWeightName } from '../types/typography';
|
|
28
|
-
import { describeFailures, ThemeContrastError } from './errors';
|
|
29
|
-
import { mergeOverrides, pick } from './overrides';
|
|
30
|
+
import { describeFailures, ThemeConfigError, ThemeContrastError } from './errors';
|
|
31
|
+
import { isPlainObject, mergeOverrides, pick } from './overrides';
|
|
30
32
|
import { validateThemeConfig } from './validateThemeConfig';
|
|
31
33
|
|
|
32
34
|
export interface BuildThemesOptions {
|
|
33
35
|
/** Platform whose font resolution applies. Defaults to the running platform. */
|
|
34
36
|
platform?: Platform;
|
|
37
|
+
/**
|
|
38
|
+
* `borderWidth.hairline`. Defaults to React Native's `StyleSheet.hairlineWidth`,
|
|
39
|
+
* or 1 where React Native is not loaded, so `buildThemes` runs on Node (THM-04).
|
|
40
|
+
*/
|
|
41
|
+
hairlineWidth?: number;
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
/** Read only when no `hairlineWidth` is given, so building themes needs no React Native. */
|
|
45
|
+
const reactNative = lazyPeer('react-native', () => {
|
|
46
|
+
// Inside `try`, so Metro bundles an app that leaves this optional peer out.
|
|
47
|
+
try {
|
|
48
|
+
return require('react-native') as { StyleSheet?: { hairlineWidth?: number } };
|
|
49
|
+
} catch (cause) {
|
|
50
|
+
throw cause;
|
|
51
|
+
}
|
|
52
|
+
});
|
|
53
|
+
|
|
54
|
+
function deviceHairlineWidth(): number {
|
|
55
|
+
const result = reactNative.get();
|
|
56
|
+
return (result.ok ? result.module.StyleSheet?.hairlineWidth : undefined) ?? 1;
|
|
35
57
|
}
|
|
36
58
|
|
|
37
|
-
/**
|
|
38
|
-
const
|
|
39
|
-
'
|
|
40
|
-
'
|
|
41
|
-
'
|
|
42
|
-
'
|
|
43
|
-
'
|
|
44
|
-
'
|
|
45
|
-
|
|
46
|
-
'size',
|
|
47
|
-
'opacity',
|
|
48
|
-
'motion',
|
|
49
|
-
'shadow',
|
|
50
|
-
'zIndex',
|
|
51
|
-
'maxFontScale',
|
|
52
|
-
'categorical',
|
|
53
|
-
] as const;
|
|
59
|
+
/** The standard tone names an app's own tone may not take over (THM-03). */
|
|
60
|
+
const STANDARD_TONES: ReadonlySet<string> = new Set([
|
|
61
|
+
'neutral',
|
|
62
|
+
'primary',
|
|
63
|
+
'info',
|
|
64
|
+
'success',
|
|
65
|
+
'warning',
|
|
66
|
+
'error',
|
|
67
|
+
]);
|
|
54
68
|
|
|
55
69
|
interface ThemeSource {
|
|
56
70
|
name: string;
|
|
@@ -70,23 +84,59 @@ function cloneFonts(font: Record<FontWeightName, FontToken>): Record<FontWeightN
|
|
|
70
84
|
return copy;
|
|
71
85
|
}
|
|
72
86
|
|
|
87
|
+
/**
|
|
88
|
+
* Every top-level group an override may name, and nothing else.
|
|
89
|
+
*
|
|
90
|
+
* An override naming a group that does not exist — `radii` for `radius`,
|
|
91
|
+
* `colors` for `color` — used to be dropped without a word, so the override
|
|
92
|
+
* simply had no effect (THM-01). The list is derived from what was generated
|
|
93
|
+
* rather than written out by hand, so a new token group cannot be forgotten
|
|
94
|
+
* (THM-06).
|
|
95
|
+
*/
|
|
96
|
+
function assertKnownOverrideGroups(
|
|
97
|
+
overrides: unknown,
|
|
98
|
+
known: Iterable<string>,
|
|
99
|
+
path: string,
|
|
100
|
+
): void {
|
|
101
|
+
if (!isPlainObject(overrides)) {
|
|
102
|
+
return;
|
|
103
|
+
}
|
|
104
|
+
const allowed = new Set(known);
|
|
105
|
+
for (const key of Object.keys(overrides)) {
|
|
106
|
+
if (overrides[key] === undefined || allowed.has(key)) {
|
|
107
|
+
continue;
|
|
108
|
+
}
|
|
109
|
+
throw new ThemeConfigError(
|
|
110
|
+
`${path}.${key}`,
|
|
111
|
+
'does not exist on the theme. Add new tokens through `extend`.',
|
|
112
|
+
);
|
|
113
|
+
}
|
|
114
|
+
}
|
|
115
|
+
|
|
73
116
|
function buildOne(
|
|
74
117
|
source: ThemeSource,
|
|
75
118
|
fonts: ReturnType<typeof buildFonts>,
|
|
76
119
|
overrides: unknown,
|
|
120
|
+
hairlineWidth: number,
|
|
77
121
|
): object {
|
|
78
122
|
const { name, scheme, config, palettes } = source;
|
|
79
123
|
const path = `overrides.${name}`;
|
|
80
|
-
const
|
|
124
|
+
const generatedColor = buildColorRoles(palettes, scheme);
|
|
125
|
+
|
|
126
|
+
// Colour is merged first, because what follows is derived from it: an
|
|
127
|
+
// override of `color.shadow` or `color.onSurface` used to leave `shadow` and
|
|
128
|
+
// `tone.neutral` built from the generated value, so a theme that pinned a
|
|
129
|
+
// colour disagreed with itself (THM-02).
|
|
130
|
+
const color = mergeOverrides(generatedColor, pick(overrides, ['color'])?.color, `${path}.color`);
|
|
81
131
|
|
|
82
|
-
const
|
|
132
|
+
const tokenGroups = {
|
|
83
133
|
color,
|
|
84
134
|
typography: buildTypography(config.typography, fonts.font, fonts.family),
|
|
85
135
|
font: cloneFonts(fonts.font),
|
|
86
136
|
fontWeight: buildFontWeight(),
|
|
87
137
|
spacing: buildSpacing(),
|
|
88
138
|
radius: buildRadius(),
|
|
89
|
-
borderWidth: buildBorderWidth(),
|
|
139
|
+
borderWidth: buildBorderWidth(hairlineWidth),
|
|
90
140
|
size: buildSize(),
|
|
91
141
|
opacity: buildOpacity(),
|
|
92
142
|
motion: buildMotion(),
|
|
@@ -94,21 +144,41 @@ function buildOne(
|
|
|
94
144
|
zIndex: buildZIndex(),
|
|
95
145
|
maxFontScale: config.typography?.maxFontScale ?? DEFAULT_MAX_FONT_SCALE,
|
|
96
146
|
categorical: buildCategorical(config.brandColors.primary, scheme),
|
|
97
|
-
}
|
|
98
|
-
|
|
147
|
+
} satisfies Omit<BaseTheme<string>, 'tone' | (typeof PRESET_KEYS)[number]>;
|
|
148
|
+
|
|
149
|
+
const tokenKeys = Object.keys(tokenGroups);
|
|
150
|
+
const tokens = mergeOverrides(tokenGroups, pick(overrides, tokenKeys), path);
|
|
99
151
|
const tone = mergeOverrides(
|
|
100
|
-
|
|
152
|
+
// From the merged colour, so `tone.neutral` follows an override of
|
|
153
|
+
// `color.onSurface` as the text presets already did.
|
|
154
|
+
buildTones<string>(palettes, tokens.color, scheme),
|
|
101
155
|
pick(overrides, ['tone'])?.tone,
|
|
102
156
|
`${path}.tone`,
|
|
103
157
|
);
|
|
104
158
|
const presets = mergeOverrides(buildPresets(tokens), pick(overrides, PRESET_KEYS), path);
|
|
105
|
-
const base = { ...tokens, tone, ...presets }
|
|
159
|
+
const base = { ...tokens, tone, ...presets } satisfies BaseTheme<string>;
|
|
106
160
|
|
|
107
161
|
if (!config.extend) {
|
|
162
|
+
assertKnownOverrideGroups(overrides, [...tokenKeys, 'tone', ...PRESET_KEYS], path);
|
|
108
163
|
return base;
|
|
109
164
|
}
|
|
110
165
|
|
|
111
166
|
const extension = config.extend({ name, scheme, theme: base });
|
|
167
|
+
for (const key of Object.keys(extension)) {
|
|
168
|
+
if (key in base) {
|
|
169
|
+
// `extend` returning `{ spacing: … }` replaced the whole spacing scale
|
|
170
|
+
// with whatever it held, unvalidated (THM-03).
|
|
171
|
+
throw new ThemeConfigError(
|
|
172
|
+
`extend.${key}`,
|
|
173
|
+
'is already a theme group. Give the extension token a name of its own.',
|
|
174
|
+
);
|
|
175
|
+
}
|
|
176
|
+
}
|
|
177
|
+
assertKnownOverrideGroups(
|
|
178
|
+
overrides,
|
|
179
|
+
[...tokenKeys, 'tone', ...PRESET_KEYS, ...Object.keys(extension)],
|
|
180
|
+
path,
|
|
181
|
+
);
|
|
112
182
|
const extensionOverrides = pick(overrides, Object.keys(extension));
|
|
113
183
|
|
|
114
184
|
return { ...base, ...mergeOverrides(extension, extensionOverrides, path) };
|
|
@@ -129,11 +199,15 @@ function checkContrast(name: string, theme: object, strict: boolean): void {
|
|
|
129
199
|
throw new ThemeContrastError(name, failures);
|
|
130
200
|
}
|
|
131
201
|
|
|
202
|
+
channel.emit('contrast', 'warn', { theme: name, failures: failures.length });
|
|
132
203
|
console.warn(
|
|
133
204
|
`[rn-theme] Theme "${name}" breaks contrast guarantees after overrides:\n${describeFailures(failures)}`,
|
|
134
205
|
);
|
|
135
206
|
}
|
|
136
207
|
|
|
208
|
+
/** Contrast warnings also reach the app's SDK sinks (`installSdkSink`, CORE-08). */
|
|
209
|
+
const channel = createDiagnosticChannel<'contrast'>('rn-theme');
|
|
210
|
+
|
|
137
211
|
/**
|
|
138
212
|
* Builds every theme from brand config — pure, with no Unistyles dependency
|
|
139
213
|
* (spec AC-9.4). `createUnistylesConfig` wraps this; call it directly to feed
|
|
@@ -150,18 +224,31 @@ export function buildThemes<
|
|
|
150
224
|
validateThemeConfig(config);
|
|
151
225
|
|
|
152
226
|
const loose = config as unknown as ThemeConfig<object, string, string>;
|
|
153
|
-
|
|
227
|
+
// rn-core's one platform source (CORE-09); off-device, fonts resolve as on Android.
|
|
228
|
+
const platform = options?.platform ?? (currentPlatform() === 'ios' ? 'ios' : 'android');
|
|
229
|
+
const hairlineWidth = options?.hairlineWidth ?? deviceHairlineWidth();
|
|
154
230
|
const fonts = buildFonts(loose.typography, platform);
|
|
155
231
|
const basePalettes = buildPalettes(loose);
|
|
156
232
|
const sources: ThemeSource[] = [
|
|
157
233
|
{ name: 'light', scheme: 'light', config: loose, palettes: basePalettes },
|
|
158
234
|
{ name: 'dark', scheme: 'dark', config: loose, palettes: basePalettes },
|
|
159
235
|
...Object.entries(loose.themes ?? {}).map(([name, named]) => {
|
|
236
|
+
const tones = { ...loose.tones, ...named.tones } as Record<string, string>;
|
|
237
|
+
for (const tone of Object.keys(tones)) {
|
|
238
|
+
if (STANDARD_TONES.has(tone)) {
|
|
239
|
+
// A custom tone named `primary` silently replaced the standard
|
|
240
|
+
// one, contrast guarantees and all (THM-03).
|
|
241
|
+
throw new ThemeConfigError(
|
|
242
|
+
`themes.${name}.tones.${tone}`,
|
|
243
|
+
'is a standard tone. Override it through `overrides` instead.',
|
|
244
|
+
);
|
|
245
|
+
}
|
|
246
|
+
}
|
|
160
247
|
const namedConfig = {
|
|
161
248
|
...loose,
|
|
162
249
|
brandColors: named.brandColors ?? loose.brandColors,
|
|
163
250
|
seeds: { ...loose.seeds, ...named.seeds },
|
|
164
|
-
tones
|
|
251
|
+
tones,
|
|
165
252
|
};
|
|
166
253
|
|
|
167
254
|
return {
|
|
@@ -176,7 +263,7 @@ export function buildThemes<
|
|
|
176
263
|
|
|
177
264
|
for (const source of sources) {
|
|
178
265
|
const overrides = (loose.overrides as Record<string, unknown> | undefined)?.[source.name];
|
|
179
|
-
const theme = buildOne(source, fonts, overrides);
|
|
266
|
+
const theme = buildOne(source, fonts, overrides, hairlineWidth);
|
|
180
267
|
|
|
181
268
|
checkContrast(source.name, theme, loose.strict === true);
|
|
182
269
|
themes[source.name] = theme;
|
package/src/theme/errors.ts
CHANGED
|
@@ -1,23 +1,36 @@
|
|
|
1
|
+
import { SdkError } from '@chipmobilesdk/rn-core';
|
|
2
|
+
|
|
1
3
|
import type { ContrastFailure } from '../color/contrast';
|
|
2
4
|
|
|
3
|
-
/**
|
|
4
|
-
|
|
5
|
+
/**
|
|
6
|
+
* The package's error codes (THM-09): `invalid_config` for a config or override
|
|
7
|
+
* the package rejects, `contrast` for a theme that breaks a contrast guarantee.
|
|
8
|
+
*/
|
|
9
|
+
export type ThemeErrorCode = 'invalid_config' | 'contrast';
|
|
10
|
+
|
|
11
|
+
/**
|
|
12
|
+
* Invalid configuration or override; `path` names the offending field. An
|
|
13
|
+
* `SdkError` (`@chipmobilesdk/rn-core`) with `pkg: 'rn-theme'`.
|
|
14
|
+
*/
|
|
15
|
+
export class ThemeConfigError extends SdkError<ThemeErrorCode> {
|
|
5
16
|
readonly path: string;
|
|
6
17
|
|
|
7
18
|
constructor(path: string, message: string) {
|
|
8
|
-
super(`${path}: ${message}`);
|
|
19
|
+
super('rn-theme', 'invalid_config', { message: `${path}: ${message}` });
|
|
9
20
|
this.name = 'ThemeConfigError';
|
|
10
21
|
this.path = path;
|
|
11
22
|
}
|
|
12
23
|
}
|
|
13
24
|
|
|
14
25
|
/** Thrown in `strict` mode when a theme breaks a contrast guarantee. */
|
|
15
|
-
export class ThemeContrastError extends
|
|
26
|
+
export class ThemeContrastError extends SdkError<ThemeErrorCode> {
|
|
16
27
|
readonly themeName: string;
|
|
17
28
|
readonly failures: ContrastFailure[];
|
|
18
29
|
|
|
19
30
|
constructor(themeName: string, failures: ContrastFailure[]) {
|
|
20
|
-
super(
|
|
31
|
+
super('rn-theme', 'contrast', {
|
|
32
|
+
message: `Theme "${themeName}" breaks contrast guarantees:\n${describeFailures(failures)}`,
|
|
33
|
+
});
|
|
21
34
|
this.name = 'ThemeContrastError';
|
|
22
35
|
this.themeName = themeName;
|
|
23
36
|
this.failures = failures;
|
|
@@ -36,6 +36,32 @@ function assertPositiveNumber(value: unknown, path: string): void {
|
|
|
36
36
|
}
|
|
37
37
|
}
|
|
38
38
|
|
|
39
|
+
/** The tones this package builds, which an app's own tone may not take over. */
|
|
40
|
+
const STANDARD_TONE_NAMES: ReadonlySet<string> = new Set([
|
|
41
|
+
'neutral',
|
|
42
|
+
'primary',
|
|
43
|
+
'info',
|
|
44
|
+
'success',
|
|
45
|
+
'warning',
|
|
46
|
+
'error',
|
|
47
|
+
]);
|
|
48
|
+
|
|
49
|
+
function validateCustomTones(value: unknown, path: string): void {
|
|
50
|
+
if (value == null) {
|
|
51
|
+
return;
|
|
52
|
+
}
|
|
53
|
+
assertObject(value, path);
|
|
54
|
+
for (const name of Object.keys(value)) {
|
|
55
|
+
if (STANDARD_TONE_NAMES.has(name)) {
|
|
56
|
+
throw new ThemeConfigError(
|
|
57
|
+
`${path}.${name}`,
|
|
58
|
+
'is a standard tone; customise it through `overrides.<theme>.tone` instead.',
|
|
59
|
+
);
|
|
60
|
+
}
|
|
61
|
+
}
|
|
62
|
+
validateColorRecord(value, path);
|
|
63
|
+
}
|
|
64
|
+
|
|
39
65
|
function validateColorRecord(value: unknown, path: string, keys?: readonly string[]): void {
|
|
40
66
|
if (value == null) {
|
|
41
67
|
return;
|
|
@@ -134,7 +160,7 @@ function validateNamedThemes(value: unknown): void {
|
|
|
134
160
|
}
|
|
135
161
|
|
|
136
162
|
validateColorRecord(theme.seeds, `${path}.seeds`, SEED_KEYS);
|
|
137
|
-
|
|
163
|
+
validateCustomTones(theme.tones, `${path}.tones`);
|
|
138
164
|
}
|
|
139
165
|
}
|
|
140
166
|
|
|
@@ -157,7 +183,7 @@ export function validateThemeConfig(config: unknown): void {
|
|
|
157
183
|
assertObject(config, 'config');
|
|
158
184
|
validateBrandColors(config.brandColors, 'brandColors');
|
|
159
185
|
validateColorRecord(config.seeds, 'seeds', SEED_KEYS);
|
|
160
|
-
|
|
186
|
+
validateCustomTones(config.tones, 'tones');
|
|
161
187
|
validateTypography(config.typography);
|
|
162
188
|
validateNamedThemes(config.themes);
|
|
163
189
|
|
package/src/tokens/defaults.ts
CHANGED
|
@@ -1,4 +1,3 @@
|
|
|
1
|
-
import { StyleSheet } from 'react-native';
|
|
2
1
|
import { withAlpha } from '../color/parse';
|
|
3
2
|
import type {
|
|
4
3
|
BorderWidthTokens,
|
|
@@ -35,23 +34,30 @@ export function buildRadius(): RadiusTokens {
|
|
|
35
34
|
return { none: 0, xs: 2, sm: 4, md: 8, lg: 16, xl: 24, full: 9999 };
|
|
36
35
|
}
|
|
37
36
|
|
|
38
|
-
|
|
39
|
-
|
|
37
|
+
/** `hairline` is the device's thinnest line, resolved by `buildThemes` (THM-04). */
|
|
38
|
+
export function buildBorderWidth(hairline: number): BorderWidthTokens {
|
|
39
|
+
return { hairline, thin: 1, thick: 2 };
|
|
40
40
|
}
|
|
41
41
|
|
|
42
42
|
export function buildSize(): SizeTokens {
|
|
43
43
|
return { touchTarget: 44, icon: { sm: 16, md: 20, lg: 24 } };
|
|
44
44
|
}
|
|
45
45
|
|
|
46
|
+
/**
|
|
47
|
+
* The opacity scale, as one definition. `color.disabled` is derived from
|
|
48
|
+
* `disabledContent` rather than from a second copy of 0.38 (THM-07).
|
|
49
|
+
*/
|
|
50
|
+
export const DEFAULT_OPACITY: OpacityTokens = {
|
|
51
|
+
hover: 0.08,
|
|
52
|
+
focus: 0.1,
|
|
53
|
+
pressed: 0.1,
|
|
54
|
+
dragged: 0.16,
|
|
55
|
+
disabledContent: 0.38,
|
|
56
|
+
disabledContainer: 0.12,
|
|
57
|
+
};
|
|
58
|
+
|
|
46
59
|
export function buildOpacity(): OpacityTokens {
|
|
47
|
-
return {
|
|
48
|
-
hover: 0.08,
|
|
49
|
-
focus: 0.1,
|
|
50
|
-
pressed: 0.1,
|
|
51
|
-
dragged: 0.16,
|
|
52
|
-
disabledContent: 0.38,
|
|
53
|
-
disabledContainer: 0.12,
|
|
54
|
-
};
|
|
60
|
+
return { ...DEFAULT_OPACITY };
|
|
55
61
|
}
|
|
56
62
|
|
|
57
63
|
export function buildMotion(): MotionTokens {
|
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
import { buildThemes, type BuildThemesOptions } from '../theme/buildThemes';
|
|
2
2
|
import { ThemeConfigError } from '../theme/errors';
|
|
3
3
|
import { isPlainObject } from '../theme/overrides';
|
|
4
|
-
import { themeNamesOf } from '../theme/validateThemeConfig';
|
|
4
|
+
import { themeNamesOf, validateThemeConfig } from '../theme/validateThemeConfig';
|
|
5
5
|
import { DEFAULT_BREAKPOINTS } from '../tokens/defaults';
|
|
6
6
|
import type { EmptyExtension, ThemeConfig, ThemeName, Themes } from '../types/theme';
|
|
7
7
|
|
|
@@ -159,6 +159,9 @@ export function createUnistylesConfig<
|
|
|
159
159
|
config: ThemeConfig<TExt, TTone, TName>,
|
|
160
160
|
options?: UnistylesConfigOptions<NoInfer<TName>, TBreakpoints>,
|
|
161
161
|
): UnistylesConfig<TExt, TTone, TName, TBreakpoints> {
|
|
162
|
+
// The config first: reading `config.themes` for the theme names threw a
|
|
163
|
+
// TypeError with no path when the config itself was not an object (THM-10).
|
|
164
|
+
validateThemeConfig(config);
|
|
162
165
|
validateOptions(options, themeNamesOf(config));
|
|
163
166
|
|
|
164
167
|
return {
|
|
@@ -1,4 +1,6 @@
|
|
|
1
|
-
import {
|
|
1
|
+
import { lazyPeer } from '@chipmobilesdk/rn-core';
|
|
2
|
+
|
|
3
|
+
import { ThemeConfigError } from '../theme/errors';
|
|
2
4
|
|
|
3
5
|
export type ThemeMode = 'system' | 'light' | 'dark';
|
|
4
6
|
|
|
@@ -8,16 +10,37 @@ export interface ThemeModeRuntime {
|
|
|
8
10
|
setTheme(name: string): void;
|
|
9
11
|
}
|
|
10
12
|
|
|
11
|
-
//
|
|
12
|
-
//
|
|
13
|
-
|
|
13
|
+
// Loaded when a mode is first applied, so importing the package (and building
|
|
14
|
+
// themes) never loads Unistyles (THM-04). The package cannot see the app's
|
|
15
|
+
// augmented `UnistylesThemes`, so the runtime is typed by the two calls it needs.
|
|
16
|
+
const unistyles = lazyPeer('react-native-unistyles', () => {
|
|
17
|
+
// Inside `try`, so Metro bundles an app that leaves this optional peer out.
|
|
18
|
+
try {
|
|
19
|
+
return require('react-native-unistyles') as { UnistylesRuntime: ThemeModeRuntime };
|
|
20
|
+
} catch (cause) {
|
|
21
|
+
throw cause;
|
|
22
|
+
}
|
|
23
|
+
});
|
|
24
|
+
|
|
25
|
+
function defaultRuntime(): ThemeModeRuntime {
|
|
26
|
+
return unistyles.require(
|
|
27
|
+
peer =>
|
|
28
|
+
new ThemeConfigError(
|
|
29
|
+
'applyThemeMode',
|
|
30
|
+
`${peer} is not installed; pass a runtime or install it`,
|
|
31
|
+
),
|
|
32
|
+
).UnistylesRuntime;
|
|
33
|
+
}
|
|
14
34
|
|
|
15
35
|
/**
|
|
16
36
|
* Applies a light/dark/system preference. Adaptive themes are switched off
|
|
17
37
|
* before a manual theme is set; in the other order the next OS colour-scheme
|
|
18
38
|
* change overrides the user's choice. Persisting the preference is the app's.
|
|
19
39
|
*/
|
|
20
|
-
export function applyThemeMode(
|
|
40
|
+
export function applyThemeMode(
|
|
41
|
+
mode: ThemeMode,
|
|
42
|
+
runtime: ThemeModeRuntime = defaultRuntime(),
|
|
43
|
+
): void {
|
|
21
44
|
if (mode === 'system') {
|
|
22
45
|
runtime.setAdaptiveThemes(true);
|
|
23
46
|
return;
|
package/src/vendor/mcu/index.ts
CHANGED
|
@@ -2,4 +2,4 @@
|
|
|
2
2
|
// imports from here only, so the vendored surface stays explicit.
|
|
3
3
|
export { Hct } from './hct/hct';
|
|
4
4
|
export { TonalPalette } from './palettes/tonal_palette';
|
|
5
|
-
export { argbFromRgb
|
|
5
|
+
export { argbFromRgb } from './utils/color_utils';
|