@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 CHANGED
@@ -1,4 +1,76 @@
1
- # Changelog
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**: `1.0.0`
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 or other libraries.
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:theme
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.0.0",
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 --noEmit",
33
- "pack:dry-run": "npm pack --dry-run"
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 (v1.0.0)
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`,
@@ -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
- { foreground: `${tone}.onContainer`, background: `${tone}.container`, target: TEXT_CONTRAST },
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(relativeLuminance(opaqueForeground), relativeLuminance(opaqueBackground));
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);
@@ -50,12 +50,11 @@ function parseHex(digits: string): Rgba {
50
50
  const full =
51
51
  digits.length <= 4
52
52
  ? digits
53
- .split('')
54
- .map(digit => digit + digit)
55
- .join('')
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';
@@ -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';
@@ -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
- >(config: ThemeConfig<TExt, TTone, TName>, options?: BuildThemesOptions): Themes<TExt, TTone, TName> {
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
- const platform = options?.platform ?? (ReactNativePlatform.OS === 'ios' ? 'ios' : 'android');
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 { name, scheme: named.scheme, config: namedConfig, palettes: buildPalettes(namedConfig) };
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;
@@ -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;
@@ -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>(base: T, overrides: unknown, path: string, segments: string[] = []): 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(`${path}.${weight}`, `is not one of ${FONT_WEIGHT_NAMES.join(', ')}.`);
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(path, 'light and dark are built in; customise them with overrides.');
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);
@@ -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
- 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 {
@@ -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' ? (perWeight?.[weight] && weight) : perWeight && nearestWeight(weight, perWeight);
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 = ((config?.baseFontSize ?? DEFAULT_BODY_SIZE) / DEFAULT_BODY_SIZE) * (config?.fontScale ?? 1);
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[]) {
@@ -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
- | 'primary'
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 {
@@ -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<TExt extends object = EmptyExtension, TTone extends string = never> = BaseTheme<TTone> &
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
 
@@ -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
- /** Theme active on first launch. Defaults to 'light'. */
25
- initialTheme?: ThemeName<TName> | (() => ThemeName<TName>);
26
- adaptiveThemes?: false;
27
- }
24
+ /** Theme active on first launch. Defaults to 'light'. */
25
+ initialTheme?: ThemeName<TName> | (() => ThemeName<TName>);
26
+ adaptiveThemes?: false;
27
+ }
28
28
  | {
29
- /** Follow the system colour scheme between light and dark. */
30
- adaptiveThemes: boolean;
31
- initialTheme?: never;
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 (nativeBreakpointsMode != null && nativeBreakpointsMode !== 'pixels' && nativeBreakpointsMode !== 'points') {
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 { 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;