@chipmobilesdk/rn-theme 1.0.0 → 1.1.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 +73 -1
- package/README.md +11 -3
- package/package.json +6 -3
- package/skills/sdk-rn-theme/SKILL.md +3 -3
- package/src/color/contrast.ts +10 -7
- package/src/color/parse.ts +4 -5
- package/src/index.ts +2 -4
- package/src/theme/buildThemes.ts +43 -6
- package/src/theme/errors.ts +18 -5
- package/src/theme/overrides.ts +6 -1
- package/src/theme/validateThemeConfig.ts +8 -2
- package/src/tokens/defaults.ts +3 -3
- package/src/tokens/typography.ts +5 -2
- package/src/types/colors.ts +2 -14
- package/src/types/theme.ts +4 -2
- package/src/types/typography.ts +1 -3
- package/src/unistyles/createUnistylesConfig.ts +13 -9
- package/src/unistyles/themeMode.ts +28 -5
package/CHANGELOG.md
CHANGED
|
@@ -1,4 +1,76 @@
|
|
|
1
|
-
#
|
|
1
|
+
# @chipmobilesdk/rn-theme
|
|
2
|
+
|
|
3
|
+
## 1.1.0
|
|
4
|
+
|
|
5
|
+
### Minor Changes
|
|
6
|
+
|
|
7
|
+
- One diagnostic stream: install a sink with `installSdkSink` from
|
|
8
|
+
`@chipmobilesdk/rn-core` and it receives what every package observes (events,
|
|
9
|
+
failures, warnings) as `SdkDiagnostic`s, redacted. Listeners are notified through
|
|
10
|
+
rn-core, so a listener that throws no longer stops the others nor fails the call that
|
|
11
|
+
notified, and its error is reported rather than swallowed. ([#17](https://github.com/operationd8979/chip-mobile-sdk/pull/17))
|
|
12
|
+
|
|
13
|
+
- rn-ads: a banner's auto-refresh is a new cycle, so its `loaded` and `impression`
|
|
14
|
+
events are no longer deduplicated away; the dedupe memory is bounded. A throwing
|
|
15
|
+
status, consent or placement listener is reported as an `internal_error` event.
|
|
16
|
+
- rn-i18n: new `onDiagnostic` config receives every diagnostic (`I18nDiagnostic`,
|
|
17
|
+
with a stable `code`); without it, warnings go to `console.warn` in development
|
|
18
|
+
builds only. The warn-once memory is bounded.
|
|
19
|
+
- rn-chart: now depends on rn-core for its diagnostics.
|
|
20
|
+
- rn-payment: status, entitlement and pending listeners report a throw to the logger
|
|
21
|
+
instead of swallowing it.
|
|
22
|
+
|
|
23
|
+
- The package's errors extend `SdkError` from the new `@chipmobilesdk/rn-core`
|
|
24
|
+
dependency: `isSdkError` recognises them alongside every other package's, and each
|
|
25
|
+
carries `pkg`, `code` and `retryable`. Names and codes are unchanged. ([#17](https://github.com/operationd8979/chip-mobile-sdk/pull/17))
|
|
26
|
+
|
|
27
|
+
- rn-ai and rn-camera: `retryable` is the new name of `recoverable`, which stays as a
|
|
28
|
+
deprecated alias.
|
|
29
|
+
- rn-auth: `retryable` is true for `network-unavailable`. `redact` also removes URLs,
|
|
30
|
+
e-mail addresses and UUIDs.
|
|
31
|
+
- rn-ads: `redact` also removes JWTs and e-mail addresses, and reduces an `AdsError`
|
|
32
|
+
to its code.
|
|
33
|
+
- rn-ai: an error message quoted from the gateway is pattern-redacted.
|
|
34
|
+
- rn-camera: `redact` also removes JWTs, e-mail addresses and UUIDs.
|
|
35
|
+
- rn-theme: `ThemeConfigError` has `code: 'invalid_config'` and `ThemeContrastError`
|
|
36
|
+
`code: 'contrast'` (new `ThemeErrorCode` type).
|
|
37
|
+
- rn-i18n: `I18nConfigError` has `code: 'invalid_config'` and `UnitConversionError`
|
|
38
|
+
`code: 'unit_conversion'` (new `I18nErrorCode` type).
|
|
39
|
+
|
|
40
|
+
- Native peers load through `lazyPeer` from `@chipmobilesdk/rn-core`, on first use,
|
|
41
|
+
and a missing one is each package's own typed error. ([#17](https://github.com/operationd8979/chip-mobile-sdk/pull/17))
|
|
42
|
+
|
|
43
|
+
- rn-auth: creating `auth` no longer loads react-native-app-auth or
|
|
44
|
+
react-native-keychain; each loads on first use. New entrypoints
|
|
45
|
+
`@chipmobilesdk/rn-auth/app-auth` and `/keychain`. The default platform comes from
|
|
46
|
+
rn-core.
|
|
47
|
+
- rn-camera: a preview whose VisionCamera feed cannot load fails the session with
|
|
48
|
+
`cameraUnavailable` (new `controller.reportSurfaceUnavailable()`), instead of
|
|
49
|
+
showing black with no error. A missing VisionCamera module is `cameraUnavailable`.
|
|
50
|
+
- rn-notification: `createMmkvStore` reports a fallback to memory as
|
|
51
|
+
`STATE_STORE_DEGRADED` (to the SDK sinks and a new `onDegraded` option) and
|
|
52
|
+
exposes `durable`.
|
|
53
|
+
- rn-payment: an iOS app validates its catalog against the iOS product ids; the
|
|
54
|
+
default platform used to be Android everywhere. A missing react-native-iap is
|
|
55
|
+
`PAYMENT_UNAVAILABLE`.
|
|
56
|
+
- rn-theme: `buildThemes` runs without React Native (new `hairlineWidth` option) and
|
|
57
|
+
importing the package no longer loads Unistyles; `applyThemeMode` loads it on
|
|
58
|
+
first use.
|
|
59
|
+
- rn-ads, rn-local-db: the same loading, with the same error codes as before.
|
|
60
|
+
|
|
61
|
+
### Patch Changes
|
|
62
|
+
|
|
63
|
+
- 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)):
|
|
64
|
+
- @chipmobilesdk/rn-core@0.1.0
|
|
65
|
+
|
|
66
|
+
## 1.0.1
|
|
67
|
+
|
|
68
|
+
### Patch Changes
|
|
69
|
+
|
|
70
|
+
- Documentation and packaging only; no code change. The README no longer writes a
|
|
71
|
+
version into its header (the changelog is the one place a version lives), the agent
|
|
72
|
+
skill no longer carries one (the consuming app's skill sync stamps the installed
|
|
73
|
+
version), and the changelog goes straight from its title to its releases. ([#15](https://github.com/operationd8979/chip-mobile-sdk/pull/15))
|
|
2
74
|
|
|
3
75
|
## 1.0.0 - 2026-10-17
|
|
4
76
|
|
package/README.md
CHANGED
|
@@ -7,7 +7,7 @@ Native apps, configured for `react-native-unistyles` 3.
|
|
|
7
7
|
> applications; no open-source license is granted. Includes Apache-2.0 code from
|
|
8
8
|
> material-color-utilities — see `NOTICE`.
|
|
9
9
|
|
|
10
|
-
- **Version**:
|
|
10
|
+
- **Version**: see [CHANGELOG](CHANGELOG.md)
|
|
11
11
|
- **Entrypoint**: `@chipmobilesdk/rn-theme`
|
|
12
12
|
- **Demo**: `src/screens/ExampleScreen.tsx`
|
|
13
13
|
|
|
@@ -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
|
|
|
@@ -220,7 +221,10 @@ belong in `extend`.
|
|
|
220
221
|
|
|
221
222
|
## Integrations
|
|
222
223
|
|
|
223
|
-
- `buildThemes(config)` — the same themes without Unistyles, e.g. for tests
|
|
224
|
+
- `buildThemes(config, options?)` — the same themes without Unistyles, e.g. for tests, other
|
|
225
|
+
libraries or a token export. It needs no React Native either: pass `platform` and
|
|
226
|
+
`hairlineWidth`, or they default to the running platform and
|
|
227
|
+
`StyleSheet.hairlineWidth` (1 where React Native is not loaded).
|
|
224
228
|
- `toNavigationTheme(theme, scheme)` — a React Navigation theme (no navigation dependency).
|
|
225
229
|
|
|
226
230
|
## Public Runtime Exports
|
|
@@ -244,6 +248,10 @@ Only `@chipmobilesdk/rn-theme` is public. Deep imports (`.../src/...`) are unsup
|
|
|
244
248
|
|
|
245
249
|
## Configuration Validation
|
|
246
250
|
|
|
251
|
+
Both errors extend `SdkError` from `@chipmobilesdk/rn-core` and carry a `code`
|
|
252
|
+
(`ThemeErrorCode`): `invalid_config` for `ThemeConfigError`, `contrast` for
|
|
253
|
+
`ThemeContrastError`.
|
|
254
|
+
|
|
247
255
|
Invalid input throws `ThemeConfigError` whose `path` names the field, e.g.
|
|
248
256
|
`overrides.dark.color.error`. Checked: brand and seed colours (opaque, supported format),
|
|
249
257
|
tone colours, typography numbers and font maps, named themes, override keys (must exist),
|
|
@@ -291,7 +299,7 @@ spreads and casts into `extend`.
|
|
|
291
299
|
## Release Validation
|
|
292
300
|
|
|
293
301
|
```bash
|
|
294
|
-
npm run validate
|
|
302
|
+
npm run validate
|
|
295
303
|
```
|
|
296
304
|
|
|
297
305
|
## Semver and Migration
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@chipmobilesdk/rn-theme",
|
|
3
|
-
"version": "1.
|
|
3
|
+
"version": "1.1.0",
|
|
4
4
|
"private": false,
|
|
5
5
|
"license": "UNLICENSED",
|
|
6
6
|
"main": "src/index.ts",
|
|
@@ -29,8 +29,11 @@
|
|
|
29
29
|
],
|
|
30
30
|
"sideEffects": false,
|
|
31
31
|
"scripts": {
|
|
32
|
-
"typecheck": "tsc -p tsconfig.json
|
|
33
|
-
"pack:
|
|
32
|
+
"typecheck": "tsc -p tsconfig.json && tsc -p tsconfig.test.json",
|
|
33
|
+
"pack:check": "npm pack --dry-run"
|
|
34
|
+
},
|
|
35
|
+
"dependencies": {
|
|
36
|
+
"@chipmobilesdk/rn-core": "^0.1.0"
|
|
34
37
|
},
|
|
35
38
|
"peerDependencies": {
|
|
36
39
|
"react": ">=19",
|
|
@@ -3,7 +3,6 @@ name: sdk-rn-theme
|
|
|
3
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: "1.0.0"
|
|
7
6
|
user-invocable: true
|
|
8
7
|
disable-model-invocation: false
|
|
9
8
|
---
|
|
@@ -46,11 +45,12 @@ Unistyles 3. Single source of truth for visual tokens; app tokens join through `
|
|
|
46
45
|
package) augments `UnistylesThemes` / `UnistylesBreakpoints` from `typeof config`.
|
|
47
46
|
- Merge Unistyles styles with arrays, never object spread of Unistyles entries.
|
|
48
47
|
|
|
49
|
-
## Public API
|
|
48
|
+
## Public API
|
|
50
49
|
|
|
51
50
|
Runtime: `createUnistylesConfig`, `buildThemes`, `applyThemeMode`, `byTone`, `layout`,
|
|
52
51
|
`presets`, `auditTheme`, `contrastRatio`, `withAlpha`, `toNavigationTheme`,
|
|
53
|
-
`DEFAULT_BREAKPOINTS`, `ThemeConfigError`, `ThemeContrastError
|
|
52
|
+
`DEFAULT_BREAKPOINTS`, `ThemeConfigError`, `ThemeContrastError` (both rn-core SDK errors with a
|
|
53
|
+
`ThemeErrorCode`).
|
|
54
54
|
|
|
55
55
|
Theme groups: `color`, `tone`, `typography`, `font`, `fontWeight`, `spacing`, `radius`,
|
|
56
56
|
`borderWidth`, `size`, `opacity`, `motion`, `shadow`, `zIndex`, `maxFontScale`,
|
package/src/color/contrast.ts
CHANGED
|
@@ -107,7 +107,11 @@ function guaranteedPairs(theme: object): ContrastPair[] {
|
|
|
107
107
|
const tone = `tone.${name}`;
|
|
108
108
|
pairs.push(
|
|
109
109
|
...onBase(`${tone}.fg`),
|
|
110
|
-
{
|
|
110
|
+
{
|
|
111
|
+
foreground: `${tone}.onContainer`,
|
|
112
|
+
background: `${tone}.container`,
|
|
113
|
+
target: TEXT_CONTRAST,
|
|
114
|
+
},
|
|
111
115
|
{ foreground: `${tone}.onFill`, background: `${tone}.fill`, target: TEXT_CONTRAST },
|
|
112
116
|
{ foreground: `${tone}.border`, background: 'color.background', target: NON_TEXT_CONTRAST },
|
|
113
117
|
);
|
|
@@ -161,11 +165,7 @@ function channelLuminance(channel: number): number {
|
|
|
161
165
|
}
|
|
162
166
|
|
|
163
167
|
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
|
-
);
|
|
168
|
+
return 0.2126 * channelLuminance(r) + 0.7152 * channelLuminance(g) + 0.0722 * channelLuminance(b);
|
|
169
169
|
}
|
|
170
170
|
|
|
171
171
|
function composite(top: Rgba, bottom: Rgba): Rgba {
|
|
@@ -190,7 +190,10 @@ export function contrastRatio(foreground: string, background: string, base?: str
|
|
|
190
190
|
|
|
191
191
|
const opaqueBackground = composite(bg, composite(baseColor, WHITE));
|
|
192
192
|
const opaqueForeground = composite(fg, opaqueBackground);
|
|
193
|
-
const lighter = Math.max(
|
|
193
|
+
const lighter = Math.max(
|
|
194
|
+
relativeLuminance(opaqueForeground),
|
|
195
|
+
relativeLuminance(opaqueBackground),
|
|
196
|
+
);
|
|
194
197
|
const darker = Math.min(relativeLuminance(opaqueForeground), relativeLuminance(opaqueBackground));
|
|
195
198
|
|
|
196
199
|
return (lighter + LUMINANCE_OFFSET) / (darker + LUMINANCE_OFFSET);
|
package/src/color/parse.ts
CHANGED
|
@@ -50,12 +50,11 @@ function parseHex(digits: string): Rgba {
|
|
|
50
50
|
const full =
|
|
51
51
|
digits.length <= 4
|
|
52
52
|
? digits
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
53
|
+
.split('')
|
|
54
|
+
.map(digit => digit + digit)
|
|
55
|
+
.join('')
|
|
56
56
|
: digits;
|
|
57
|
-
const channel = (index: number) =>
|
|
58
|
-
parseInt(full.slice(index * 2, index * 2 + 2), 16);
|
|
57
|
+
const channel = (index: number) => parseInt(full.slice(index * 2, index * 2 + 2), 16);
|
|
59
58
|
|
|
60
59
|
return {
|
|
61
60
|
r: channel(0),
|
package/src/index.ts
CHANGED
|
@@ -2,13 +2,11 @@
|
|
|
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';
|
|
8
|
-
export type {
|
|
9
|
-
UnistylesConfig,
|
|
10
|
-
UnistylesConfigOptions,
|
|
11
|
-
} from './unistyles/createUnistylesConfig';
|
|
9
|
+
export type { UnistylesConfig, UnistylesConfigOptions } from './unistyles/createUnistylesConfig';
|
|
12
10
|
export { applyThemeMode } from './unistyles/themeMode';
|
|
13
11
|
export type { ThemeMode, ThemeModeRuntime } from './unistyles/themeMode';
|
|
14
12
|
export { DEFAULT_BREAKPOINTS } from './tokens/defaults';
|
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';
|
|
@@ -32,6 +34,26 @@ import { validateThemeConfig } from './validateThemeConfig';
|
|
|
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
59
|
/** Groups that come straight from tokens (presets and tones are derived after). */
|
|
@@ -74,6 +96,7 @@ function buildOne(
|
|
|
74
96
|
source: ThemeSource,
|
|
75
97
|
fonts: ReturnType<typeof buildFonts>,
|
|
76
98
|
overrides: unknown,
|
|
99
|
+
hairlineWidth: number,
|
|
77
100
|
): object {
|
|
78
101
|
const { name, scheme, config, palettes } = source;
|
|
79
102
|
const path = `overrides.${name}`;
|
|
@@ -86,7 +109,7 @@ function buildOne(
|
|
|
86
109
|
fontWeight: buildFontWeight(),
|
|
87
110
|
spacing: buildSpacing(),
|
|
88
111
|
radius: buildRadius(),
|
|
89
|
-
borderWidth: buildBorderWidth(),
|
|
112
|
+
borderWidth: buildBorderWidth(hairlineWidth),
|
|
90
113
|
size: buildSize(),
|
|
91
114
|
opacity: buildOpacity(),
|
|
92
115
|
motion: buildMotion(),
|
|
@@ -129,11 +152,15 @@ function checkContrast(name: string, theme: object, strict: boolean): void {
|
|
|
129
152
|
throw new ThemeContrastError(name, failures);
|
|
130
153
|
}
|
|
131
154
|
|
|
155
|
+
channel.emit('contrast', 'warn', { theme: name, failures: failures.length });
|
|
132
156
|
console.warn(
|
|
133
157
|
`[rn-theme] Theme "${name}" breaks contrast guarantees after overrides:\n${describeFailures(failures)}`,
|
|
134
158
|
);
|
|
135
159
|
}
|
|
136
160
|
|
|
161
|
+
/** Contrast warnings also reach the app's SDK sinks (`installSdkSink`, CORE-08). */
|
|
162
|
+
const channel = createDiagnosticChannel<'contrast'>('rn-theme');
|
|
163
|
+
|
|
137
164
|
/**
|
|
138
165
|
* Builds every theme from brand config — pure, with no Unistyles dependency
|
|
139
166
|
* (spec AC-9.4). `createUnistylesConfig` wraps this; call it directly to feed
|
|
@@ -143,11 +170,16 @@ export function buildThemes<
|
|
|
143
170
|
TExt extends object = EmptyExtension,
|
|
144
171
|
TTone extends string = never,
|
|
145
172
|
TName extends string = never,
|
|
146
|
-
>(
|
|
173
|
+
>(
|
|
174
|
+
config: ThemeConfig<TExt, TTone, TName>,
|
|
175
|
+
options?: BuildThemesOptions,
|
|
176
|
+
): Themes<TExt, TTone, TName> {
|
|
147
177
|
validateThemeConfig(config);
|
|
148
178
|
|
|
149
179
|
const loose = config as unknown as ThemeConfig<object, string, string>;
|
|
150
|
-
|
|
180
|
+
// rn-core's one platform source (CORE-09); off-device, fonts resolve as on Android.
|
|
181
|
+
const platform = options?.platform ?? (currentPlatform() === 'ios' ? 'ios' : 'android');
|
|
182
|
+
const hairlineWidth = options?.hairlineWidth ?? deviceHairlineWidth();
|
|
151
183
|
const fonts = buildFonts(loose.typography, platform);
|
|
152
184
|
const basePalettes = buildPalettes(loose);
|
|
153
185
|
const sources: ThemeSource[] = [
|
|
@@ -161,14 +193,19 @@ export function buildThemes<
|
|
|
161
193
|
tones: { ...loose.tones, ...named.tones } as Record<string, string>,
|
|
162
194
|
};
|
|
163
195
|
|
|
164
|
-
return {
|
|
196
|
+
return {
|
|
197
|
+
name,
|
|
198
|
+
scheme: named.scheme,
|
|
199
|
+
config: namedConfig,
|
|
200
|
+
palettes: buildPalettes(namedConfig),
|
|
201
|
+
};
|
|
165
202
|
}),
|
|
166
203
|
];
|
|
167
204
|
const themes: Record<string, object> = {};
|
|
168
205
|
|
|
169
206
|
for (const source of sources) {
|
|
170
207
|
const overrides = (loose.overrides as Record<string, unknown> | undefined)?.[source.name];
|
|
171
|
-
const theme = buildOne(source, fonts, overrides);
|
|
208
|
+
const theme = buildOne(source, fonts, overrides, hairlineWidth);
|
|
172
209
|
|
|
173
210
|
checkContrast(source.name, theme, loose.strict === true);
|
|
174
211
|
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;
|
package/src/theme/overrides.ts
CHANGED
|
@@ -85,7 +85,12 @@ function mergeEntry(entry: Entry): unknown {
|
|
|
85
85
|
* keys must exist in `base`, colours must parse, numbers must be finite.
|
|
86
86
|
* Arrays are replaced whole. `base` itself is never mutated.
|
|
87
87
|
*/
|
|
88
|
-
export function mergeOverrides<T>(
|
|
88
|
+
export function mergeOverrides<T>(
|
|
89
|
+
base: T,
|
|
90
|
+
overrides: unknown,
|
|
91
|
+
path: string,
|
|
92
|
+
segments: string[] = [],
|
|
93
|
+
): T {
|
|
89
94
|
if (overrides == null) {
|
|
90
95
|
return base;
|
|
91
96
|
}
|
|
@@ -70,7 +70,10 @@ function validateFontMap(value: unknown, path: string): void {
|
|
|
70
70
|
|
|
71
71
|
for (const [weight, family] of Object.entries(value)) {
|
|
72
72
|
if (!FONT_WEIGHT_NAMES.includes(weight as FontWeightName)) {
|
|
73
|
-
throw new ThemeConfigError(
|
|
73
|
+
throw new ThemeConfigError(
|
|
74
|
+
`${path}.${weight}`,
|
|
75
|
+
`is not one of ${FONT_WEIGHT_NAMES.join(', ')}.`,
|
|
76
|
+
);
|
|
74
77
|
}
|
|
75
78
|
assertNonEmptyString(family, `${path}.${weight}`);
|
|
76
79
|
}
|
|
@@ -114,7 +117,10 @@ function validateNamedThemes(value: unknown): void {
|
|
|
114
117
|
const path = `themes.${name}`;
|
|
115
118
|
|
|
116
119
|
if (RESERVED_THEME_NAMES.has(name)) {
|
|
117
|
-
throw new ThemeConfigError(
|
|
120
|
+
throw new ThemeConfigError(
|
|
121
|
+
path,
|
|
122
|
+
'light and dark are built in; customise them with overrides.',
|
|
123
|
+
);
|
|
118
124
|
}
|
|
119
125
|
|
|
120
126
|
assertObject(theme, path);
|
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,8 +34,9 @@ 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 {
|
package/src/tokens/typography.ts
CHANGED
|
@@ -84,7 +84,9 @@ export function buildFonts(config: TypographyConfig | undefined, platform: Platf
|
|
|
84
84
|
// iOS picks weights inside one family, so a partial iOS map only renames
|
|
85
85
|
// the weights it lists; Android needs a file per weight, hence nearest.
|
|
86
86
|
const nearest =
|
|
87
|
-
platform === 'ios'
|
|
87
|
+
platform === 'ios'
|
|
88
|
+
? perWeight?.[weight] && weight
|
|
89
|
+
: perWeight && nearestWeight(weight, perWeight);
|
|
88
90
|
|
|
89
91
|
if (!perWeight || !nearest) {
|
|
90
92
|
return { fontFamily: family, fontWeight: FONT_WEIGHTS[weight] };
|
|
@@ -117,7 +119,8 @@ export function buildTypography(
|
|
|
117
119
|
font: Record<FontWeightName, FontToken>,
|
|
118
120
|
family: string,
|
|
119
121
|
): Typography {
|
|
120
|
-
const scale =
|
|
122
|
+
const scale =
|
|
123
|
+
((config?.baseFontSize ?? DEFAULT_BODY_SIZE) / DEFAULT_BODY_SIZE) * (config?.fontScale ?? 1);
|
|
121
124
|
const typography = { fontFamily: family } as Typography;
|
|
122
125
|
|
|
123
126
|
for (const role of Object.keys(SCALE) as TypographyRole[]) {
|
package/src/types/colors.ts
CHANGED
|
@@ -2,13 +2,7 @@ export type ColorScheme = 'light' | 'dark';
|
|
|
2
2
|
|
|
3
3
|
/** Colours whose roles come in sets of four: X, onX, XContainer, onXContainer. */
|
|
4
4
|
export type AccentRoleName =
|
|
5
|
-
| '
|
|
6
|
-
| 'secondary'
|
|
7
|
-
| 'tertiary'
|
|
8
|
-
| 'error'
|
|
9
|
-
| 'success'
|
|
10
|
-
| 'warning'
|
|
11
|
-
| 'info';
|
|
5
|
+
'primary' | 'secondary' | 'tertiary' | 'error' | 'success' | 'warning' | 'info';
|
|
12
6
|
|
|
13
7
|
export interface ColorRoles {
|
|
14
8
|
primary: string;
|
|
@@ -69,13 +63,7 @@ export interface ColorRoles {
|
|
|
69
63
|
disabled: string;
|
|
70
64
|
}
|
|
71
65
|
|
|
72
|
-
export type StandardToneName =
|
|
73
|
-
| 'neutral'
|
|
74
|
-
| 'primary'
|
|
75
|
-
| 'info'
|
|
76
|
-
| 'success'
|
|
77
|
-
| 'warning'
|
|
78
|
-
| 'error';
|
|
66
|
+
export type StandardToneName = 'neutral' | 'primary' | 'info' | 'success' | 'warning' | 'error';
|
|
79
67
|
|
|
80
68
|
/** A matched colour set for one tone, for use with `byTone`. */
|
|
81
69
|
export interface ToneColors {
|
package/src/types/theme.ts
CHANGED
|
@@ -114,8 +114,10 @@ export interface BaseTheme<TTone extends string = never> {
|
|
|
114
114
|
|
|
115
115
|
export type EmptyExtension = Record<never, never>;
|
|
116
116
|
|
|
117
|
-
export type Theme<
|
|
118
|
-
TExt
|
|
117
|
+
export type Theme<
|
|
118
|
+
TExt extends object = EmptyExtension,
|
|
119
|
+
TTone extends string = never,
|
|
120
|
+
> = BaseTheme<TTone> & TExt;
|
|
119
121
|
|
|
120
122
|
export type ThemeName<TName extends string = never> = 'light' | 'dark' | TName;
|
|
121
123
|
|
package/src/types/typography.ts
CHANGED
|
@@ -32,9 +32,7 @@ export interface TypographyConfig {
|
|
|
32
32
|
export type TypographyRole = 'display' | 'headline' | 'title' | 'body' | 'label' | 'caption';
|
|
33
33
|
|
|
34
34
|
export type TypographyStyleName =
|
|
35
|
-
| TypographyRole
|
|
36
|
-
| `${TypographyRole}Small`
|
|
37
|
-
| `${TypographyRole}Large`;
|
|
35
|
+
TypographyRole | `${TypographyRole}Small` | `${TypographyRole}Large`;
|
|
38
36
|
|
|
39
37
|
export interface TypographyToken {
|
|
40
38
|
fontSize: number;
|
|
@@ -21,15 +21,15 @@ export type UnistylesConfigOptions<
|
|
|
21
21
|
breakpoints?: TBreakpoints;
|
|
22
22
|
} & (
|
|
23
23
|
| {
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
24
|
+
/** Theme active on first launch. Defaults to 'light'. */
|
|
25
|
+
initialTheme?: ThemeName<TName> | (() => ThemeName<TName>);
|
|
26
|
+
adaptiveThemes?: false;
|
|
27
|
+
}
|
|
28
28
|
| {
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
29
|
+
/** Follow the system colour scheme between light and dark. */
|
|
30
|
+
adaptiveThemes: boolean;
|
|
31
|
+
initialTheme?: never;
|
|
32
|
+
}
|
|
33
33
|
);
|
|
34
34
|
|
|
35
35
|
type UnistylesSettings<TName extends string> = UnistylesBaseSettings &
|
|
@@ -103,7 +103,11 @@ function validateOptions(options: unknown, themeNames: string[]): void {
|
|
|
103
103
|
throw new ThemeConfigError('options.CSSVars', 'must be a boolean.');
|
|
104
104
|
}
|
|
105
105
|
|
|
106
|
-
if (
|
|
106
|
+
if (
|
|
107
|
+
nativeBreakpointsMode != null &&
|
|
108
|
+
nativeBreakpointsMode !== 'pixels' &&
|
|
109
|
+
nativeBreakpointsMode !== 'points'
|
|
110
|
+
) {
|
|
107
111
|
throw new ThemeConfigError('options.nativeBreakpointsMode', "must be 'pixels' or 'points'.");
|
|
108
112
|
}
|
|
109
113
|
|
|
@@ -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;
|