@chipmobilesdk/rn-theme 0.2.2 → 1.0.1

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.
Files changed (42) hide show
  1. package/CHANGELOG.md +100 -23
  2. package/NOTICE +9 -0
  3. package/README.md +277 -187
  4. package/package.json +49 -50
  5. package/skills/sdk-rn-theme/SKILL.md +44 -48
  6. package/src/color/categorical.ts +33 -0
  7. package/src/color/contrast.ts +233 -0
  8. package/src/color/parse.ts +119 -0
  9. package/src/color/roles.ts +262 -0
  10. package/src/index.ts +23 -20
  11. package/src/integration/navigationTheme.ts +51 -0
  12. package/src/presets/byTone.ts +28 -0
  13. package/src/presets/layout.ts +38 -0
  14. package/src/presets/themePresets.ts +101 -0
  15. package/src/theme/buildThemes.ts +186 -0
  16. package/src/theme/errors.ts +34 -0
  17. package/src/theme/overrides.ts +147 -0
  18. package/src/theme/validateThemeConfig.ts +177 -0
  19. package/src/tokens/defaults.ts +103 -46
  20. package/src/tokens/typography.ts +147 -64
  21. package/src/types/brand.ts +16 -0
  22. package/src/types/colors.ts +68 -11
  23. package/src/types/index.ts +44 -5
  24. package/src/types/theme.ts +163 -0
  25. package/src/types/tokens.ts +47 -5
  26. package/src/types/typography.ts +44 -10
  27. package/src/unistyles/createUnistylesConfig.ts +169 -177
  28. package/src/unistyles/themeMode.ts +28 -0
  29. package/src/vendor/mcu/LICENSE +191 -0
  30. package/src/vendor/mcu/hct/cam16.ts +419 -0
  31. package/src/vendor/mcu/hct/hct.ts +216 -0
  32. package/src/vendor/mcu/hct/hct_solver.ts +557 -0
  33. package/src/vendor/mcu/hct/viewing_conditions.ts +121 -0
  34. package/src/vendor/mcu/index.ts +5 -0
  35. package/src/vendor/mcu/palettes/tonal_palette.ts +172 -0
  36. package/src/vendor/mcu/utils/color_utils.ts +302 -0
  37. package/src/vendor/mcu/utils/math_utils.ts +148 -0
  38. package/src/types/style.ts +0 -26
  39. package/src/unistyles/types.ts +0 -24
  40. package/src/unistyles/validateThemeConfig.ts +0 -154
  41. package/src/utils/colorGeneration.ts +0 -87
  42. package/src/utils/colorMath.ts +0 -55
@@ -0,0 +1,186 @@
1
+ import { Platform as ReactNativePlatform } from 'react-native';
2
+ import { buildCategorical } from '../color/categorical';
3
+ import { auditTheme } from '../color/contrast';
4
+ import { buildColorRoles, buildPalettes, buildTones } from '../color/roles';
5
+ import { buildPresets, PRESET_KEYS } from '../presets/themePresets';
6
+ import {
7
+ buildBorderWidth,
8
+ buildMotion,
9
+ buildOpacity,
10
+ buildRadius,
11
+ buildShadows,
12
+ buildSize,
13
+ buildSpacing,
14
+ buildZIndex,
15
+ DEFAULT_MAX_FONT_SCALE,
16
+ } from '../tokens/defaults';
17
+ import { buildFonts, buildFontWeight, buildTypography, type Platform } from '../tokens/typography';
18
+ import type { ColorScheme } from '../types/colors';
19
+ import type {
20
+ BaseTheme,
21
+ EmptyExtension,
22
+ Theme,
23
+ ThemeConfig,
24
+ ThemeName,
25
+ Themes,
26
+ } from '../types/theme';
27
+ import type { FontToken, FontWeightName } from '../types/typography';
28
+ import { describeFailures, ThemeContrastError } from './errors';
29
+ import { mergeOverrides, pick } from './overrides';
30
+ import { validateThemeConfig } from './validateThemeConfig';
31
+
32
+ export interface BuildThemesOptions {
33
+ /** Platform whose font resolution applies. Defaults to the running platform. */
34
+ platform?: Platform;
35
+ }
36
+
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;
54
+
55
+ interface ThemeSource {
56
+ name: string;
57
+ scheme: ColorScheme;
58
+ config: ThemeConfig<object, string, string>;
59
+ /** Shared by light and dark: palettes cache their tones (spec FR-4). */
60
+ palettes: ReturnType<typeof buildPalettes>;
61
+ }
62
+
63
+ function cloneFonts(font: Record<FontWeightName, FontToken>): Record<FontWeightName, FontToken> {
64
+ const copy = {} as Record<FontWeightName, FontToken>;
65
+
66
+ for (const [weight, token] of Object.entries(font) as Array<[FontWeightName, FontToken]>) {
67
+ copy[weight] = { ...token };
68
+ }
69
+
70
+ return copy;
71
+ }
72
+
73
+ function buildOne(
74
+ source: ThemeSource,
75
+ fonts: ReturnType<typeof buildFonts>,
76
+ overrides: unknown,
77
+ ): object {
78
+ const { name, scheme, config, palettes } = source;
79
+ const path = `overrides.${name}`;
80
+ const color = buildColorRoles(palettes, scheme);
81
+
82
+ const generated = {
83
+ color,
84
+ typography: buildTypography(config.typography, fonts.font, fonts.family),
85
+ font: cloneFonts(fonts.font),
86
+ fontWeight: buildFontWeight(),
87
+ spacing: buildSpacing(),
88
+ radius: buildRadius(),
89
+ borderWidth: buildBorderWidth(),
90
+ size: buildSize(),
91
+ opacity: buildOpacity(),
92
+ motion: buildMotion(),
93
+ shadow: buildShadows(color.shadow),
94
+ zIndex: buildZIndex(),
95
+ maxFontScale: config.typography?.maxFontScale ?? DEFAULT_MAX_FONT_SCALE,
96
+ categorical: buildCategorical(config.brandColors.primary, scheme),
97
+ };
98
+ const tokens = mergeOverrides(generated, pick(overrides, TOKEN_KEYS), path);
99
+ const tone = mergeOverrides(
100
+ buildTones<string>(palettes, color, scheme),
101
+ pick(overrides, ['tone'])?.tone,
102
+ `${path}.tone`,
103
+ );
104
+ const presets = mergeOverrides(buildPresets(tokens), pick(overrides, PRESET_KEYS), path);
105
+ const base = { ...tokens, tone, ...presets } as BaseTheme<string>;
106
+
107
+ if (!config.extend) {
108
+ return base;
109
+ }
110
+
111
+ const extension = config.extend({ name, scheme, theme: base });
112
+ const extensionOverrides = pick(overrides, Object.keys(extension));
113
+
114
+ return { ...base, ...mergeOverrides(extension, extensionOverrides, path) };
115
+ }
116
+
117
+ function checkContrast(name: string, theme: object, strict: boolean): void {
118
+ if (!__DEV__ && !strict) {
119
+ return;
120
+ }
121
+
122
+ const failures = auditTheme(theme);
123
+
124
+ if (failures.length === 0) {
125
+ return;
126
+ }
127
+
128
+ if (strict) {
129
+ throw new ThemeContrastError(name, failures);
130
+ }
131
+
132
+ console.warn(
133
+ `[rn-theme] Theme "${name}" breaks contrast guarantees after overrides:\n${describeFailures(failures)}`,
134
+ );
135
+ }
136
+
137
+ /**
138
+ * Builds every theme from brand config — pure, with no Unistyles dependency
139
+ * (spec AC-9.4). `createUnistylesConfig` wraps this; call it directly to feed
140
+ * tokens to other libraries or tests.
141
+ */
142
+ export function buildThemes<
143
+ TExt extends object = EmptyExtension,
144
+ TTone extends string = never,
145
+ TName extends string = never,
146
+ >(
147
+ config: ThemeConfig<TExt, TTone, TName>,
148
+ options?: BuildThemesOptions,
149
+ ): Themes<TExt, TTone, TName> {
150
+ validateThemeConfig(config);
151
+
152
+ const loose = config as unknown as ThemeConfig<object, string, string>;
153
+ const platform = options?.platform ?? (ReactNativePlatform.OS === 'ios' ? 'ios' : 'android');
154
+ const fonts = buildFonts(loose.typography, platform);
155
+ const basePalettes = buildPalettes(loose);
156
+ const sources: ThemeSource[] = [
157
+ { name: 'light', scheme: 'light', config: loose, palettes: basePalettes },
158
+ { name: 'dark', scheme: 'dark', config: loose, palettes: basePalettes },
159
+ ...Object.entries(loose.themes ?? {}).map(([name, named]) => {
160
+ const namedConfig = {
161
+ ...loose,
162
+ brandColors: named.brandColors ?? loose.brandColors,
163
+ seeds: { ...loose.seeds, ...named.seeds },
164
+ tones: { ...loose.tones, ...named.tones } as Record<string, string>,
165
+ };
166
+
167
+ return {
168
+ name,
169
+ scheme: named.scheme,
170
+ config: namedConfig,
171
+ palettes: buildPalettes(namedConfig),
172
+ };
173
+ }),
174
+ ];
175
+ const themes: Record<string, object> = {};
176
+
177
+ for (const source of sources) {
178
+ const overrides = (loose.overrides as Record<string, unknown> | undefined)?.[source.name];
179
+ const theme = buildOne(source, fonts, overrides);
180
+
181
+ checkContrast(source.name, theme, loose.strict === true);
182
+ themes[source.name] = theme;
183
+ }
184
+
185
+ return themes as Record<ThemeName<TName>, Theme<TExt, TTone>>;
186
+ }
@@ -0,0 +1,34 @@
1
+ import type { ContrastFailure } from '../color/contrast';
2
+
3
+ /** Invalid configuration or override; `path` names the offending field. */
4
+ export class ThemeConfigError extends Error {
5
+ readonly path: string;
6
+
7
+ constructor(path: string, message: string) {
8
+ super(`${path}: ${message}`);
9
+ this.name = 'ThemeConfigError';
10
+ this.path = path;
11
+ }
12
+ }
13
+
14
+ /** Thrown in `strict` mode when a theme breaks a contrast guarantee. */
15
+ export class ThemeContrastError extends Error {
16
+ readonly themeName: string;
17
+ readonly failures: ContrastFailure[];
18
+
19
+ constructor(themeName: string, failures: ContrastFailure[]) {
20
+ super(`Theme "${themeName}" breaks contrast guarantees:\n${describeFailures(failures)}`);
21
+ this.name = 'ThemeContrastError';
22
+ this.themeName = themeName;
23
+ this.failures = failures;
24
+ }
25
+ }
26
+
27
+ export function describeFailures(failures: ContrastFailure[]): string {
28
+ return failures
29
+ .map(
30
+ ({ foreground, background, ratio, target }) =>
31
+ ` ${foreground} on ${background}: ${ratio}:1 (needs ${target}:1)`,
32
+ )
33
+ .join('\n');
34
+ }
@@ -0,0 +1,147 @@
1
+ import { isValidColor } from '../color/parse';
2
+ import { ThemeConfigError } from './errors';
3
+
4
+ type PlainObject = Record<string, unknown>;
5
+
6
+ export function isPlainObject(value: unknown): value is PlainObject {
7
+ return typeof value === 'object' && value !== null && !Array.isArray(value);
8
+ }
9
+
10
+ /** Optional style keys an override may add although the generated token omits them. */
11
+ const OPTIONAL_NUMBER_KEYS = new Set(['letterSpacing']);
12
+
13
+ /** Groups whose every leaf is a colour. */
14
+ const COLOR_GROUPS = new Set(['color', 'tone', 'categorical']);
15
+
16
+ function isColorPath(segments: string[]): boolean {
17
+ const leaf = segments.at(-1) ?? '';
18
+
19
+ return COLOR_GROUPS.has(segments[0]) || leaf === 'color' || leaf.endsWith('Color');
20
+ }
21
+
22
+ function assertFiniteNumber(value: unknown, path: string): void {
23
+ if (typeof value !== 'number' || !Number.isFinite(value)) {
24
+ throw new ThemeConfigError(path, 'must be a finite number.');
25
+ }
26
+ }
27
+
28
+ function validateLeaf(value: unknown, base: unknown, segments: string[], path: string): void {
29
+ if (typeof base === 'number') {
30
+ assertFiniteNumber(value, path);
31
+ return;
32
+ }
33
+
34
+ if (typeof base !== 'string') {
35
+ return;
36
+ }
37
+
38
+ if (typeof value !== 'string') {
39
+ throw new ThemeConfigError(path, 'must be a string.');
40
+ }
41
+
42
+ // A string that is a colour today (e.g. an `extend` token) must stay one.
43
+ if ((isColorPath(segments) || isValidColor(base)) && !isValidColor(value)) {
44
+ throw new ThemeConfigError(path, `"${value}" is not a supported colour.`);
45
+ }
46
+ }
47
+
48
+ interface Entry {
49
+ key: string;
50
+ value: unknown;
51
+ baseValue: unknown;
52
+ path: string;
53
+ segments: string[];
54
+ }
55
+
56
+ function mergeArray({ value, baseValue, path, segments }: Entry): unknown[] {
57
+ if (!Array.isArray(value)) {
58
+ throw new ThemeConfigError(path, 'must be an array.');
59
+ }
60
+
61
+ const sample: unknown = Array.isArray(baseValue) ? baseValue[0] : undefined;
62
+ value.forEach((item, index) => validateLeaf(item, sample, segments, `${path}.${index}`));
63
+
64
+ return [...value];
65
+ }
66
+
67
+ function mergeEntry(entry: Entry): unknown {
68
+ const { value, baseValue, path, segments } = entry;
69
+
70
+ if (Array.isArray(baseValue)) {
71
+ return mergeArray(entry);
72
+ }
73
+
74
+ if (isPlainObject(baseValue)) {
75
+ return mergeOverrides(baseValue, value, path, segments);
76
+ }
77
+
78
+ validateLeaf(value, baseValue, segments, path);
79
+
80
+ return value;
81
+ }
82
+
83
+ /**
84
+ * Merges `overrides` into a fresh copy of `base`, validating as it goes:
85
+ * keys must exist in `base`, colours must parse, numbers must be finite.
86
+ * Arrays are replaced whole. `base` itself is never mutated.
87
+ */
88
+ export function mergeOverrides<T>(
89
+ base: T,
90
+ overrides: unknown,
91
+ path: string,
92
+ segments: string[] = [],
93
+ ): T {
94
+ if (overrides == null) {
95
+ return base;
96
+ }
97
+
98
+ if (!isPlainObject(overrides) || !isPlainObject(base)) {
99
+ throw new ThemeConfigError(path, 'must be an object.');
100
+ }
101
+
102
+ const result: PlainObject = { ...base };
103
+
104
+ for (const [key, value] of Object.entries(overrides)) {
105
+ const entry: Entry = {
106
+ key,
107
+ value,
108
+ baseValue: base[key],
109
+ path: `${path}.${key}`,
110
+ segments: [...segments, key],
111
+ };
112
+
113
+ if (value === undefined) {
114
+ continue;
115
+ }
116
+
117
+ if (key in base) {
118
+ result[key] = mergeEntry(entry);
119
+ } else if (OPTIONAL_NUMBER_KEYS.has(key)) {
120
+ assertFiniteNumber(value, entry.path);
121
+ result[key] = value;
122
+ } else {
123
+ throw new ThemeConfigError(
124
+ entry.path,
125
+ 'does not exist on the theme. Add new tokens through `extend`.',
126
+ );
127
+ }
128
+ }
129
+
130
+ return result as T;
131
+ }
132
+
133
+ export function pick(source: unknown, keys: readonly string[]): PlainObject | undefined {
134
+ if (!isPlainObject(source)) {
135
+ return undefined;
136
+ }
137
+
138
+ const picked: PlainObject = {};
139
+
140
+ for (const key of keys) {
141
+ if (key in source) {
142
+ picked[key] = source[key];
143
+ }
144
+ }
145
+
146
+ return picked;
147
+ }
@@ -0,0 +1,177 @@
1
+ import { isOpaqueColor } from '../color/parse';
2
+ import type { FontWeightName } from '../types/typography';
3
+ import { ThemeConfigError } from './errors';
4
+ import { isPlainObject } from './overrides';
5
+
6
+ const BRAND_KEYS = ['primary', 'secondary', 'tertiary'] as const;
7
+ const SEED_KEYS = ['neutral', 'error', 'success', 'warning', 'info'] as const;
8
+ const FONT_WEIGHT_NAMES: FontWeightName[] = ['regular', 'medium', 'semibold', 'bold', 'heavy'];
9
+ const SCHEMES = new Set(['light', 'dark']);
10
+ const RESERVED_THEME_NAMES = new Set(['light', 'dark']);
11
+
12
+ function assertObject(value: unknown, path: string): asserts value is Record<string, unknown> {
13
+ if (!isPlainObject(value)) {
14
+ throw new ThemeConfigError(path, 'must be an object.');
15
+ }
16
+ }
17
+
18
+ function assertOpaqueColor(value: unknown, path: string): void {
19
+ if (!isOpaqueColor(value)) {
20
+ throw new ThemeConfigError(
21
+ path,
22
+ `must be an opaque colour (#RGB, #RRGGBB or rgb()), received ${JSON.stringify(value)}.`,
23
+ );
24
+ }
25
+ }
26
+
27
+ function assertNonEmptyString(value: unknown, path: string): void {
28
+ if (typeof value !== 'string' || value.trim().length === 0) {
29
+ throw new ThemeConfigError(path, 'must be a non-empty string.');
30
+ }
31
+ }
32
+
33
+ function assertPositiveNumber(value: unknown, path: string): void {
34
+ if (typeof value !== 'number' || !Number.isFinite(value) || value <= 0) {
35
+ throw new ThemeConfigError(path, 'must be a positive finite number.');
36
+ }
37
+ }
38
+
39
+ function validateColorRecord(value: unknown, path: string, keys?: readonly string[]): void {
40
+ if (value == null) {
41
+ return;
42
+ }
43
+
44
+ assertObject(value, path);
45
+
46
+ for (const [key, color] of Object.entries(value)) {
47
+ if (keys && !keys.includes(key)) {
48
+ throw new ThemeConfigError(`${path}.${key}`, `is not one of ${keys.join(', ')}.`);
49
+ }
50
+ if (color !== undefined) {
51
+ assertOpaqueColor(color, `${path}.${key}`);
52
+ }
53
+ }
54
+ }
55
+
56
+ function validateBrandColors(value: unknown, path: string): void {
57
+ assertObject(value, path);
58
+
59
+ for (const key of BRAND_KEYS) {
60
+ assertOpaqueColor(value[key], `${path}.${key}`);
61
+ }
62
+ }
63
+
64
+ function validateFontMap(value: unknown, path: string): void {
65
+ if (value == null) {
66
+ return;
67
+ }
68
+
69
+ assertObject(value, path);
70
+
71
+ for (const [weight, family] of Object.entries(value)) {
72
+ if (!FONT_WEIGHT_NAMES.includes(weight as FontWeightName)) {
73
+ throw new ThemeConfigError(
74
+ `${path}.${weight}`,
75
+ `is not one of ${FONT_WEIGHT_NAMES.join(', ')}.`,
76
+ );
77
+ }
78
+ assertNonEmptyString(family, `${path}.${weight}`);
79
+ }
80
+ }
81
+
82
+ function validateTypography(value: unknown): void {
83
+ if (value == null) {
84
+ return;
85
+ }
86
+
87
+ assertObject(value, 'typography');
88
+
89
+ if (value.fontFamily != null) {
90
+ assertNonEmptyString(value.fontFamily, 'typography.fontFamily');
91
+ }
92
+
93
+ for (const key of ['baseFontSize', 'fontScale', 'maxFontScale'] as const) {
94
+ if (value[key] != null) {
95
+ assertPositiveNumber(value[key], `typography.${key}`);
96
+ }
97
+ }
98
+
99
+ if (value.fonts != null) {
100
+ assertObject(value.fonts, 'typography.fonts');
101
+ if (value.fonts.family != null) {
102
+ assertNonEmptyString(value.fonts.family, 'typography.fonts.family');
103
+ }
104
+ validateFontMap(value.fonts.ios, 'typography.fonts.ios');
105
+ validateFontMap(value.fonts.android, 'typography.fonts.android');
106
+ }
107
+ }
108
+
109
+ function validateNamedThemes(value: unknown): void {
110
+ if (value == null) {
111
+ return;
112
+ }
113
+
114
+ assertObject(value, 'themes');
115
+
116
+ for (const [name, theme] of Object.entries(value)) {
117
+ const path = `themes.${name}`;
118
+
119
+ if (RESERVED_THEME_NAMES.has(name)) {
120
+ throw new ThemeConfigError(
121
+ path,
122
+ 'light and dark are built in; customise them with overrides.',
123
+ );
124
+ }
125
+
126
+ assertObject(theme, path);
127
+
128
+ if (!SCHEMES.has(theme.scheme as string)) {
129
+ throw new ThemeConfigError(`${path}.scheme`, "must be 'light' or 'dark'.");
130
+ }
131
+
132
+ if (theme.brandColors != null) {
133
+ validateBrandColors(theme.brandColors, `${path}.brandColors`);
134
+ }
135
+
136
+ validateColorRecord(theme.seeds, `${path}.seeds`, SEED_KEYS);
137
+ validateColorRecord(theme.tones, `${path}.tones`);
138
+ }
139
+ }
140
+
141
+ function validateOverrideThemes(value: unknown, themeNames: string[]): void {
142
+ if (value == null) {
143
+ return;
144
+ }
145
+
146
+ assertObject(value, 'overrides');
147
+
148
+ for (const name of Object.keys(value)) {
149
+ if (!themeNames.includes(name)) {
150
+ throw new ThemeConfigError(`overrides.${name}`, `is not a theme (${themeNames.join(', ')}).`);
151
+ }
152
+ }
153
+ }
154
+
155
+ /** Validates the config shape; override values are validated while merging. */
156
+ export function validateThemeConfig(config: unknown): void {
157
+ assertObject(config, 'config');
158
+ validateBrandColors(config.brandColors, 'brandColors');
159
+ validateColorRecord(config.seeds, 'seeds', SEED_KEYS);
160
+ validateColorRecord(config.tones, 'tones');
161
+ validateTypography(config.typography);
162
+ validateNamedThemes(config.themes);
163
+
164
+ if (config.extend != null && typeof config.extend !== 'function') {
165
+ throw new ThemeConfigError('extend', 'must be a function.');
166
+ }
167
+
168
+ if (config.strict != null && typeof config.strict !== 'boolean') {
169
+ throw new ThemeConfigError('strict', 'must be a boolean.');
170
+ }
171
+
172
+ validateOverrideThemes(config.overrides, themeNamesOf(config as { themes?: object }));
173
+ }
174
+
175
+ export function themeNamesOf(config: { themes?: object }): string[] {
176
+ return ['light', 'dark', ...Object.keys(config.themes ?? {})];
177
+ }
@@ -1,52 +1,109 @@
1
- import type { SpacingTokens, RadiusTokens, ShadowTokens, ZIndexTokens } from '../types/tokens';
2
-
3
- export const DEFAULT_SPACING: SpacingTokens = {
4
- xs: 4,
5
- sm: 8,
6
- md: 16,
7
- lg: 24,
8
- xl: 32,
9
- xxl: 48,
10
- };
11
-
12
- export const DEFAULT_RADIUS: RadiusTokens = {
13
- sm: 4,
14
- md: 8,
15
- lg: 16,
16
- xl: 24,
17
- full: 9999,
18
- };
1
+ import { StyleSheet } from 'react-native';
2
+ import { withAlpha } from '../color/parse';
3
+ import type {
4
+ BorderWidthTokens,
5
+ MotionTokens,
6
+ OpacityTokens,
7
+ RadiusTokens,
8
+ ShadowTokens,
9
+ SizeTokens,
10
+ SpacingTokens,
11
+ ZIndexTokens,
12
+ } from '../types/tokens';
19
13
 
20
- export function buildShadows(shadowColor: string): ShadowTokens {
14
+ // Every builder returns a fresh object so no two themes share a reference
15
+ // (spec AC-3.5, plan D-9).
16
+
17
+ export function buildSpacing(): SpacingTokens {
21
18
  return {
22
- sm: {
23
- shadowColor,
24
- shadowOffset: { width: 0, height: 1 },
25
- shadowOpacity: 0.12,
26
- shadowRadius: 2,
27
- elevation: 2,
28
- },
29
- md: {
30
- shadowColor,
31
- shadowOffset: { width: 0, height: 2 },
32
- shadowOpacity: 0.16,
33
- shadowRadius: 6,
34
- elevation: 4,
35
- },
36
- lg: {
37
- shadowColor,
38
- shadowOffset: { width: 0, height: 4 },
39
- shadowOpacity: 0.22,
40
- shadowRadius: 12,
41
- elevation: 8,
19
+ none: 0,
20
+ xxs: 2,
21
+ xs: 4,
22
+ sm: 8,
23
+ smPlus: 12,
24
+ md: 16,
25
+ mdPlus: 20,
26
+ lg: 24,
27
+ xl: 32,
28
+ xlPlus: 40,
29
+ xxl: 48,
30
+ xxxl: 64,
31
+ };
32
+ }
33
+
34
+ export function buildRadius(): RadiusTokens {
35
+ return { none: 0, xs: 2, sm: 4, md: 8, lg: 16, xl: 24, full: 9999 };
36
+ }
37
+
38
+ export function buildBorderWidth(): BorderWidthTokens {
39
+ return { hairline: StyleSheet.hairlineWidth, thin: 1, thick: 2 };
40
+ }
41
+
42
+ export function buildSize(): SizeTokens {
43
+ return { touchTarget: 44, icon: { sm: 16, md: 20, lg: 24 } };
44
+ }
45
+
46
+ 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
+ };
55
+ }
56
+
57
+ export function buildMotion(): MotionTokens {
58
+ return {
59
+ duration: { short: 100, medium: 250, long: 400 },
60
+ easing: {
61
+ standard: [0.2, 0, 0, 1],
62
+ emphasized: [0.05, 0.7, 0.1, 1],
63
+ decelerate: [0, 0, 0, 1],
64
+ accelerate: [0.3, 0, 1, 1],
42
65
  },
43
66
  };
44
67
  }
45
68
 
46
- export const DEFAULT_Z_INDEX: ZIndexTokens = {
47
- base: 0,
48
- raised: 10,
49
- modal: 100,
50
- overlay: 200,
51
- toast: 300,
52
- };
69
+ export function buildZIndex(): ZIndexTokens {
70
+ return { base: 0, raised: 10, modal: 100, overlay: 200, toast: 300 };
71
+ }
72
+
73
+ /** Material 3 elevation levels 1–3: a key shadow plus an ambient shadow. */
74
+ const ELEVATION_LAYERS = {
75
+ sm: [
76
+ [1, 2, 0, 0.3],
77
+ [1, 3, 1, 0.15],
78
+ ],
79
+ md: [
80
+ [1, 2, 0, 0.3],
81
+ [2, 6, 2, 0.15],
82
+ ],
83
+ lg: [
84
+ [1, 3, 0, 0.3],
85
+ [4, 8, 3, 0.15],
86
+ ],
87
+ } as const;
88
+
89
+ export function buildShadows(shadowColor: string): ShadowTokens {
90
+ const shadow = (layers: (typeof ELEVATION_LAYERS)[keyof typeof ELEVATION_LAYERS]) => ({
91
+ boxShadow: layers
92
+ .map(
93
+ ([offsetY, blur, spread, alpha]) =>
94
+ `0px ${offsetY}px ${blur}px ${spread}px ${withAlpha(shadowColor, alpha)}`,
95
+ )
96
+ .join(', '),
97
+ });
98
+
99
+ return {
100
+ sm: shadow(ELEVATION_LAYERS.sm),
101
+ md: shadow(ELEVATION_LAYERS.md),
102
+ lg: shadow(ELEVATION_LAYERS.lg),
103
+ };
104
+ }
105
+
106
+ export const DEFAULT_MAX_FONT_SCALE = 2;
107
+
108
+ /** Material 3 window size classes, in points. */
109
+ export const DEFAULT_BREAKPOINTS = { compact: 0, medium: 600, expanded: 840 };