@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 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`, on Android and
27
- iOS. Shadows use React Native's cross-platform `boxShadow` (New Architecture).
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. Override keys must already exist — new tokens
219
- belong in `extend`.
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 or other libraries.
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.1",
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 --noEmit",
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`,
@@ -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
- export const DISABLED_CONTENT_OPACITY = 0.38;
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';
@@ -1,4 +1,6 @@
1
- import { Platform as ReactNativePlatform } from 'react-native';
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
- /** Groups that come straight from tokens (presets and tones are derived after). */
38
- const TOKEN_KEYS = [
39
- 'color',
40
- 'typography',
41
- 'font',
42
- 'fontWeight',
43
- 'spacing',
44
- 'radius',
45
- 'borderWidth',
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 color = buildColorRoles(palettes, scheme);
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 generated = {
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
- const tokens = mergeOverrides(generated, pick(overrides, TOKEN_KEYS), path);
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
- buildTones<string>(palettes, color, scheme),
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 } as BaseTheme<string>;
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
- const platform = options?.platform ?? (ReactNativePlatform.OS === 'ios' ? 'ios' : 'android');
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: { ...loose.tones, ...named.tones } as Record<string, string>,
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;
@@ -1,23 +1,36 @@
1
+ import { SdkError } from '@chipmobilesdk/rn-core';
2
+
1
3
  import type { ContrastFailure } from '../color/contrast';
2
4
 
3
- /** Invalid configuration or override; `path` names the offending field. */
4
- export class ThemeConfigError extends Error {
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 Error {
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(`Theme "${themeName}" breaks contrast guarantees:\n${describeFailures(failures)}`);
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
- validateColorRecord(theme.tones, `${path}.tones`);
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
- validateColorRecord(config.tones, 'tones');
186
+ validateCustomTones(config.tones, 'tones');
161
187
  validateTypography(config.typography);
162
188
  validateNamedThemes(config.themes);
163
189
 
@@ -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
- export function buildBorderWidth(): BorderWidthTokens {
39
- return { hairline: StyleSheet.hairlineWidth, thin: 1, thick: 2 };
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 { UnistylesRuntime } from 'react-native-unistyles';
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
- // The package cannot see the app's augmented `UnistylesThemes`, so `setTheme`
12
- // is typed as `never` here; the runtime accepts any configured theme name.
13
- const defaultRuntime = UnistylesRuntime as unknown as ThemeModeRuntime;
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(mode: ThemeMode, runtime: ThemeModeRuntime = defaultRuntime): void {
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;
@@ -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, lstarFromArgb } from './utils/color_utils';
5
+ export { argbFromRgb } from './utils/color_utils';