@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.
- package/CHANGELOG.md +100 -23
- package/NOTICE +9 -0
- package/README.md +277 -187
- package/package.json +49 -50
- package/skills/sdk-rn-theme/SKILL.md +44 -48
- package/src/color/categorical.ts +33 -0
- package/src/color/contrast.ts +233 -0
- package/src/color/parse.ts +119 -0
- package/src/color/roles.ts +262 -0
- package/src/index.ts +23 -20
- package/src/integration/navigationTheme.ts +51 -0
- package/src/presets/byTone.ts +28 -0
- package/src/presets/layout.ts +38 -0
- package/src/presets/themePresets.ts +101 -0
- package/src/theme/buildThemes.ts +186 -0
- package/src/theme/errors.ts +34 -0
- package/src/theme/overrides.ts +147 -0
- package/src/theme/validateThemeConfig.ts +177 -0
- package/src/tokens/defaults.ts +103 -46
- package/src/tokens/typography.ts +147 -64
- package/src/types/brand.ts +16 -0
- package/src/types/colors.ts +68 -11
- package/src/types/index.ts +44 -5
- package/src/types/theme.ts +163 -0
- package/src/types/tokens.ts +47 -5
- package/src/types/typography.ts +44 -10
- package/src/unistyles/createUnistylesConfig.ts +169 -177
- package/src/unistyles/themeMode.ts +28 -0
- package/src/vendor/mcu/LICENSE +191 -0
- package/src/vendor/mcu/hct/cam16.ts +419 -0
- package/src/vendor/mcu/hct/hct.ts +216 -0
- package/src/vendor/mcu/hct/hct_solver.ts +557 -0
- package/src/vendor/mcu/hct/viewing_conditions.ts +121 -0
- package/src/vendor/mcu/index.ts +5 -0
- package/src/vendor/mcu/palettes/tonal_palette.ts +172 -0
- package/src/vendor/mcu/utils/color_utils.ts +302 -0
- package/src/vendor/mcu/utils/math_utils.ts +148 -0
- package/src/types/style.ts +0 -26
- package/src/unistyles/types.ts +0 -24
- package/src/unistyles/validateThemeConfig.ts +0 -154
- package/src/utils/colorGeneration.ts +0 -87
- package/src/utils/colorMath.ts +0 -55
|
@@ -0,0 +1,262 @@
|
|
|
1
|
+
import { Hct, TonalPalette } from '../vendor/mcu';
|
|
2
|
+
import type { BrandColors, SeedColors } from '../types/brand';
|
|
3
|
+
import type {
|
|
4
|
+
AccentRoleName,
|
|
5
|
+
ColorRoles,
|
|
6
|
+
ColorScheme,
|
|
7
|
+
StandardToneName,
|
|
8
|
+
ToneColors,
|
|
9
|
+
} from '../types/colors';
|
|
10
|
+
import { argbFromColor, hexFromArgb, withAlpha } from './parse';
|
|
11
|
+
|
|
12
|
+
/**
|
|
13
|
+
* 0.2.x light semantic colours, kept as default seeds so their hues carry over.
|
|
14
|
+
*/
|
|
15
|
+
export const DEFAULT_SEEDS: Required<Omit<SeedColors, 'neutral'>> = {
|
|
16
|
+
error: '#B3261E',
|
|
17
|
+
success: '#2E7D32',
|
|
18
|
+
warning: '#E65100',
|
|
19
|
+
info: '#0277BD',
|
|
20
|
+
};
|
|
21
|
+
|
|
22
|
+
/** Neutral chroma when no neutral seed is given (Material 3 uses 4–8). */
|
|
23
|
+
const NEUTRAL_CHROMA = 4;
|
|
24
|
+
const NEUTRAL_VARIANT_CHROMA = 8;
|
|
25
|
+
/** Upper bound for a supplied neutral seed, so surfaces never turn into a brand colour. */
|
|
26
|
+
const MAX_NEUTRAL_SEED_CHROMA = 16;
|
|
27
|
+
const NEUTRAL_VARIANT_EXTRA_CHROMA = 4;
|
|
28
|
+
|
|
29
|
+
/** Material 3 opacities applied to `onSurface` and the scrim. */
|
|
30
|
+
export const DISABLED_CONTENT_OPACITY = 0.38;
|
|
31
|
+
const SCRIM_OPACITY = 0.32;
|
|
32
|
+
|
|
33
|
+
/**
|
|
34
|
+
* Tones per role (plan D-3). Contrast depends only on tone, so these pairs
|
|
35
|
+
* hold for every hue and chroma.
|
|
36
|
+
*/
|
|
37
|
+
const TONES = {
|
|
38
|
+
light: {
|
|
39
|
+
role: 40,
|
|
40
|
+
onRole: 100,
|
|
41
|
+
container: 90,
|
|
42
|
+
onContainer: 10,
|
|
43
|
+
toneBorder: 50,
|
|
44
|
+
background: 98,
|
|
45
|
+
onBackground: 10,
|
|
46
|
+
surfaceVariant: 90,
|
|
47
|
+
onSurfaceVariant: 30,
|
|
48
|
+
surfaceContainerLowest: 100,
|
|
49
|
+
surfaceContainerLow: 96,
|
|
50
|
+
surfaceContainer: 94,
|
|
51
|
+
surfaceContainerHigh: 92,
|
|
52
|
+
surfaceContainerHighest: 90,
|
|
53
|
+
inverseSurface: 20,
|
|
54
|
+
inverseOnSurface: 95,
|
|
55
|
+
inversePrimary: 80,
|
|
56
|
+
outline: 50,
|
|
57
|
+
outlineVariant: 80,
|
|
58
|
+
},
|
|
59
|
+
dark: {
|
|
60
|
+
role: 80,
|
|
61
|
+
onRole: 20,
|
|
62
|
+
container: 30,
|
|
63
|
+
onContainer: 90,
|
|
64
|
+
toneBorder: 60,
|
|
65
|
+
background: 6,
|
|
66
|
+
onBackground: 90,
|
|
67
|
+
surfaceVariant: 30,
|
|
68
|
+
onSurfaceVariant: 80,
|
|
69
|
+
surfaceContainerLowest: 4,
|
|
70
|
+
surfaceContainerLow: 10,
|
|
71
|
+
surfaceContainer: 12,
|
|
72
|
+
surfaceContainerHigh: 17,
|
|
73
|
+
surfaceContainerHighest: 22,
|
|
74
|
+
inverseSurface: 90,
|
|
75
|
+
inverseOnSurface: 20,
|
|
76
|
+
inversePrimary: 40,
|
|
77
|
+
outline: 60,
|
|
78
|
+
outlineVariant: 30,
|
|
79
|
+
},
|
|
80
|
+
} as const;
|
|
81
|
+
|
|
82
|
+
const BLACK_TONE = 0;
|
|
83
|
+
const KEY_TONE = 50;
|
|
84
|
+
|
|
85
|
+
export interface PaletteInput {
|
|
86
|
+
brandColors: BrandColors;
|
|
87
|
+
seeds?: SeedColors;
|
|
88
|
+
tones?: Record<string, string>;
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
interface Palettes {
|
|
92
|
+
accent: Record<AccentRoleName, TonalPalette>;
|
|
93
|
+
neutral: TonalPalette;
|
|
94
|
+
neutralVariant: TonalPalette;
|
|
95
|
+
custom: Record<string, TonalPalette>;
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
/** Keeps the colour's hue and chroma (plan D-2). */
|
|
99
|
+
function paletteOf(color: string): TonalPalette {
|
|
100
|
+
return TonalPalette.fromInt(argbFromColor(color));
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
/**
|
|
104
|
+
* Same tones as `TonalPalette.fromHueAndChroma`, without its key-colour search:
|
|
105
|
+
* a palette's tones depend only on hue and chroma, and these neutral chromas
|
|
106
|
+
* fit the gamut at tone 50, so no search is needed (spec FR-4).
|
|
107
|
+
*/
|
|
108
|
+
function paletteFromHueAndChroma(hue: number, chroma: number): TonalPalette {
|
|
109
|
+
return TonalPalette.fromHct(Hct.from(hue, chroma, KEY_TONE));
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
function neutralPalettes(input: PaletteInput): Pick<Palettes, 'neutral' | 'neutralVariant'> {
|
|
113
|
+
if (input.seeds?.neutral != null) {
|
|
114
|
+
const seed = Hct.fromInt(argbFromColor(input.seeds.neutral));
|
|
115
|
+
const chroma = Math.min(seed.chroma, MAX_NEUTRAL_SEED_CHROMA);
|
|
116
|
+
|
|
117
|
+
return {
|
|
118
|
+
neutral: paletteFromHueAndChroma(seed.hue, chroma),
|
|
119
|
+
neutralVariant: paletteFromHueAndChroma(seed.hue, chroma + NEUTRAL_VARIANT_EXTRA_CHROMA),
|
|
120
|
+
};
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
const hue = Hct.fromInt(argbFromColor(input.brandColors.primary)).hue;
|
|
124
|
+
|
|
125
|
+
return {
|
|
126
|
+
neutral: paletteFromHueAndChroma(hue, NEUTRAL_CHROMA),
|
|
127
|
+
neutralVariant: paletteFromHueAndChroma(hue, NEUTRAL_VARIANT_CHROMA),
|
|
128
|
+
};
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
export function buildPalettes(input: PaletteInput): Palettes {
|
|
132
|
+
const seeds = { ...DEFAULT_SEEDS, ...input.seeds };
|
|
133
|
+
const custom: Record<string, TonalPalette> = {};
|
|
134
|
+
|
|
135
|
+
for (const [name, color] of Object.entries(input.tones ?? {})) {
|
|
136
|
+
custom[name] = paletteOf(color);
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
return {
|
|
140
|
+
accent: {
|
|
141
|
+
primary: paletteOf(input.brandColors.primary),
|
|
142
|
+
secondary: paletteOf(input.brandColors.secondary),
|
|
143
|
+
tertiary: paletteOf(input.brandColors.tertiary),
|
|
144
|
+
error: paletteOf(seeds.error),
|
|
145
|
+
success: paletteOf(seeds.success),
|
|
146
|
+
warning: paletteOf(seeds.warning),
|
|
147
|
+
info: paletteOf(seeds.info),
|
|
148
|
+
},
|
|
149
|
+
...neutralPalettes(input),
|
|
150
|
+
custom,
|
|
151
|
+
};
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
function hex(palette: TonalPalette, tone: number): string {
|
|
155
|
+
return hexFromArgb(palette.tone(tone));
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
export function buildColorRoles(palettes: Palettes, scheme: ColorScheme): ColorRoles {
|
|
159
|
+
const t = TONES[scheme];
|
|
160
|
+
const { neutral, neutralVariant } = palettes;
|
|
161
|
+
const accent = (palette: TonalPalette) => ({
|
|
162
|
+
role: hex(palette, t.role),
|
|
163
|
+
onRole: hex(palette, t.onRole),
|
|
164
|
+
container: hex(palette, t.container),
|
|
165
|
+
onContainer: hex(palette, t.onContainer),
|
|
166
|
+
});
|
|
167
|
+
const [primary, secondary, tertiary, error, success, warning, info] = (
|
|
168
|
+
['primary', 'secondary', 'tertiary', 'error', 'success', 'warning', 'info'] as const
|
|
169
|
+
).map(name => accent(palettes.accent[name]));
|
|
170
|
+
const onSurface = hex(neutral, t.onBackground);
|
|
171
|
+
|
|
172
|
+
return {
|
|
173
|
+
primary: primary.role,
|
|
174
|
+
onPrimary: primary.onRole,
|
|
175
|
+
primaryContainer: primary.container,
|
|
176
|
+
onPrimaryContainer: primary.onContainer,
|
|
177
|
+
secondary: secondary.role,
|
|
178
|
+
onSecondary: secondary.onRole,
|
|
179
|
+
secondaryContainer: secondary.container,
|
|
180
|
+
onSecondaryContainer: secondary.onContainer,
|
|
181
|
+
tertiary: tertiary.role,
|
|
182
|
+
onTertiary: tertiary.onRole,
|
|
183
|
+
tertiaryContainer: tertiary.container,
|
|
184
|
+
onTertiaryContainer: tertiary.onContainer,
|
|
185
|
+
error: error.role,
|
|
186
|
+
onError: error.onRole,
|
|
187
|
+
errorContainer: error.container,
|
|
188
|
+
onErrorContainer: error.onContainer,
|
|
189
|
+
success: success.role,
|
|
190
|
+
onSuccess: success.onRole,
|
|
191
|
+
successContainer: success.container,
|
|
192
|
+
onSuccessContainer: success.onContainer,
|
|
193
|
+
warning: warning.role,
|
|
194
|
+
onWarning: warning.onRole,
|
|
195
|
+
warningContainer: warning.container,
|
|
196
|
+
onWarningContainer: warning.onContainer,
|
|
197
|
+
info: info.role,
|
|
198
|
+
onInfo: info.onRole,
|
|
199
|
+
infoContainer: info.container,
|
|
200
|
+
onInfoContainer: info.onContainer,
|
|
201
|
+
background: hex(neutral, t.background),
|
|
202
|
+
onBackground: onSurface,
|
|
203
|
+
surface: hex(neutral, t.background),
|
|
204
|
+
onSurface,
|
|
205
|
+
surfaceVariant: hex(neutralVariant, t.surfaceVariant),
|
|
206
|
+
onSurfaceVariant: hex(neutralVariant, t.onSurfaceVariant),
|
|
207
|
+
surfaceContainerLowest: hex(neutral, t.surfaceContainerLowest),
|
|
208
|
+
surfaceContainerLow: hex(neutral, t.surfaceContainerLow),
|
|
209
|
+
surfaceContainer: hex(neutral, t.surfaceContainer),
|
|
210
|
+
surfaceContainerHigh: hex(neutral, t.surfaceContainerHigh),
|
|
211
|
+
surfaceContainerHighest: hex(neutral, t.surfaceContainerHighest),
|
|
212
|
+
inverseSurface: hex(neutral, t.inverseSurface),
|
|
213
|
+
inverseOnSurface: hex(neutral, t.inverseOnSurface),
|
|
214
|
+
inversePrimary: hex(palettes.accent.primary, t.inversePrimary),
|
|
215
|
+
outline: hex(neutralVariant, t.outline),
|
|
216
|
+
outlineVariant: hex(neutralVariant, t.outlineVariant),
|
|
217
|
+
scrim: withAlpha(hex(neutral, BLACK_TONE), SCRIM_OPACITY),
|
|
218
|
+
shadow: hex(neutral, BLACK_TONE),
|
|
219
|
+
disabled: withAlpha(onSurface, DISABLED_CONTENT_OPACITY),
|
|
220
|
+
};
|
|
221
|
+
}
|
|
222
|
+
|
|
223
|
+
function accentTone(palette: TonalPalette, scheme: ColorScheme): ToneColors {
|
|
224
|
+
const t = TONES[scheme];
|
|
225
|
+
|
|
226
|
+
return {
|
|
227
|
+
fg: hex(palette, t.role),
|
|
228
|
+
container: hex(palette, t.container),
|
|
229
|
+
onContainer: hex(palette, t.onContainer),
|
|
230
|
+
border: hex(palette, t.toneBorder),
|
|
231
|
+
fill: hex(palette, t.role),
|
|
232
|
+
onFill: hex(palette, t.onRole),
|
|
233
|
+
};
|
|
234
|
+
}
|
|
235
|
+
|
|
236
|
+
export function buildTones<TTone extends string>(
|
|
237
|
+
palettes: Palettes,
|
|
238
|
+
color: ColorRoles,
|
|
239
|
+
scheme: ColorScheme,
|
|
240
|
+
): Record<StandardToneName | TTone, ToneColors> {
|
|
241
|
+
const tones: Record<string, ToneColors> = {
|
|
242
|
+
neutral: {
|
|
243
|
+
fg: color.onSurface,
|
|
244
|
+
container: color.surfaceContainerHigh,
|
|
245
|
+
onContainer: color.onSurface,
|
|
246
|
+
border: color.outline,
|
|
247
|
+
fill: color.inverseSurface,
|
|
248
|
+
onFill: color.inverseOnSurface,
|
|
249
|
+
},
|
|
250
|
+
primary: accentTone(palettes.accent.primary, scheme),
|
|
251
|
+
info: accentTone(palettes.accent.info, scheme),
|
|
252
|
+
success: accentTone(palettes.accent.success, scheme),
|
|
253
|
+
warning: accentTone(palettes.accent.warning, scheme),
|
|
254
|
+
error: accentTone(palettes.accent.error, scheme),
|
|
255
|
+
};
|
|
256
|
+
|
|
257
|
+
for (const [name, palette] of Object.entries(palettes.custom)) {
|
|
258
|
+
tones[name] = accentTone(palette, scheme);
|
|
259
|
+
}
|
|
260
|
+
|
|
261
|
+
return tones as Record<StandardToneName | TTone, ToneColors>;
|
|
262
|
+
}
|
package/src/index.ts
CHANGED
|
@@ -1,24 +1,27 @@
|
|
|
1
|
-
// ───
|
|
2
|
-
export {
|
|
1
|
+
// ─── Theme building ─────────────────────────────────────────────────────────
|
|
2
|
+
export { buildThemes } from './theme/buildThemes';
|
|
3
|
+
export type { BuildThemesOptions } from './theme/buildThemes';
|
|
4
|
+
export { ThemeConfigError, ThemeContrastError } from './theme/errors';
|
|
3
5
|
|
|
4
|
-
// ─── Unistyles
|
|
6
|
+
// ─── Unistyles ──────────────────────────────────────────────────────────────
|
|
5
7
|
export { createUnistylesConfig } from './unistyles/createUnistylesConfig';
|
|
6
|
-
export type {
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
8
|
+
export type { UnistylesConfig, UnistylesConfigOptions } from './unistyles/createUnistylesConfig';
|
|
9
|
+
export { applyThemeMode } from './unistyles/themeMode';
|
|
10
|
+
export type { ThemeMode, ThemeModeRuntime } from './unistyles/themeMode';
|
|
11
|
+
export { DEFAULT_BREAKPOINTS } from './tokens/defaults';
|
|
12
|
+
|
|
13
|
+
// ─── Colour utilities ───────────────────────────────────────────────────────
|
|
14
|
+
export { auditTheme, contrastRatio } from './color/contrast';
|
|
15
|
+
export type { ContrastFailure } from './color/contrast';
|
|
16
|
+
export { withAlpha } from './color/parse';
|
|
17
|
+
|
|
18
|
+
// ─── Style presets ──────────────────────────────────────────────────────────
|
|
19
|
+
export { layout, presets } from './presets/layout';
|
|
20
|
+
export { byTone } from './presets/byTone';
|
|
21
|
+
|
|
22
|
+
// ─── Integrations ───────────────────────────────────────────────────────────
|
|
23
|
+
export { toNavigationTheme } from './integration/navigationTheme';
|
|
24
|
+
export type { NavigationTheme } from './integration/navigationTheme';
|
|
12
25
|
|
|
13
26
|
// ─── Types ──────────────────────────────────────────────────────────────────
|
|
14
|
-
export type
|
|
15
|
-
export type { ThemeColors } from './types/colors';
|
|
16
|
-
export type { TypographyConfig, TypographyToken, Typography } from './types/typography';
|
|
17
|
-
export type {
|
|
18
|
-
SpacingTokens,
|
|
19
|
-
RadiusTokens,
|
|
20
|
-
ShadowToken,
|
|
21
|
-
ShadowTokens,
|
|
22
|
-
ZIndexTokens,
|
|
23
|
-
} from './types/tokens';
|
|
24
|
-
export type { DeepPartial, StyleConfig, StyleTheme } from './types/style';
|
|
27
|
+
export type * from './types';
|
|
@@ -0,0 +1,51 @@
|
|
|
1
|
+
import type { ColorRoles, ColorScheme } from '../types/colors';
|
|
2
|
+
import type { FontToken, FontWeightName } from '../types/typography';
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* The theme shape React Navigation expects, declared here so the package needs
|
|
6
|
+
* no navigation dependency (spec AC-9.3).
|
|
7
|
+
*/
|
|
8
|
+
export interface NavigationTheme {
|
|
9
|
+
dark: boolean;
|
|
10
|
+
colors: {
|
|
11
|
+
primary: string;
|
|
12
|
+
background: string;
|
|
13
|
+
card: string;
|
|
14
|
+
text: string;
|
|
15
|
+
border: string;
|
|
16
|
+
notification: string;
|
|
17
|
+
};
|
|
18
|
+
fonts: {
|
|
19
|
+
regular: FontToken;
|
|
20
|
+
medium: FontToken;
|
|
21
|
+
bold: FontToken;
|
|
22
|
+
heavy: FontToken;
|
|
23
|
+
};
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
interface NavigationSource {
|
|
27
|
+
color: ColorRoles;
|
|
28
|
+
font: Record<FontWeightName, FontToken>;
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
export function toNavigationTheme(theme: NavigationSource, scheme: ColorScheme): NavigationTheme {
|
|
32
|
+
const { color, font } = theme;
|
|
33
|
+
|
|
34
|
+
return {
|
|
35
|
+
dark: scheme === 'dark',
|
|
36
|
+
colors: {
|
|
37
|
+
primary: color.primary,
|
|
38
|
+
background: color.background,
|
|
39
|
+
card: color.surfaceContainer,
|
|
40
|
+
text: color.onSurface,
|
|
41
|
+
border: color.outlineVariant,
|
|
42
|
+
notification: color.error,
|
|
43
|
+
},
|
|
44
|
+
fonts: {
|
|
45
|
+
regular: { ...font.regular },
|
|
46
|
+
medium: { ...font.medium },
|
|
47
|
+
bold: { ...font.bold },
|
|
48
|
+
heavy: { ...font.heavy },
|
|
49
|
+
},
|
|
50
|
+
};
|
|
51
|
+
}
|
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
import type { ToneColors } from '../types/colors';
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Builds a variant map with one entry per tone of the theme.
|
|
5
|
+
*
|
|
6
|
+
* Call it inline under a literal `variants` key, with the stylesheet's own
|
|
7
|
+
* `theme` argument:
|
|
8
|
+
*
|
|
9
|
+
* ```ts
|
|
10
|
+
* toneText: { variants: { tone: byTone(theme, t => ({ color: t.onContainer })) } },
|
|
11
|
+
* ```
|
|
12
|
+
*
|
|
13
|
+
* The Unistyles Babel plugin reads dependencies statically: a variants object
|
|
14
|
+
* built elsewhere, or a `theme` read through an alias, hides them and the
|
|
15
|
+
* style stops updating on theme or variant changes.
|
|
16
|
+
*/
|
|
17
|
+
export function byTone<TTheme extends { tone: Record<string, ToneColors> }, TStyle>(
|
|
18
|
+
theme: TTheme,
|
|
19
|
+
build: (tone: ToneColors, name: keyof TTheme['tone'] & string) => TStyle,
|
|
20
|
+
): Record<keyof TTheme['tone'] & string, TStyle> {
|
|
21
|
+
const result = {} as Record<keyof TTheme['tone'] & string, TStyle>;
|
|
22
|
+
|
|
23
|
+
for (const name of Object.keys(theme.tone) as Array<keyof TTheme['tone'] & string>) {
|
|
24
|
+
result[name] = build(theme.tone[name], name);
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
return result;
|
|
28
|
+
}
|
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
import type { TextStyle, ViewStyle } from 'react-native';
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Theme-independent flex presets. Spread them inside `StyleSheet.create` or
|
|
5
|
+
* put them in a style array; styles built only from these carry no theme
|
|
6
|
+
* dependency, so Unistyles never recalculates them on a theme change.
|
|
7
|
+
*/
|
|
8
|
+
export const layout = {
|
|
9
|
+
row: { flexDirection: 'row' },
|
|
10
|
+
column: { flexDirection: 'column' },
|
|
11
|
+
rowCenter: { flexDirection: 'row', alignItems: 'center' },
|
|
12
|
+
rowBetween: { flexDirection: 'row', alignItems: 'center', justifyContent: 'space-between' },
|
|
13
|
+
center: { alignItems: 'center', justifyContent: 'center' },
|
|
14
|
+
fill: { flex: 1 },
|
|
15
|
+
wrap: { flexDirection: 'row', flexWrap: 'wrap' },
|
|
16
|
+
absoluteFill: { position: 'absolute', top: 0, right: 0, bottom: 0, left: 0 },
|
|
17
|
+
selfStart: { alignSelf: 'flex-start' },
|
|
18
|
+
selfCenter: { alignSelf: 'center' },
|
|
19
|
+
selfEnd: { alignSelf: 'flex-end' },
|
|
20
|
+
} as const satisfies Record<string, ViewStyle>;
|
|
21
|
+
|
|
22
|
+
interface TextPresetsStatic {
|
|
23
|
+
/**
|
|
24
|
+
* For a `Text` used as an icon: Android sizes text boxes from asymmetric
|
|
25
|
+
* font metrics, so a centred glyph sits high or low beside a label unless
|
|
26
|
+
* font padding is removed and the glyph centred in its box. iOS ignores both.
|
|
27
|
+
*/
|
|
28
|
+
glyphAlign: Pick<TextStyle, 'includeFontPadding' | 'textAlignVertical'>;
|
|
29
|
+
/** Equal-width digits, so columns of times or amounts do not jitter. */
|
|
30
|
+
tabularNums: Pick<TextStyle, 'fontVariant'>;
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
// Annotated rather than `as const`: a readonly `fontVariant` tuple fails
|
|
34
|
+
// React Native's `TextStyle` and degrades inference of sibling styles (spec AC-3.7).
|
|
35
|
+
export const presets: TextPresetsStatic = {
|
|
36
|
+
glyphAlign: { includeFontPadding: false, textAlignVertical: 'center' },
|
|
37
|
+
tabularNums: { fontVariant: ['tabular-nums'] },
|
|
38
|
+
};
|
|
@@ -0,0 +1,101 @@
|
|
|
1
|
+
import type { ColorRoles } from '../types/colors';
|
|
2
|
+
import type {
|
|
3
|
+
BaseTheme,
|
|
4
|
+
BorderPresets,
|
|
5
|
+
BoxPresets,
|
|
6
|
+
InlinePreset,
|
|
7
|
+
StackPreset,
|
|
8
|
+
TextPreset,
|
|
9
|
+
TextPresets,
|
|
10
|
+
} from '../types/theme';
|
|
11
|
+
import type { SpacingKey } from '../types/tokens';
|
|
12
|
+
import type { TypographyToken } from '../types/typography';
|
|
13
|
+
|
|
14
|
+
type PresetKey = 'text' | 'stack' | 'inline' | 'border' | 'box';
|
|
15
|
+
type TokenSource = Omit<BaseTheme, PresetKey | 'tone'>;
|
|
16
|
+
|
|
17
|
+
export const PRESET_KEYS: readonly PresetKey[] = ['text', 'stack', 'inline', 'border', 'box'];
|
|
18
|
+
|
|
19
|
+
function textPreset(token: TypographyToken, color: string): TextPreset {
|
|
20
|
+
return { ...token, color };
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
function buildText({ typography }: TokenSource, color: ColorRoles): TextPresets {
|
|
24
|
+
return {
|
|
25
|
+
body: textPreset(typography.body, color.onSurface),
|
|
26
|
+
bodyMuted: textPreset(typography.body, color.onSurfaceVariant),
|
|
27
|
+
label: textPreset(typography.label, color.onSurface),
|
|
28
|
+
labelMuted: textPreset(typography.label, color.onSurfaceVariant),
|
|
29
|
+
caption: textPreset(typography.caption, color.onSurfaceVariant),
|
|
30
|
+
title: textPreset(typography.title, color.onSurface),
|
|
31
|
+
headline: textPreset(typography.headline, color.onSurface),
|
|
32
|
+
disabled: textPreset(typography.body, color.disabled),
|
|
33
|
+
};
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
function bySpacing<T>(spacing: TokenSource['spacing'], build: (gap: number) => T) {
|
|
37
|
+
const result = {} as Record<SpacingKey, T>;
|
|
38
|
+
|
|
39
|
+
for (const key of Object.keys(spacing) as SpacingKey[]) {
|
|
40
|
+
result[key] = build(spacing[key]);
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
return result;
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
function buildBorder({ borderWidth, color }: TokenSource): BorderPresets {
|
|
47
|
+
return {
|
|
48
|
+
hairline: { borderWidth: borderWidth.hairline, borderColor: color.outlineVariant },
|
|
49
|
+
top: { borderTopWidth: borderWidth.thin, borderTopColor: color.outlineVariant },
|
|
50
|
+
bottom: { borderBottomWidth: borderWidth.thin, borderBottomColor: color.outlineVariant },
|
|
51
|
+
all: { borderWidth: borderWidth.thin, borderColor: color.outline },
|
|
52
|
+
};
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
function buildBox({ borderWidth, color, radius, spacing, size }: TokenSource): BoxPresets {
|
|
56
|
+
return {
|
|
57
|
+
outlined: {
|
|
58
|
+
borderWidth: borderWidth.thin,
|
|
59
|
+
borderColor: color.outline,
|
|
60
|
+
borderRadius: radius.md,
|
|
61
|
+
padding: spacing.sm,
|
|
62
|
+
gap: spacing.xs,
|
|
63
|
+
},
|
|
64
|
+
card: {
|
|
65
|
+
backgroundColor: color.surfaceContainerLow,
|
|
66
|
+
borderRadius: radius.lg,
|
|
67
|
+
padding: spacing.md,
|
|
68
|
+
gap: spacing.sm,
|
|
69
|
+
},
|
|
70
|
+
sheet: {
|
|
71
|
+
backgroundColor: color.surfaceContainerLow,
|
|
72
|
+
borderTopLeftRadius: radius.xl,
|
|
73
|
+
borderTopRightRadius: radius.xl,
|
|
74
|
+
padding: spacing.md,
|
|
75
|
+
gap: spacing.md,
|
|
76
|
+
},
|
|
77
|
+
touchTarget: {
|
|
78
|
+
minWidth: size.touchTarget,
|
|
79
|
+
minHeight: size.touchTarget,
|
|
80
|
+
justifyContent: 'center',
|
|
81
|
+
},
|
|
82
|
+
};
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
/**
|
|
86
|
+
* Token-reading presets, built from the theme's final (overridden) tokens so
|
|
87
|
+
* they follow every override — e.g. `radius.md = 0` reaches `box.outlined`.
|
|
88
|
+
*/
|
|
89
|
+
export function buildPresets(tokens: TokenSource): Pick<BaseTheme, PresetKey> {
|
|
90
|
+
return {
|
|
91
|
+
text: buildText(tokens, tokens.color),
|
|
92
|
+
stack: bySpacing<StackPreset>(tokens.spacing, gap => ({ flexDirection: 'column', gap })),
|
|
93
|
+
inline: bySpacing<InlinePreset>(tokens.spacing, gap => ({
|
|
94
|
+
flexDirection: 'row',
|
|
95
|
+
alignItems: 'center',
|
|
96
|
+
gap,
|
|
97
|
+
})),
|
|
98
|
+
border: buildBorder(tokens),
|
|
99
|
+
box: buildBox(tokens),
|
|
100
|
+
};
|
|
101
|
+
}
|