@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 +13 -1
- package/README.md +14 -11
- package/package.json +1 -1
- package/src/color/roles.ts +3 -1
- package/src/theme/buildThemes.ts +84 -26
- package/src/theme/validateThemeConfig.ts +28 -2
- package/src/tokens/defaults.ts +14 -8
- package/src/unistyles/createUnistylesConfig.ts +4 -1
- package/src/vendor/mcu/index.ts +1 -1
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
|
|
27
|
-
iOS
|
|
25
|
+
React Native Unistyles `^3.2.5`, TypeScript `^5.8.3` and Node.js `>=22.11.0`. **Android is
|
|
26
|
+
device-validated; iOS is pending** (the package has no native code, and shadows use React
|
|
27
|
+
Native's cross-platform `boxShadow` on the New Architecture).
|
|
28
28
|
|
|
29
29
|
## Setup Order
|
|
30
30
|
|
|
@@ -216,8 +216,17 @@ createUnistylesConfig({
|
|
|
216
216
|
});
|
|
217
217
|
```
|
|
218
218
|
|
|
219
|
-
Extensions may not reuse a base group name
|
|
220
|
-
|
|
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
package/src/color/roles.ts
CHANGED
|
@@ -7,6 +7,7 @@ import type {
|
|
|
7
7
|
StandardToneName,
|
|
8
8
|
ToneColors,
|
|
9
9
|
} from '../types/colors';
|
|
10
|
+
import { DEFAULT_OPACITY } from '../tokens/defaults';
|
|
10
11
|
import { argbFromColor, hexFromArgb, withAlpha } from './parse';
|
|
11
12
|
|
|
12
13
|
/**
|
|
@@ -27,7 +28,8 @@ const MAX_NEUTRAL_SEED_CHROMA = 16;
|
|
|
27
28
|
const NEUTRAL_VARIANT_EXTRA_CHROMA = 4;
|
|
28
29
|
|
|
29
30
|
/** Material 3 opacities applied to `onSurface` and the scrim. */
|
|
30
|
-
|
|
31
|
+
/** The same figure as `opacity.disabledContent`, from its one definition (THM-07). */
|
|
32
|
+
export const DISABLED_CONTENT_OPACITY = DEFAULT_OPACITY.disabledContent;
|
|
31
33
|
const SCRIM_OPACITY = 0.32;
|
|
32
34
|
|
|
33
35
|
/**
|
package/src/theme/buildThemes.ts
CHANGED
|
@@ -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
|
-
/**
|
|
60
|
-
const
|
|
61
|
-
'
|
|
62
|
-
'
|
|
63
|
-
'
|
|
64
|
-
'
|
|
65
|
-
'
|
|
66
|
-
'
|
|
67
|
-
|
|
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
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
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 }
|
|
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
|
|
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
|
-
|
|
163
|
+
validateCustomTones(theme.tones, `${path}.tones`);
|
|
138
164
|
}
|
|
139
165
|
}
|
|
140
166
|
|
|
@@ -157,7 +183,7 @@ export function validateThemeConfig(config: unknown): void {
|
|
|
157
183
|
assertObject(config, 'config');
|
|
158
184
|
validateBrandColors(config.brandColors, 'brandColors');
|
|
159
185
|
validateColorRecord(config.seeds, 'seeds', SEED_KEYS);
|
|
160
|
-
|
|
186
|
+
validateCustomTones(config.tones, 'tones');
|
|
161
187
|
validateTypography(config.typography);
|
|
162
188
|
validateNamedThemes(config.themes);
|
|
163
189
|
|
package/src/tokens/defaults.ts
CHANGED
|
@@ -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 {
|
package/src/vendor/mcu/index.ts
CHANGED
|
@@ -2,4 +2,4 @@
|
|
|
2
2
|
// imports from here only, so the vendored surface stays explicit.
|
|
3
3
|
export { Hct } from './hct/hct';
|
|
4
4
|
export { TonalPalette } from './palettes/tonal_palette';
|
|
5
|
-
export { argbFromRgb
|
|
5
|
+
export { argbFromRgb } from './utils/color_utils';
|