@chipmobilesdk/rn-theme 1.1.0 → 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,17 @@
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
+
3
15
  ## 1.1.0
4
16
 
5
17
  ### Minor Changes
@@ -119,7 +131,7 @@ extension, and style presets. See `specs/014-rn-theme-v1/spec.md` in the source
119
131
  | `color.disabled` used as muted text | `color.onSurfaceVariant` or `text.bodyMuted` / `text.labelMuted` |
120
132
  | `color.disabled` for inactive UI | unchanged meaning; now `onSurface` at 38 % opacity, contrast-exempt |
121
133
  | `color.error`/`success`/`warning`/`info` as fills | `color.errorContainer` … with `onErrorContainer` …, or `tone.*.fill` / `onFill` |
122
- | `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) |
123
135
  | `shadow.md.shadowColor` … `elevation` | `shadow.md.boxShadow` |
124
136
  | App tokens spread into the theme + cast | `extend: ({ theme }) => ({ ... })` |
125
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
 
@@ -216,8 +216,17 @@ createUnistylesConfig({
216
216
  });
217
217
  ```
218
218
 
219
- Extensions may not reuse a base group name. Override keys must already exist — new tokens
220
- 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.
221
230
 
222
231
  ## Integrations
223
232
 
@@ -296,12 +305,6 @@ See the mapping table in `CHANGELOG.md` (`## 1.0.0`). In short: rename `typograp
296
305
  augmentation (`UnistylesThemeFromBrand`) with the one in *Setup Order*; move app tokens from
297
306
  spreads and casts into `extend`.
298
307
 
299
- ## Release Validation
300
-
301
- ```bash
302
- npm run validate
303
- ```
304
-
305
308
  ## Semver and Migration
306
309
 
307
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.1.0",
3
+ "version": "1.2.0",
4
4
  "private": false,
5
5
  "license": "UNLICENSED",
6
6
  "main": "src/index.ts",
@@ -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
  /**
@@ -27,8 +27,8 @@ import type {
27
27
  Themes,
28
28
  } from '../types/theme';
29
29
  import type { FontToken, FontWeightName } from '../types/typography';
30
- import { describeFailures, ThemeContrastError } from './errors';
31
- import { mergeOverrides, pick } from './overrides';
30
+ import { describeFailures, ThemeConfigError, ThemeContrastError } from './errors';
31
+ import { isPlainObject, mergeOverrides, pick } from './overrides';
32
32
  import { validateThemeConfig } from './validateThemeConfig';
33
33
 
34
34
  export interface BuildThemesOptions {
@@ -56,23 +56,15 @@ function deviceHairlineWidth(): number {
56
56
  return (result.ok ? result.module.StyleSheet?.hairlineWidth : undefined) ?? 1;
57
57
  }
58
58
 
59
- /** Groups that come straight from tokens (presets and tones are derived after). */
60
- const TOKEN_KEYS = [
61
- 'color',
62
- 'typography',
63
- 'font',
64
- 'fontWeight',
65
- 'spacing',
66
- 'radius',
67
- 'borderWidth',
68
- 'size',
69
- 'opacity',
70
- 'motion',
71
- 'shadow',
72
- 'zIndex',
73
- 'maxFontScale',
74
- 'categorical',
75
- ] 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
+ ]);
76
68
 
77
69
  interface ThemeSource {
78
70
  name: string;
@@ -92,6 +84,35 @@ function cloneFonts(font: Record<FontWeightName, FontToken>): Record<FontWeightN
92
84
  return copy;
93
85
  }
94
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
+
95
116
  function buildOne(
96
117
  source: ThemeSource,
97
118
  fonts: ReturnType<typeof buildFonts>,
@@ -100,9 +121,15 @@ function buildOne(
100
121
  ): object {
101
122
  const { name, scheme, config, palettes } = source;
102
123
  const path = `overrides.${name}`;
103
- 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`);
104
131
 
105
- const generated = {
132
+ const tokenGroups = {
106
133
  color,
107
134
  typography: buildTypography(config.typography, fonts.font, fonts.family),
108
135
  font: cloneFonts(fonts.font),
@@ -117,21 +144,41 @@ function buildOne(
117
144
  zIndex: buildZIndex(),
118
145
  maxFontScale: config.typography?.maxFontScale ?? DEFAULT_MAX_FONT_SCALE,
119
146
  categorical: buildCategorical(config.brandColors.primary, scheme),
120
- };
121
- 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);
122
151
  const tone = mergeOverrides(
123
- 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),
124
155
  pick(overrides, ['tone'])?.tone,
125
156
  `${path}.tone`,
126
157
  );
127
158
  const presets = mergeOverrides(buildPresets(tokens), pick(overrides, PRESET_KEYS), path);
128
- const base = { ...tokens, tone, ...presets } as BaseTheme<string>;
159
+ const base = { ...tokens, tone, ...presets } satisfies BaseTheme<string>;
129
160
 
130
161
  if (!config.extend) {
162
+ assertKnownOverrideGroups(overrides, [...tokenKeys, 'tone', ...PRESET_KEYS], path);
131
163
  return base;
132
164
  }
133
165
 
134
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
+ );
135
182
  const extensionOverrides = pick(overrides, Object.keys(extension));
136
183
 
137
184
  return { ...base, ...mergeOverrides(extension, extensionOverrides, path) };
@@ -186,11 +233,22 @@ export function buildThemes<
186
233
  { name: 'light', scheme: 'light', config: loose, palettes: basePalettes },
187
234
  { name: 'dark', scheme: 'dark', config: loose, palettes: basePalettes },
188
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
+ }
189
247
  const namedConfig = {
190
248
  ...loose,
191
249
  brandColors: named.brandColors ?? loose.brandColors,
192
250
  seeds: { ...loose.seeds, ...named.seeds },
193
- tones: { ...loose.tones, ...named.tones } as Record<string, string>,
251
+ tones,
194
252
  };
195
253
 
196
254
  return {
@@ -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
 
@@ -43,15 +43,21 @@ 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 {
@@ -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';