@mr.dj2u/library-registry 0.0.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.
Files changed (131) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +42 -0
  3. package/THIRD_PARTY_NOTICES.md +90 -0
  4. package/assets/create-expo-app/sdk-56/assets/images/expo-badge-white.png +0 -0
  5. package/assets/create-expo-app/sdk-56/assets/images/expo-badge.png +0 -0
  6. package/assets/create-expo-app/sdk-56/assets/images/expo-logo.png +0 -0
  7. package/assets/create-expo-app/sdk-56/assets/images/logo-glow.png +0 -0
  8. package/assets/create-expo-app/sdk-56/assets/images/react-logo.png +0 -0
  9. package/assets/create-expo-app/sdk-56/assets/images/react-logo@2x.png +0 -0
  10. package/assets/create-expo-app/sdk-56/assets/images/react-logo@3x.png +0 -0
  11. package/assets/create-expo-app/sdk-56/assets/images/tabIcons/explore.png +0 -0
  12. package/assets/create-expo-app/sdk-56/assets/images/tabIcons/explore@2x.png +0 -0
  13. package/assets/create-expo-app/sdk-56/assets/images/tabIcons/explore@3x.png +0 -0
  14. package/assets/create-expo-app/sdk-56/assets/images/tabIcons/home.png +0 -0
  15. package/assets/create-expo-app/sdk-56/assets/images/tabIcons/home@2x.png +0 -0
  16. package/assets/create-expo-app/sdk-56/assets/images/tabIcons/home@3x.png +0 -0
  17. package/assets/create-expo-app/sdk-56/assets/images/tutorial-web.png +0 -0
  18. package/assets/create-expo-app/sdk-56/src/app/_layout.tsx +15 -0
  19. package/assets/create-expo-app/sdk-56/src/app/explore.tsx +180 -0
  20. package/assets/create-expo-app/sdk-56/src/app/index.tsx +98 -0
  21. package/assets/create-expo-app/sdk-56/src/components/animated-icon.module.css +6 -0
  22. package/assets/create-expo-app/sdk-56/src/components/animated-icon.tsx +132 -0
  23. package/assets/create-expo-app/sdk-56/src/components/animated-icon.web.tsx +108 -0
  24. package/assets/create-expo-app/sdk-56/src/components/app-tabs.tsx +32 -0
  25. package/assets/create-expo-app/sdk-56/src/components/app-tabs.web.tsx +115 -0
  26. package/assets/create-expo-app/sdk-56/src/components/external-link.tsx +25 -0
  27. package/assets/create-expo-app/sdk-56/src/components/hint-row.tsx +35 -0
  28. package/assets/create-expo-app/sdk-56/src/components/themed-text.tsx +73 -0
  29. package/assets/create-expo-app/sdk-56/src/components/themed-view.tsx +16 -0
  30. package/assets/create-expo-app/sdk-56/src/components/ui/collapsible.tsx +65 -0
  31. package/assets/create-expo-app/sdk-56/src/components/web-badge.tsx +43 -0
  32. package/assets/create-expo-app/sdk-56/src/constants/theme.ts +65 -0
  33. package/assets/create-expo-app/sdk-56/src/global.css +9 -0
  34. package/assets/create-expo-app/sdk-56/src/hooks/use-color-scheme.ts +1 -0
  35. package/assets/create-expo-app/sdk-56/src/hooks/use-color-scheme.web.ts +21 -0
  36. package/assets/create-expo-app/sdk-56/src/hooks/use-theme.ts +14 -0
  37. package/assets/create-expo-stack/2.21.3/base/components/BackButton.tsx +23 -0
  38. package/assets/create-expo-stack/2.21.3/base/components/Button.tsx +42 -0
  39. package/assets/create-expo-stack/2.21.3/base/components/Container.tsx +14 -0
  40. package/assets/create-expo-stack/2.21.3/base/components/EditScreenInfo.tsx +36 -0
  41. package/assets/create-expo-stack/2.21.3/base/components/HeaderButton.tsx +31 -0
  42. package/assets/create-expo-stack/2.21.3/base/components/ScreenContent.tsx +40 -0
  43. package/assets/create-expo-stack/2.21.3/base/components/TabBarIcon.tsx +15 -0
  44. package/assets/create-expo-stack/2.21.3-mrdj.2/src/components/nativewindui/ActivityIndicator.tsx +10 -0
  45. package/assets/create-expo-stack/2.21.3-mrdj.2/src/components/nativewindui/Avatar.tsx +32 -0
  46. package/assets/create-expo-stack/2.21.3-mrdj.2/src/components/nativewindui/Button.tsx +141 -0
  47. package/assets/create-expo-stack/2.21.3-mrdj.2/src/components/nativewindui/DatePicker/DatePicker.android.tsx +77 -0
  48. package/assets/create-expo-stack/2.21.3-mrdj.2/src/components/nativewindui/DatePicker/DatePicker.tsx +23 -0
  49. package/assets/create-expo-stack/2.21.3-mrdj.2/src/components/nativewindui/DatePicker/index.ts +1 -0
  50. package/assets/create-expo-stack/2.21.3-mrdj.2/src/components/nativewindui/Icon/Icon.ios.tsx +55 -0
  51. package/assets/create-expo-stack/2.21.3-mrdj.2/src/components/nativewindui/Icon/Icon.tsx +58 -0
  52. package/assets/create-expo-stack/2.21.3-mrdj.2/src/components/nativewindui/Icon/index.ts +16 -0
  53. package/assets/create-expo-stack/2.21.3-mrdj.2/src/components/nativewindui/Icon/types.ts +19 -0
  54. package/assets/create-expo-stack/2.21.3-mrdj.2/src/components/nativewindui/Picker.tsx +42 -0
  55. package/assets/create-expo-stack/2.21.3-mrdj.2/src/components/nativewindui/ProgressIndicator.tsx +68 -0
  56. package/assets/create-expo-stack/2.21.3-mrdj.2/src/components/nativewindui/Slider.tsx +28 -0
  57. package/assets/create-expo-stack/2.21.3-mrdj.2/src/components/nativewindui/Text.tsx +55 -0
  58. package/assets/create-expo-stack/2.21.3-mrdj.2/src/components/nativewindui/ThemeToggle.tsx +33 -0
  59. package/assets/create-expo-stack/2.21.3-mrdj.2/src/components/nativewindui/Toggle.tsx +20 -0
  60. package/assets/create-expo-stack/2.21.3-mrdj.2/src/lib/cn.ts +6 -0
  61. package/assets/create-expo-stack/2.21.3-mrdj.2/src/lib/useColorScheme.tsx +21 -0
  62. package/assets/create-expo-stack/2.21.3-mrdj.2/src/lib/useHeaderSearchBar.tsx +33 -0
  63. package/assets/create-expo-stack/2.21.3-mrdj.2/src/theme/colors.ts +123 -0
  64. package/assets/create-expo-stack/2.21.3-mrdj.2/src/theme/index.ts +32 -0
  65. package/assets/create-expo-stack/2.21.3-mrdj.2/src/theme/with-opacity.ts +155 -0
  66. package/assets/mds/routes/drawer-tabs/data.tsx +1 -0
  67. package/assets/mds/routes/drawer-tabs/index.tsx +1 -0
  68. package/assets/mds/routes/drawer-tabs/nativewindui.tsx +1 -0
  69. package/assets/mds/routes/drawer-tabs/sdk-56.tsx +1 -0
  70. package/assets/mds/routes/drawer-tabs/stylist.tsx +1 -0
  71. package/assets/mds/scripts/stylist-sync-android.mjs +39 -0
  72. package/assets/mds/src/app/exposition/data.tsx +1 -0
  73. package/assets/mds/src/app/exposition/index.tsx +1 -0
  74. package/assets/mds/src/app/exposition/nativewindui.tsx +1 -0
  75. package/assets/mds/src/app/exposition/sdk-56.tsx +1 -0
  76. package/assets/mds/src/app/exposition/stylist-sync+api.ts +241 -0
  77. package/assets/mds/src/app/exposition/stylist.tsx +1 -0
  78. package/assets/mds/src/app/onboarding/account-setup.tsx +1 -0
  79. package/assets/mds/src/app/onboarding/agreement.tsx +1 -0
  80. package/assets/mds/src/app/onboarding/terms.tsx +1 -0
  81. package/assets/mds/src/app/onboarding.tsx +1 -0
  82. package/assets/mds/src/app/settings.tsx +1 -0
  83. package/assets/mds/src/components/exposition/index.ts +8 -0
  84. package/assets/mds/src/components/exposition/notice.tsx +3 -0
  85. package/assets/mds/src/components/exposition/package-card.tsx +76 -0
  86. package/assets/mds/src/components/swmansion/animated-pressable.tsx +54 -0
  87. package/assets/mds/src/components/swmansion/gesture-card.tsx +54 -0
  88. package/assets/mds/src/components/swmansion/keyboard-form.tsx +73 -0
  89. package/assets/mds/src/components/swmansion/screens-card.tsx +40 -0
  90. package/assets/mds/src/components/swmansion/software-mansion-logo.tsx +15 -0
  91. package/assets/mds/src/components/swmansion/svg-mark.tsx +16 -0
  92. package/assets/mds/src/data/mock-app.ts +21 -0
  93. package/assets/mds/src/features/exposition/data-screen.tsx +152 -0
  94. package/assets/mds/src/features/exposition/embedded-fonts.ts +72 -0
  95. package/assets/mds/src/features/exposition/expo-sdk-56-screen.tsx +709 -0
  96. package/assets/mds/src/features/exposition/exposition-screen.tsx +191 -0
  97. package/assets/mds/src/features/exposition/nativewindui-screen.tsx +125 -0
  98. package/assets/mds/src/features/exposition/stylist-screen.tsx +3456 -0
  99. package/assets/mds/src/features/onboarding/account-setup-screen.tsx +54 -0
  100. package/assets/mds/src/features/onboarding/agreement-screen.tsx +6 -0
  101. package/assets/mds/src/features/onboarding/components/legal-document-view.tsx +103 -0
  102. package/assets/mds/src/features/onboarding/legal-documents.ts +80 -0
  103. package/assets/mds/src/features/onboarding/onboarding-screen.tsx +167 -0
  104. package/assets/mds/src/features/onboarding/terms-screen.tsx +6 -0
  105. package/assets/mds/src/features/settings/settings-screen.tsx +57 -0
  106. package/assets/mds/src/services/local-data.native.ts +103 -0
  107. package/assets/mds/src/services/local-data.ts +38 -0
  108. package/assets/mds/src/theme/font-assets.ts +3 -0
  109. package/assets/mds/src/theme/provider.tsx +59 -0
  110. package/assets/mds/src/theme/tokens.ts +180 -0
  111. package/dist/catalog.d.ts +3 -0
  112. package/dist/catalog.d.ts.map +1 -0
  113. package/dist/catalog.js +1110 -0
  114. package/dist/catalog.js.map +1 -0
  115. package/dist/index.d.ts +5 -0
  116. package/dist/index.d.ts.map +1 -0
  117. package/dist/index.js +4 -0
  118. package/dist/index.js.map +1 -0
  119. package/dist/registry.d.ts +7 -0
  120. package/dist/registry.d.ts.map +1 -0
  121. package/dist/registry.js +479 -0
  122. package/dist/registry.js.map +1 -0
  123. package/dist/types.d.ts +169 -0
  124. package/dist/types.d.ts.map +1 -0
  125. package/dist/types.js +2 -0
  126. package/dist/types.js.map +1 -0
  127. package/dist/validation.d.ts +6 -0
  128. package/dist/validation.d.ts.map +1 -0
  129. package/dist/validation.js +209 -0
  130. package/dist/validation.js.map +1 -0
  131. package/package.json +51 -0
@@ -0,0 +1,3456 @@
1
+ import AsyncStorage from '@react-native-async-storage/async-storage';
2
+ /* eslint-disable react-hooks/set-state-in-effect */
3
+ import { useEffect, useEffectEvent, useMemo, useRef, useState } from 'react';
4
+ import {
5
+ Alert,
6
+ Animated,
7
+ Easing,
8
+ Modal,
9
+ Platform,
10
+ Pressable,
11
+ ScrollView,
12
+ StyleSheet,
13
+ Text,
14
+ TextInput,
15
+ type TextStyle,
16
+ View,
17
+ } from 'react-native';
18
+ import ColorPicker, { HueSlider, Panel1, Preview } from 'reanimated-color-picker';
19
+ import tailwindColors from 'tailwindcss/colors';
20
+ import { useSafeAreaInsets } from 'react-native-safe-area-context';
21
+
22
+ import { AnimatedPressable } from '../../components/swmansion/animated-pressable';
23
+ import { EMBEDDED_GOOGLE_FONTS } from './embedded-fonts';
24
+ import defaultThemeTokens, {
25
+ type StylistColorMode,
26
+ type StylistColorPalette,
27
+ type StylistColorScheme,
28
+ type StylistFamilyMode,
29
+ type StylistSemanticFamilies,
30
+ type StylistThemeTokens,
31
+ } from '../../theme/tokens';
32
+ import { useSetAppTheme } from '../../theme/provider';
33
+
34
+ type SemanticColorKey = keyof StylistSemanticFamilies;
35
+ type PaletteColorKey = keyof StylistColorPalette;
36
+ type TailwindShade = 50 | 100 | 200 | 300 | 400 | 500 | 600 | 700 | 800 | 900 | 950;
37
+ type ColorInputMode = 'picker' | 'families';
38
+ type WritePolicy = 'managed' | 'overwrite';
39
+ type SaveMessageTone = 'info' | 'success' | 'error';
40
+ type PaletteFamilies = Record<PaletteColorKey, TailwindColorFamily>;
41
+ type PaletteShades = Record<PaletteColorKey, TailwindShade>;
42
+ type LivePickerPreview = {
43
+ colorInputMode: ColorInputMode;
44
+ colorMode: StylistColorMode;
45
+ hex: string;
46
+ key: PaletteColorKey;
47
+ scheme: StylistColorScheme;
48
+ };
49
+ type FontRoleKey =
50
+ | 'fontDisplay'
51
+ | 'fontTitle'
52
+ | 'fontSubtitle'
53
+ | 'fontBody'
54
+ | 'fontCaption'
55
+ | 'fontMono';
56
+ type StylistTypographyRoles = {
57
+ fontDisplay: string;
58
+ fontTitle: string;
59
+ fontSubtitle: string;
60
+ fontBody: string;
61
+ fontCaption: string;
62
+ fontMono: string;
63
+ };
64
+ type ExtendedStylistThemeTokens = Omit<StylistThemeTokens, 'typography'> & {
65
+ typography: StylistThemeTokens['typography'] & StylistTypographyRoles;
66
+ };
67
+ type TopToggleHelpKey = 'colorMode' | 'preview' | 'familyStrategy';
68
+
69
+ type TailwindColorFamily =
70
+ | 'slate'
71
+ | 'gray'
72
+ | 'zinc'
73
+ | 'neutral'
74
+ | 'stone'
75
+ | 'red'
76
+ | 'orange'
77
+ | 'amber'
78
+ | 'yellow'
79
+ | 'lime'
80
+ | 'green'
81
+ | 'emerald'
82
+ | 'teal'
83
+ | 'cyan'
84
+ | 'sky'
85
+ | 'blue'
86
+ | 'indigo'
87
+ | 'violet'
88
+ | 'purple'
89
+ | 'fuchsia'
90
+ | 'pink'
91
+ | 'rose';
92
+
93
+ const paletteColorKeys: PaletteColorKey[] = [
94
+ 'background',
95
+ 'surface',
96
+ 'text',
97
+ 'primary',
98
+ 'secondary',
99
+ 'success',
100
+ 'warning',
101
+ ];
102
+
103
+ const semanticColorKeys: SemanticColorKey[] = ['primary', 'secondary', 'success', 'warning'];
104
+ const stylistThemeTokens = defaultThemeTokens;
105
+
106
+ const spacingKeys: (keyof StylistThemeTokens['layout']['spacing'])[] = [
107
+ 'xs',
108
+ 'sm',
109
+ 'md',
110
+ 'lg',
111
+ 'xl',
112
+ ];
113
+ const schemeKeys: StylistColorScheme[] = ['light', 'dark'];
114
+ const familyModeOptions: { label: string; value: StylistFamilyMode }[] = [
115
+ { label: '1 family', value: 'one' },
116
+ { label: '2 families', value: 'two' },
117
+ ];
118
+ const colorModeOptions: { label: string; value: StylistColorMode }[] = [
119
+ { label: 'BG Color', value: 'bg' },
120
+ { label: 'Automatic', value: 'automatic' },
121
+ ];
122
+ const colorInputModeOptions: { label: string; value: ColorInputMode }[] = [
123
+ { label: 'Color Picker', value: 'picker' },
124
+ { label: 'Tailwind Families', value: 'families' },
125
+ ];
126
+ const NATIVE_SAVE_COMMAND = 'npm run stylist:sync:android';
127
+ const GOOGLE_FONTS_KEY_STORAGE = 'mds.stylist.googleFontsApiKey';
128
+ const GOOGLE_FONTS_BANNER_DISMISSED_STORAGE = 'mds.stylist.googleFontsBannerDismissed';
129
+ const GOOGLE_FONTS_API_URL = 'https://www.googleapis.com/webfonts/v1/webfonts';
130
+ const WEB_LAST_SYNC_MESSAGE_STORAGE = 'mds.stylist.lastSyncMessage';
131
+ const WEB_SYSTEM_FONTS = new Set([
132
+ 'system',
133
+ 'arial',
134
+ 'helvetica',
135
+ 'times new roman',
136
+ 'georgia',
137
+ 'courier new',
138
+ 'monospace',
139
+ 'sans-serif',
140
+ 'serif',
141
+ 'ui-sans-serif',
142
+ ]);
143
+ const NATIVE_SAFE_FONTS = new Set([
144
+ 'system',
145
+ 'arial',
146
+ 'helvetica',
147
+ 'times new roman',
148
+ 'georgia',
149
+ 'courier new',
150
+ 'monospace',
151
+ 'sans-serif',
152
+ 'serif',
153
+ 'notoserif',
154
+ 'noto sans',
155
+ ]);
156
+ const fontRoleFields: {
157
+ key: FontRoleKey;
158
+ label: string;
159
+ placeholder: string;
160
+ }[] = [
161
+ { key: 'fontDisplay', label: 'Display', placeholder: 'Display font family' },
162
+ { key: 'fontTitle', label: 'Title', placeholder: 'Title font family' },
163
+ {
164
+ key: 'fontSubtitle',
165
+ label: 'Subtitle',
166
+ placeholder: 'Subtitle font family',
167
+ },
168
+ { key: 'fontBody', label: 'Body', placeholder: 'Body font family' },
169
+ { key: 'fontCaption', label: 'Caption', placeholder: 'Caption font family' },
170
+ { key: 'fontMono', label: 'Mono', placeholder: 'Mono font family' },
171
+ ];
172
+ const builtInFontChoices = [
173
+ 'System',
174
+ 'monospace',
175
+ 'Arial',
176
+ 'Helvetica',
177
+ 'Times New Roman',
178
+ 'Georgia',
179
+ 'Courier New',
180
+ 'sans-serif',
181
+ 'serif',
182
+ ];
183
+ const loadedWebFonts = new Set<string>();
184
+
185
+ type WebSyncMessageSnapshot = { message: string; timestamp: number };
186
+
187
+ function readWebSyncMessageSnapshot(): WebSyncMessageSnapshot | null {
188
+ if (Platform.OS !== 'web') {
189
+ return null;
190
+ }
191
+
192
+ try {
193
+ const raw = (globalThis as any).sessionStorage?.getItem(WEB_LAST_SYNC_MESSAGE_STORAGE);
194
+ if (!raw) {
195
+ return null;
196
+ }
197
+ const parsed = JSON.parse(raw) as Partial<WebSyncMessageSnapshot>;
198
+ if (!parsed.message || typeof parsed.message !== 'string') {
199
+ return null;
200
+ }
201
+ if (typeof parsed.timestamp !== 'number') {
202
+ return null;
203
+ }
204
+ return { message: parsed.message, timestamp: parsed.timestamp };
205
+ } catch {
206
+ return null;
207
+ }
208
+ }
209
+
210
+ function writeWebSyncMessageSnapshot(message: string) {
211
+ if (Platform.OS !== 'web') {
212
+ return;
213
+ }
214
+
215
+ try {
216
+ (globalThis as any).sessionStorage?.setItem(
217
+ WEB_LAST_SYNC_MESSAGE_STORAGE,
218
+ JSON.stringify({ message, timestamp: Date.now() } satisfies WebSyncMessageSnapshot)
219
+ );
220
+ } catch {
221
+ // no-op
222
+ }
223
+ }
224
+
225
+ function clearWebSyncMessageSnapshot() {
226
+ if (Platform.OS !== 'web') {
227
+ return;
228
+ }
229
+
230
+ try {
231
+ (globalThis as any).sessionStorage?.removeItem(WEB_LAST_SYNC_MESSAGE_STORAGE);
232
+ } catch {
233
+ // no-op
234
+ }
235
+ }
236
+
237
+ const tailwindFamilies: TailwindColorFamily[] = [
238
+ 'slate',
239
+ 'gray',
240
+ 'zinc',
241
+ 'neutral',
242
+ 'stone',
243
+ 'red',
244
+ 'orange',
245
+ 'amber',
246
+ 'yellow',
247
+ 'lime',
248
+ 'green',
249
+ 'emerald',
250
+ 'teal',
251
+ 'cyan',
252
+ 'sky',
253
+ 'blue',
254
+ 'indigo',
255
+ 'violet',
256
+ 'purple',
257
+ 'fuchsia',
258
+ 'pink',
259
+ 'rose',
260
+ ];
261
+
262
+ const pickerSwatches = [
263
+ '#f44336',
264
+ '#e91e63',
265
+ '#9c27b0',
266
+ '#673ab7',
267
+ '#3f51b5',
268
+ '#2196f3',
269
+ '#03a9f4',
270
+ '#00bcd4',
271
+ '#009688',
272
+ '#4caf50',
273
+ '#8bc34a',
274
+ '#cddc39',
275
+ '#ffeb3b',
276
+ '#ffc107',
277
+ '#ff9800',
278
+ '#ff5722',
279
+ '#795548',
280
+ '#9e9e9e',
281
+ '#607d8b',
282
+ ];
283
+
284
+ const tailwindPalette = tailwindColors as unknown as Record<
285
+ string,
286
+ Partial<Record<TailwindShade, string>>
287
+ >;
288
+ const automaticLockedKeys: PaletteColorKey[] = ['background', 'surface', 'text'];
289
+ const shadeOptions: TailwindShade[] = [50, 100, 200, 300, 400, 500, 600, 700, 800, 900, 950];
290
+ const topToggleHelpCopy: Record<TopToggleHelpKey, { title: string; body: string }> = {
291
+ colorMode: {
292
+ title: 'Color Mode',
293
+ body: 'Automatic derives background, surface, and text from primary. BG Color lets you set each palette color directly.',
294
+ },
295
+ preview: {
296
+ title: 'Preview',
297
+ body: 'Preview switches light/dark editing context. Saved tokens still include both schemes.',
298
+ },
299
+ familyStrategy: {
300
+ title: 'Family Strategy',
301
+ body: '1 family mirrors semantic color families across light and dark. 2 families lets each scheme use its own family mapping.',
302
+ },
303
+ };
304
+
305
+ const defaultBgFamilies: Record<StylistColorScheme, PaletteFamilies> = {
306
+ light: {
307
+ background: 'slate',
308
+ surface: 'gray',
309
+ text: 'slate',
310
+ primary: 'blue',
311
+ secondary: 'violet',
312
+ success: 'emerald',
313
+ warning: 'amber',
314
+ },
315
+ dark: {
316
+ background: 'slate',
317
+ surface: 'gray',
318
+ text: 'slate',
319
+ primary: 'blue',
320
+ secondary: 'violet',
321
+ success: 'emerald',
322
+ warning: 'amber',
323
+ },
324
+ };
325
+
326
+ const defaultBgFamilyShades: Record<StylistColorScheme, PaletteShades> = {
327
+ light: {
328
+ background: 50,
329
+ surface: 100,
330
+ text: 900,
331
+ primary: 500,
332
+ secondary: 500,
333
+ success: 500,
334
+ warning: 500,
335
+ },
336
+ dark: {
337
+ background: 950,
338
+ surface: 900,
339
+ text: 50,
340
+ primary: 400,
341
+ secondary: 400,
342
+ success: 400,
343
+ warning: 400,
344
+ },
345
+ };
346
+
347
+ function hexToRgb(hex: string): { r: number; g: number; b: number } {
348
+ const normalized = hex.replace('#', '').trim();
349
+ const value =
350
+ normalized.length === 3
351
+ ? normalized
352
+ .split('')
353
+ .map((char) => `${char}${char}`)
354
+ .join('')
355
+ : normalized;
356
+
357
+ const int = Number.parseInt(value, 16);
358
+ return {
359
+ r: (int >> 16) & 255,
360
+ g: (int >> 8) & 255,
361
+ b: int & 255,
362
+ };
363
+ }
364
+
365
+ function rgbToHex(r: number, g: number, b: number): string {
366
+ const clamp = (value: number) => Math.max(0, Math.min(255, Math.round(value)));
367
+ const toHex = (value: number) => clamp(value).toString(16).padStart(2, '0');
368
+ return `#${toHex(r)}${toHex(g)}${toHex(b)}`;
369
+ }
370
+
371
+ function mixHex(base: string, mix: string, amount: number): string {
372
+ const a = hexToRgb(base);
373
+ const b = hexToRgb(mix);
374
+ return rgbToHex(
375
+ a.r + (b.r - a.r) * amount,
376
+ a.g + (b.g - a.g) * amount,
377
+ a.b + (b.b - a.b) * amount
378
+ );
379
+ }
380
+
381
+ function relativeLuminance(hex: string): number {
382
+ const { r, g, b } = hexToRgb(hex);
383
+ const toLinear = (channel: number) => {
384
+ const value = channel / 255;
385
+ return value <= 0.03928 ? value / 12.92 : ((value + 0.055) / 1.055) ** 2.4;
386
+ };
387
+ return 0.2126 * toLinear(r) + 0.7152 * toLinear(g) + 0.0722 * toLinear(b);
388
+ }
389
+
390
+ function contrastRatio(a: string, b: string): number {
391
+ const lighter = Math.max(relativeLuminance(a), relativeLuminance(b));
392
+ const darker = Math.min(relativeLuminance(a), relativeLuminance(b));
393
+ return (lighter + 0.05) / (darker + 0.05);
394
+ }
395
+
396
+ function ensureReadableTextColor(
397
+ text: string,
398
+ surface: string,
399
+ scheme: StylistColorScheme
400
+ ): string {
401
+ if (contrastRatio(text, surface) >= 7) {
402
+ return text;
403
+ }
404
+
405
+ const target = scheme === 'dark' ? '#ffffff' : '#000000';
406
+ for (const amount of [0.25, 0.4, 0.55, 0.7, 0.85, 1]) {
407
+ const candidate = mixHex(text, target, amount);
408
+ if (contrastRatio(candidate, surface) >= 7) {
409
+ return candidate;
410
+ }
411
+ }
412
+
413
+ return target;
414
+ }
415
+
416
+ function getReadableChipTextColor(background: string): string {
417
+ return contrastRatio('#ffffff', background) >= contrastRatio('#111827', background)
418
+ ? '#ffffff'
419
+ : '#111827';
420
+ }
421
+
422
+ function generateTailwindLikeScale(primaryHex: string): Record<TailwindShade, string> {
423
+ const white = '#ffffff';
424
+ const black = '#000000';
425
+
426
+ return {
427
+ 50: mixHex(primaryHex, white, 0.95),
428
+ 100: mixHex(primaryHex, white, 0.88),
429
+ 200: mixHex(primaryHex, white, 0.74),
430
+ 300: mixHex(primaryHex, white, 0.58),
431
+ 400: mixHex(primaryHex, white, 0.32),
432
+ 500: primaryHex.toLowerCase(),
433
+ 600: mixHex(primaryHex, black, 0.16),
434
+ 700: mixHex(primaryHex, black, 0.32),
435
+ 800: mixHex(primaryHex, black, 0.5),
436
+ 900: mixHex(primaryHex, black, 0.68),
437
+ 950: mixHex(primaryHex, black, 0.8),
438
+ };
439
+ }
440
+
441
+ function linearToSrgb(value: number): number {
442
+ if (value <= 0.0031308) {
443
+ return 12.92 * value;
444
+ }
445
+
446
+ return 1.055 * Math.pow(value, 1 / 2.4) - 0.055;
447
+ }
448
+
449
+ function clamp01(value: number): number {
450
+ return Math.max(0, Math.min(1, value));
451
+ }
452
+
453
+ function toHexChannel(value: number): string {
454
+ const channel = Math.round(clamp01(value) * 255);
455
+ return channel.toString(16).padStart(2, '0');
456
+ }
457
+
458
+ function oklchToHex(l: number, c: number, hDeg: number): string {
459
+ const hRad = (hDeg * Math.PI) / 180;
460
+ const a = c * Math.cos(hRad);
461
+ const b = c * Math.sin(hRad);
462
+
463
+ const lPrime = l + 0.3963377774 * a + 0.2158037573 * b;
464
+ const mPrime = l - 0.1055613458 * a - 0.0638541728 * b;
465
+ const sPrime = l - 0.0894841775 * a - 1.291485548 * b;
466
+
467
+ const lCube = lPrime ** 3;
468
+ const mCube = mPrime ** 3;
469
+ const sCube = sPrime ** 3;
470
+
471
+ const rLinear = 4.0767416621 * lCube - 3.3077115913 * mCube + 0.2309699292 * sCube;
472
+ const gLinear = -1.2684380046 * lCube + 2.6097574011 * mCube - 0.3413193965 * sCube;
473
+ const bLinear = -0.0041960863 * lCube - 0.7034186147 * mCube + 1.707614701 * sCube;
474
+
475
+ const r = linearToSrgb(rLinear);
476
+ const g = linearToSrgb(gLinear);
477
+ const bOut = linearToSrgb(bLinear);
478
+
479
+ return `#${toHexChannel(r)}${toHexChannel(g)}${toHexChannel(bOut)}`;
480
+ }
481
+
482
+ function parseOklch(color: string): { l: number; c: number; h: number } | null {
483
+ const match = color
484
+ .trim()
485
+ .match(/^oklch\(\s*([0-9.]+)%\s+([0-9.]+)\s+([0-9.]+)(?:\s*\/\s*[0-9.%]+)?\s*\)$/i);
486
+
487
+ if (!match) {
488
+ return null;
489
+ }
490
+
491
+ const l = Number.parseFloat(match[1] ?? '');
492
+ const c = Number.parseFloat(match[2] ?? '');
493
+ const h = Number.parseFloat(match[3] ?? '');
494
+ if (!Number.isFinite(l) || !Number.isFinite(c) || !Number.isFinite(h)) {
495
+ return null;
496
+ }
497
+
498
+ return { l: l / 100, c, h };
499
+ }
500
+
501
+ function getTailwindColor(family: string, shade: TailwindShade): string {
502
+ const familyScale = tailwindPalette[family] ?? tailwindPalette.blue;
503
+ const value = familyScale?.[shade] ?? tailwindPalette.blue?.[shade] ?? '#3b82f6';
504
+ const normalized = value.trim().toLowerCase();
505
+ if (normalized.startsWith('#')) {
506
+ return normalized;
507
+ }
508
+ if (normalized.startsWith('oklch(')) {
509
+ const parsed = parseOklch(normalized);
510
+ if (parsed) {
511
+ return oklchToHex(parsed.l, parsed.c, parsed.h);
512
+ }
513
+ }
514
+
515
+ return '#3b82f6';
516
+ }
517
+
518
+ function nudgeShadeTowardCenter(shade: TailwindShade): TailwindShade {
519
+ const order: TailwindShade[] = [50, 100, 200, 300, 400, 500, 600, 700, 800, 900, 950];
520
+ const index = order.indexOf(shade);
521
+ if (index <= 0) return 100;
522
+ if (index >= order.length - 1) return 900;
523
+ const next = shade < 500 ? index + 1 : index - 1;
524
+ return order[next] ?? shade;
525
+ }
526
+
527
+ function deriveAutomaticPalette(
528
+ families: StylistSemanticFamilies,
529
+ scheme: StylistColorScheme,
530
+ useSharedSemanticShades = false
531
+ ): StylistColorPalette {
532
+ const semanticShade: TailwindShade = useSharedSemanticShades
533
+ ? 500
534
+ : scheme === 'light'
535
+ ? 500
536
+ : 400;
537
+ const backgroundShade: TailwindShade = scheme === 'light' ? 50 : 950;
538
+ let surfaceShade: TailwindShade = scheme === 'light' ? 100 : 900;
539
+
540
+ const background = getTailwindColor(families.primary, backgroundShade);
541
+ let surface = getTailwindColor(families.primary, surfaceShade);
542
+
543
+ if (background === surface) {
544
+ surfaceShade = nudgeShadeTowardCenter(surfaceShade);
545
+ surface = getTailwindColor(families.primary, surfaceShade);
546
+ }
547
+
548
+ const text = ensureReadableTextColor(
549
+ getTailwindColor(families.primary, scheme === 'light' ? 900 : 200),
550
+ surface,
551
+ scheme
552
+ );
553
+
554
+ return {
555
+ background,
556
+ surface,
557
+ text,
558
+ primary: getTailwindColor(families.primary, semanticShade),
559
+ secondary: getTailwindColor(families.secondary, semanticShade),
560
+ success: getTailwindColor(families.success, semanticShade),
561
+ warning: getTailwindColor(families.warning, semanticShade),
562
+ };
563
+ }
564
+
565
+ function deriveBgPaletteFromFamilies(
566
+ families: PaletteFamilies,
567
+ shades: PaletteShades
568
+ ): StylistColorPalette {
569
+ return {
570
+ background: getTailwindColor(families.background, shades.background),
571
+ surface: getTailwindColor(families.surface, shades.surface),
572
+ text: getTailwindColor(families.text, shades.text),
573
+ primary: getTailwindColor(families.primary, shades.primary),
574
+ secondary: getTailwindColor(families.secondary, shades.secondary),
575
+ success: getTailwindColor(families.success, shades.success),
576
+ warning: getTailwindColor(families.warning, shades.warning),
577
+ };
578
+ }
579
+
580
+ function deriveAutomaticPaletteFromPrimary(
581
+ source: StylistColorPalette,
582
+ scheme: StylistColorScheme
583
+ ): StylistColorPalette {
584
+ const shades = generateTailwindLikeScale(source.primary);
585
+
586
+ return {
587
+ ...source,
588
+ background: shades[scheme === 'light' ? 50 : 950],
589
+ surface: shades[scheme === 'light' ? 100 : 900],
590
+ text: ensureReadableTextColor(
591
+ shades[scheme === 'light' ? 900 : 200],
592
+ shades[scheme === 'light' ? 100 : 900],
593
+ scheme
594
+ ),
595
+ };
596
+ }
597
+
598
+ function ensureDistinctBackgroundSurface(
599
+ palette: StylistColorPalette,
600
+ scheme: StylistColorScheme
601
+ ): StylistColorPalette {
602
+ if (palette.background !== palette.surface) {
603
+ return palette;
604
+ }
605
+
606
+ const fallbackSurface = scheme === 'light' ? '#e2e8f0' : '#18181b';
607
+ const fallbackAlternate = scheme === 'light' ? '#f1f5f9' : '#27272a';
608
+
609
+ return {
610
+ ...palette,
611
+ surface: palette.background === fallbackSurface ? fallbackAlternate : fallbackSurface,
612
+ };
613
+ }
614
+
615
+ function reconcileTheme(
616
+ theme: StylistThemeTokens,
617
+ colorInputMode: ColorInputMode,
618
+ bgFamilies: Record<StylistColorScheme, PaletteFamilies>,
619
+ bgFamilyShades: Record<StylistColorScheme, PaletteShades>
620
+ ): StylistThemeTokens {
621
+ const familiesDark =
622
+ theme.colorSystem.familyMode === 'one' ? { ...theme.families.light } : theme.families.dark;
623
+
624
+ const bgLightSource =
625
+ colorInputMode === 'families'
626
+ ? deriveBgPaletteFromFamilies(bgFamilies.light, bgFamilyShades.light)
627
+ : theme.palettes.bg.light;
628
+ const bgDarkSource =
629
+ colorInputMode === 'families'
630
+ ? deriveBgPaletteFromFamilies(
631
+ theme.colorSystem.familyMode === 'one' ? bgFamilies.light : bgFamilies.dark,
632
+ theme.colorSystem.familyMode === 'one' ? bgFamilyShades.light : bgFamilyShades.dark
633
+ )
634
+ : theme.palettes.bg.dark;
635
+
636
+ const bgLight = ensureDistinctBackgroundSurface(bgLightSource, 'light');
637
+ const bgDark = ensureDistinctBackgroundSurface(bgDarkSource, 'dark');
638
+
639
+ const useSharedSemanticShades = theme.colorSystem.familyMode === 'one';
640
+ const automaticFamilyLight = deriveAutomaticPalette(
641
+ theme.families.light,
642
+ 'light',
643
+ useSharedSemanticShades
644
+ );
645
+ const automaticFamilyDark = deriveAutomaticPalette(familiesDark, 'dark', useSharedSemanticShades);
646
+
647
+ const automaticLightSource =
648
+ colorInputMode === 'families'
649
+ ? automaticFamilyLight
650
+ : deriveAutomaticPaletteFromPrimary(theme.palettes.automatic.light, 'light');
651
+ const automaticDarkSource =
652
+ colorInputMode === 'families'
653
+ ? automaticFamilyDark
654
+ : deriveAutomaticPaletteFromPrimary(theme.palettes.automatic.dark, 'dark');
655
+
656
+ const automaticLight = ensureDistinctBackgroundSurface(automaticLightSource, 'light');
657
+ const automaticDark = ensureDistinctBackgroundSurface(automaticDarkSource, 'dark');
658
+
659
+ const resolvedColors =
660
+ theme.colorSystem.mode === 'automatic'
661
+ ? { light: automaticLight, dark: automaticDark }
662
+ : { light: bgLight, dark: bgDark };
663
+
664
+ return {
665
+ ...theme,
666
+ families: {
667
+ ...theme.families,
668
+ dark: familiesDark,
669
+ },
670
+ palettes: {
671
+ ...theme.palettes,
672
+ bg: {
673
+ light: bgLight,
674
+ dark: bgDark,
675
+ },
676
+ automatic: {
677
+ light: automaticLight,
678
+ dark: automaticDark,
679
+ },
680
+ },
681
+ colors: resolvedColors,
682
+ };
683
+ }
684
+
685
+ function isLockedAutomaticPickerKey(
686
+ mode: StylistColorMode,
687
+ inputMode: ColorInputMode,
688
+ key: PaletteColorKey
689
+ ): boolean {
690
+ return mode === 'automatic' && inputMode === 'picker' && automaticLockedKeys.includes(key);
691
+ }
692
+
693
+ function sanitizeHexDraftInput(raw: string): string {
694
+ const hexOnly = raw
695
+ .trim()
696
+ .replace(/[^0-9a-fA-F]/g, '')
697
+ .slice(0, 6)
698
+ .toLowerCase();
699
+ return `#${hexOnly}`;
700
+ }
701
+
702
+ function isCommitReadyHex(raw: string): boolean {
703
+ const length = sanitizeHexDraftInput(raw).slice(1).length;
704
+ return length === 3 || length === 6;
705
+ }
706
+
707
+ function isImmediateApplyHex(raw: string): boolean {
708
+ return sanitizeHexDraftInput(raw).slice(1).length === 6;
709
+ }
710
+
711
+ function normalizeHexForTheme(raw: string): string {
712
+ const sanitized = sanitizeHexDraftInput(raw);
713
+ const hexBody = sanitized.slice(1);
714
+ if (hexBody.length === 3) {
715
+ return `#${hexBody
716
+ .split('')
717
+ .map((char) => `${char}${char}`)
718
+ .join('')}`;
719
+ }
720
+ return sanitized;
721
+ }
722
+
723
+ function normalizeFontFamilyName(value: string): string {
724
+ return value.replace(/^['"]|['"]$/g, '').trim();
725
+ }
726
+
727
+ function humanizeSaveError(rawMessage: string): string {
728
+ const message = rawMessage.trim();
729
+ if (!message) {
730
+ return 'Theme save failed for an unknown reason.';
731
+ }
732
+ if (message.includes('spawn EINVAL')) {
733
+ return 'Theme save could not start the sync command on this machine (spawn EINVAL). Check your local Node/npm shell setup, then retry.';
734
+ }
735
+ if (message.includes('Failed to parse stylist sync output')) {
736
+ return 'Theme save finished with unreadable CLI output. Please rerun Save Theme and check terminal logs.';
737
+ }
738
+ return message;
739
+ }
740
+
741
+ function normalizeThemeTypography(theme: StylistThemeTokens): ExtendedStylistThemeTokens {
742
+ const fallback = normalizeFontFamilyName(theme.typography.fontFamily) || 'System';
743
+
744
+ return {
745
+ ...theme,
746
+ typography: {
747
+ ...theme.typography,
748
+ fontFamily: fallback,
749
+ fontDisplay:
750
+ normalizeFontFamilyName(
751
+ (theme.typography as Partial<StylistTypographyRoles>).fontDisplay ?? fallback
752
+ ) || fallback,
753
+ fontTitle:
754
+ normalizeFontFamilyName(
755
+ (theme.typography as Partial<StylistTypographyRoles>).fontTitle ?? fallback
756
+ ) || fallback,
757
+ fontSubtitle:
758
+ normalizeFontFamilyName(
759
+ (theme.typography as Partial<StylistTypographyRoles>).fontSubtitle ?? fallback
760
+ ) || fallback,
761
+ fontBody:
762
+ normalizeFontFamilyName(
763
+ (theme.typography as Partial<StylistTypographyRoles>).fontBody ?? fallback
764
+ ) || fallback,
765
+ fontCaption:
766
+ normalizeFontFamilyName(
767
+ (theme.typography as Partial<StylistTypographyRoles>).fontCaption ?? fallback
768
+ ) || fallback,
769
+ fontMono:
770
+ normalizeFontFamilyName(
771
+ (theme.typography as Partial<StylistTypographyRoles>).fontMono ?? 'monospace'
772
+ ) || 'monospace',
773
+ },
774
+ };
775
+ }
776
+
777
+ function withFontFamilyAlias(theme: ExtendedStylistThemeTokens): ExtendedStylistThemeTokens {
778
+ return {
779
+ ...theme,
780
+ typography: {
781
+ ...theme.typography,
782
+ fontFamily: normalizeFontFamilyName(theme.typography.fontDisplay) || 'System',
783
+ },
784
+ };
785
+ }
786
+
787
+ function areThemesEqual(
788
+ left: ExtendedStylistThemeTokens,
789
+ right: ExtendedStylistThemeTokens
790
+ ): boolean {
791
+ return JSON.stringify(left) === JSON.stringify(right);
792
+ }
793
+
794
+ function makeFontCssFamily(fontFamily: string): string {
795
+ const normalized = normalizeFontFamilyName(fontFamily);
796
+ if (!normalized) {
797
+ return 'System';
798
+ }
799
+
800
+ return normalized.includes(' ') ? `"${normalized}"` : normalized;
801
+ }
802
+
803
+ function makeNativeFontFamily(fontFamily: string): string {
804
+ const normalized = normalizeFontFamilyName(fontFamily);
805
+ return normalized || 'System';
806
+ }
807
+
808
+ function isNativeUnsafeFont(fontFamily: string): boolean {
809
+ const key = normalizeFontFamilyName(fontFamily).toLowerCase();
810
+ if (!key) {
811
+ return false;
812
+ }
813
+
814
+ return !NATIVE_SAFE_FONTS.has(key);
815
+ }
816
+
817
+ function resolvePreviewFontFamily(fontFamily: string): string {
818
+ return Platform.OS === 'web' ? makeFontCssFamily(fontFamily) : makeNativeFontFamily(fontFamily);
819
+ }
820
+
821
+ function resolvePreviewFontWeight(
822
+ fontFamily: string,
823
+ weight: NonNullable<TextStyle['fontWeight']>
824
+ ): NonNullable<TextStyle['fontWeight']> {
825
+ if (Platform.OS === 'web') {
826
+ return weight;
827
+ }
828
+
829
+ // Many Google Fonts are single-weight on native until the sync downloads assets, so avoid
830
+ // requesting unsupported weights that can cause the font to fall back.
831
+ return isNativeUnsafeFont(fontFamily) ? 'normal' : weight;
832
+ }
833
+
834
+ function buildGoogleFontsStylesheetUrl(fontFamily: string): string {
835
+ const familyParam = normalizeFontFamilyName(fontFamily).replace(/\s+/g, '+');
836
+ return `https://fonts.googleapis.com/css2?family=${familyParam}:wght@400;500;700;800&display=swap`;
837
+ }
838
+
839
+ function ensureWebFontLoaded(fontFamily: string): void {
840
+ if (Platform.OS !== 'web' || typeof document === 'undefined') {
841
+ return;
842
+ }
843
+
844
+ const normalized = normalizeFontFamilyName(fontFamily);
845
+ if (!normalized) {
846
+ return;
847
+ }
848
+
849
+ if (WEB_SYSTEM_FONTS.has(normalized.toLowerCase()) || loadedWebFonts.has(normalized)) {
850
+ return;
851
+ }
852
+
853
+ const existing = document.querySelector(`link[data-stylist-font="${normalized}"]`);
854
+ if (existing) {
855
+ loadedWebFonts.add(normalized);
856
+ return;
857
+ }
858
+
859
+ const link = document.createElement('link');
860
+ link.rel = 'stylesheet';
861
+ link.href = buildGoogleFontsStylesheetUrl(normalized);
862
+ link.setAttribute('data-stylist-font', normalized);
863
+ document.head.appendChild(link);
864
+ loadedWebFonts.add(normalized);
865
+ }
866
+
867
+ export default function StylistScreen() {
868
+ const insets = useSafeAreaInsets();
869
+ const setAppTheme = useSetAppTheme();
870
+ const [colorInputMode, setColorInputMode] = useState<ColorInputMode>('picker');
871
+ const [bgFamilies, setBgFamilies] =
872
+ useState<Record<StylistColorScheme, PaletteFamilies>>(defaultBgFamilies);
873
+ const [bgFamilyShades, setBgFamilyShades] =
874
+ useState<Record<StylistColorScheme, PaletteShades>>(defaultBgFamilyShades);
875
+ const [theme, setTheme] = useState<ExtendedStylistThemeTokens>(
876
+ withFontFamilyAlias(
877
+ normalizeThemeTypography(
878
+ reconcileTheme(stylistThemeTokens, 'picker', defaultBgFamilies, defaultBgFamilyShades)
879
+ )
880
+ )
881
+ );
882
+ const [selectedColor, setSelectedColor] = useState<PaletteColorKey>('primary');
883
+ const [pickerHexDraft, setPickerHexDraft] = useState('#');
884
+ const [livePickerPreview, setLivePickerPreview] = useState<LivePickerPreview | null>(null);
885
+ const [saveMessage, setSaveMessage] = useState('');
886
+ const [saveMessageTone, setSaveMessageTone] = useState<SaveMessageTone>('info');
887
+ const [nativeDraft, setNativeDraft] = useState('');
888
+ const [saving, setSaving] = useState(false);
889
+ const [saveMessageNonce, setSaveMessageNonce] = useState(0);
890
+ const [writePolicy, setWritePolicy] = useState<WritePolicy | null>(null);
891
+ const [showWritePolicyModal, setShowWritePolicyModal] = useState(false);
892
+ const [activeTopToggleHelp, setActiveTopToggleHelp] = useState<TopToggleHelpKey | null>(null);
893
+ const [writePolicyLoaded, setWritePolicyLoaded] = useState(Platform.OS !== 'web');
894
+ const [apiKeyDraft, setApiKeyDraft] = useState('');
895
+ const [storedApiKey, setStoredApiKey] = useState('');
896
+ const [fontBannerDismissed, setFontBannerDismissed] = useState(false);
897
+ const [remoteFonts, setRemoteFonts] = useState<string[]>([]);
898
+ const [loadingFonts, setLoadingFonts] = useState(false);
899
+ const [fontFetchError, setFontFetchError] = useState('');
900
+ const [fontRefreshIndex, setFontRefreshIndex] = useState(0);
901
+ const saveStatusTimeout = useRef<ReturnType<typeof setTimeout> | null>(null);
902
+ const saveMessageKind = useRef<'none' | 'load' | 'sync' | 'status'>('none');
903
+ const [explicitFontRoles, setExplicitFontRoles] = useState<Record<FontRoleKey, boolean>>({
904
+ fontDisplay: true,
905
+ fontTitle: true,
906
+ fontSubtitle: true,
907
+ fontBody: true,
908
+ fontCaption: true,
909
+ fontMono: true,
910
+ });
911
+ const saveButtonScale = useMemo(() => new Animated.Value(1), []);
912
+ const saveBannerOpacity = useMemo(() => new Animated.Value(1), []);
913
+
914
+ function pulseSaveButton() {
915
+ saveButtonScale.stopAnimation();
916
+ Animated.sequence([
917
+ Animated.timing(saveButtonScale, {
918
+ toValue: 0.96,
919
+ duration: 90,
920
+ easing: Easing.out(Easing.quad),
921
+ useNativeDriver: Platform.OS !== 'web',
922
+ }),
923
+ Animated.spring(saveButtonScale, {
924
+ toValue: 1,
925
+ speed: 16,
926
+ bounciness: 6,
927
+ useNativeDriver: Platform.OS !== 'web',
928
+ }),
929
+ ]).start();
930
+ }
931
+
932
+ function publishSaveMessage(tone: SaveMessageTone, message: string) {
933
+ if (saveStatusTimeout.current) {
934
+ clearTimeout(saveStatusTimeout.current);
935
+ saveStatusTimeout.current = null;
936
+ }
937
+ const stamp = new Date().toLocaleTimeString([], {
938
+ hour: '2-digit',
939
+ minute: '2-digit',
940
+ second: '2-digit',
941
+ hour12: false,
942
+ });
943
+ setSaveMessageTone(tone);
944
+ setSaveMessage(`${message} (${stamp})`);
945
+ setSaveMessageNonce((value) => value + 1);
946
+ saveBannerOpacity.stopAnimation();
947
+ saveBannerOpacity.setValue(0.2);
948
+ Animated.timing(saveBannerOpacity, {
949
+ toValue: 1,
950
+ duration: 220,
951
+ easing: Easing.out(Easing.quad),
952
+ useNativeDriver: Platform.OS !== 'web',
953
+ }).start();
954
+ }
955
+
956
+ const publishEffectMessage = useEffectEvent((tone: SaveMessageTone, message: string) => {
957
+ publishSaveMessage(tone, message);
958
+ });
959
+
960
+ function publishSyncMessage(message: string) {
961
+ saveMessageKind.current = 'sync';
962
+ publishSaveMessage('success', message);
963
+ writeWebSyncMessageSnapshot(message);
964
+ saveStatusTimeout.current = setTimeout(() => {
965
+ saveMessageKind.current = 'status';
966
+ clearWebSyncMessageSnapshot();
967
+ publishSaveMessage('info', 'Current theme files and preview are in sync.');
968
+ }, 3000);
969
+ }
970
+
971
+ useEffect(() => {
972
+ if (Platform.OS !== 'web') {
973
+ return;
974
+ }
975
+
976
+ const restored = readWebSyncMessageSnapshot();
977
+ if (restored) {
978
+ saveMessageKind.current = 'sync';
979
+ publishEffectMessage('success', restored.message);
980
+ const elapsed = Date.now() - restored.timestamp;
981
+ const remaining = Math.max(0, 3000 - elapsed);
982
+ saveStatusTimeout.current = setTimeout(() => {
983
+ saveMessageKind.current = 'status';
984
+ clearWebSyncMessageSnapshot();
985
+ publishEffectMessage('info', 'Current theme files and preview are in sync.');
986
+ }, remaining);
987
+ }
988
+
989
+ let cancelled = false;
990
+ async function hydrateWritePolicy() {
991
+ try {
992
+ const response = await fetch('/exposition/stylist-sync');
993
+ if (!response.ok) {
994
+ if (!cancelled) {
995
+ setWritePolicyLoaded(true);
996
+ }
997
+ return;
998
+ }
999
+ const payload = (await response.json()) as {
1000
+ hasConfig?: boolean;
1001
+ writePolicy?: WritePolicy;
1002
+ theme?: StylistThemeTokens;
1003
+ mismatchDetected?: boolean;
1004
+ themeSource?: 'theme.json' | 'style.md' | 'default';
1005
+ };
1006
+ if (cancelled) {
1007
+ return;
1008
+ }
1009
+ if (payload.theme) {
1010
+ const nextTheme = withFontFamilyAlias(
1011
+ normalizeThemeTypography(
1012
+ reconcileTheme(payload.theme, 'picker', defaultBgFamilies, defaultBgFamilyShades)
1013
+ )
1014
+ );
1015
+ setTheme((prev) => (areThemesEqual(prev, nextTheme) ? prev : nextTheme));
1016
+ setAppTheme(nextTheme);
1017
+ setExplicitFontRoles({
1018
+ fontDisplay: true,
1019
+ fontTitle: true,
1020
+ fontSubtitle: true,
1021
+ fontBody: true,
1022
+ fontCaption: true,
1023
+ fontMono: true,
1024
+ });
1025
+ }
1026
+ if (payload.hasConfig && payload.writePolicy) {
1027
+ setWritePolicy(payload.writePolicy);
1028
+ }
1029
+ if (payload.themeSource === 'style.md' && payload.mismatchDetected) {
1030
+ if (saveMessageKind.current === 'none') {
1031
+ saveMessageKind.current = 'load';
1032
+ setSaveMessageTone('info');
1033
+ setSaveMessage(
1034
+ 'Detected mismatch between project/style.md managed block and project/theme.json. Loaded project/style.md by startup priority.'
1035
+ );
1036
+ }
1037
+ } else if (payload.themeSource === 'style.md') {
1038
+ if (saveMessageKind.current === 'none') {
1039
+ saveMessageKind.current = 'load';
1040
+ setSaveMessageTone('info');
1041
+ setSaveMessage('Loaded theme from project/style.md managed block.');
1042
+ }
1043
+ }
1044
+ } finally {
1045
+ if (!cancelled) {
1046
+ setWritePolicyLoaded(true);
1047
+ }
1048
+ }
1049
+ }
1050
+
1051
+ void hydrateWritePolicy();
1052
+ return () => {
1053
+ cancelled = true;
1054
+ };
1055
+ }, [setAppTheme]);
1056
+
1057
+ const activeScheme = theme.colorSystem.previewScheme;
1058
+ const basePreviewColors = theme.colors[activeScheme];
1059
+ const baseEditablePalette =
1060
+ theme.colorSystem.mode === 'automatic'
1061
+ ? theme.palettes.automatic[activeScheme]
1062
+ : theme.palettes.bg[activeScheme];
1063
+ const livePickerMatchesActiveView =
1064
+ livePickerPreview?.scheme === activeScheme &&
1065
+ livePickerPreview.colorMode === theme.colorSystem.mode &&
1066
+ livePickerPreview.colorInputMode === colorInputMode;
1067
+ const previewColors = useMemo(() => {
1068
+ if (!livePickerPreview || !livePickerMatchesActiveView) {
1069
+ return basePreviewColors;
1070
+ }
1071
+
1072
+ if (theme.colorSystem.mode === 'automatic') {
1073
+ return ensureDistinctBackgroundSurface(
1074
+ deriveAutomaticPaletteFromPrimary(
1075
+ {
1076
+ ...theme.palettes.automatic[activeScheme],
1077
+ [livePickerPreview.key]: livePickerPreview.hex,
1078
+ },
1079
+ activeScheme
1080
+ ),
1081
+ activeScheme
1082
+ );
1083
+ }
1084
+
1085
+ return ensureDistinctBackgroundSurface(
1086
+ {
1087
+ ...theme.palettes.bg[activeScheme],
1088
+ [livePickerPreview.key]: livePickerPreview.hex,
1089
+ },
1090
+ activeScheme
1091
+ );
1092
+ }, [
1093
+ activeScheme,
1094
+ basePreviewColors,
1095
+ livePickerMatchesActiveView,
1096
+ livePickerPreview,
1097
+ theme.colorSystem.mode,
1098
+ theme.palettes.automatic,
1099
+ theme.palettes.bg,
1100
+ ]);
1101
+ const controlTextColor = ensureReadableTextColor(
1102
+ previewColors.text,
1103
+ previewColors.surface,
1104
+ activeScheme
1105
+ );
1106
+ const editablePalette = useMemo(() => {
1107
+ if (!livePickerPreview || !livePickerMatchesActiveView) {
1108
+ return baseEditablePalette;
1109
+ }
1110
+
1111
+ return {
1112
+ ...baseEditablePalette,
1113
+ [livePickerPreview.key]: livePickerPreview.hex,
1114
+ };
1115
+ }, [baseEditablePalette, livePickerMatchesActiveView, livePickerPreview]);
1116
+ const pickerTargetKey = getPickerTargetKey();
1117
+ const pickerDisplayHex = editablePalette[pickerTargetKey].toLowerCase();
1118
+ const activeFamilyScheme: StylistColorScheme =
1119
+ theme.colorSystem.familyMode === 'one' ? 'light' : activeScheme;
1120
+ const activeFamilies = theme.families[activeFamilyScheme];
1121
+ const activeBgFamilies = bgFamilies[activeFamilyScheme];
1122
+ const activeBgFamilyShades = bgFamilyShades[activeFamilyScheme];
1123
+
1124
+ const previewCard = useMemo(
1125
+ () => ({
1126
+ backgroundColor: previewColors.surface,
1127
+ borderColor: previewColors.primary,
1128
+ borderRadius: theme.layout.radius,
1129
+ borderWidth: 1,
1130
+ padding: theme.layout.spacing.md,
1131
+ gap: theme.layout.spacing.sm,
1132
+ }),
1133
+ [previewColors, theme.layout]
1134
+ );
1135
+
1136
+ const galleryTokens = useMemo(
1137
+ () => ({
1138
+ rowGap: theme.layout.spacing.sm,
1139
+ compactGap: theme.layout.spacing.xs,
1140
+ cardPadding: theme.layout.spacing.md,
1141
+ sectionPadding: theme.layout.spacing.lg,
1142
+ pillPaddingHorizontal: theme.layout.spacing.sm,
1143
+ pillPaddingVertical: Math.max(4, theme.layout.spacing.xs),
1144
+ inputGap: theme.layout.spacing.sm,
1145
+ inputMinHeight: Math.max(42, theme.layout.spacing.xl),
1146
+ radius: theme.layout.radius,
1147
+ }),
1148
+ [theme.layout]
1149
+ );
1150
+
1151
+ const availableFontFamilies = useMemo(() => {
1152
+ const merged = new Map<string, string>();
1153
+ for (const family of [...builtInFontChoices, ...EMBEDDED_GOOGLE_FONTS, ...remoteFonts]) {
1154
+ const normalized = normalizeFontFamilyName(family);
1155
+ if (!normalized) {
1156
+ continue;
1157
+ }
1158
+ const key = normalized.toLowerCase();
1159
+ if (!merged.has(key)) {
1160
+ merged.set(key, normalized);
1161
+ }
1162
+ }
1163
+ return Array.from(merged.values()).sort((a, b) => a.localeCompare(b));
1164
+ }, [remoteFonts]);
1165
+
1166
+ useEffect(() => {
1167
+ setTheme((prev) => {
1168
+ const nextTheme = withFontFamilyAlias(
1169
+ normalizeThemeTypography(reconcileTheme(prev, colorInputMode, bgFamilies, bgFamilyShades))
1170
+ );
1171
+ return areThemesEqual(prev, nextTheme) ? prev : nextTheme;
1172
+ });
1173
+ }, [bgFamilies, bgFamilyShades, colorInputMode]);
1174
+
1175
+ useEffect(() => {
1176
+ const lockedInAutomaticPicker = isLockedAutomaticPickerKey(
1177
+ theme.colorSystem.mode,
1178
+ colorInputMode,
1179
+ selectedColor
1180
+ );
1181
+ if (lockedInAutomaticPicker) {
1182
+ setSelectedColor('primary');
1183
+ }
1184
+ }, [colorInputMode, selectedColor, theme.colorSystem.mode]);
1185
+
1186
+ useEffect(() => {
1187
+ setPickerHexDraft((prev) => (prev === pickerDisplayHex ? prev : pickerDisplayHex));
1188
+ }, [pickerDisplayHex]);
1189
+
1190
+ useEffect(() => {
1191
+ let isMounted = true;
1192
+
1193
+ async function hydrateFontSettings() {
1194
+ try {
1195
+ const [savedKey, dismissed] = await Promise.all([
1196
+ AsyncStorage.getItem(GOOGLE_FONTS_KEY_STORAGE),
1197
+ AsyncStorage.getItem(GOOGLE_FONTS_BANNER_DISMISSED_STORAGE),
1198
+ ]);
1199
+ if (!isMounted) {
1200
+ return;
1201
+ }
1202
+ const nextKey = savedKey?.trim() ?? '';
1203
+ setStoredApiKey(nextKey);
1204
+ setApiKeyDraft(nextKey);
1205
+ setFontBannerDismissed(dismissed === 'true' && !nextKey);
1206
+ } catch {
1207
+ if (isMounted) {
1208
+ setFontBannerDismissed(false);
1209
+ }
1210
+ }
1211
+ }
1212
+
1213
+ void hydrateFontSettings();
1214
+
1215
+ return () => {
1216
+ isMounted = false;
1217
+ };
1218
+ }, []);
1219
+
1220
+ useEffect(() => {
1221
+ if (Platform.OS !== 'web') {
1222
+ return;
1223
+ }
1224
+
1225
+ ensureWebFontLoaded(theme.typography.fontDisplay);
1226
+ ensureWebFontLoaded(theme.typography.fontTitle);
1227
+ ensureWebFontLoaded(theme.typography.fontSubtitle);
1228
+ ensureWebFontLoaded(theme.typography.fontBody);
1229
+ ensureWebFontLoaded(theme.typography.fontCaption);
1230
+ ensureWebFontLoaded(theme.typography.fontMono);
1231
+ }, [
1232
+ theme.typography.fontDisplay,
1233
+ theme.typography.fontTitle,
1234
+ theme.typography.fontSubtitle,
1235
+ theme.typography.fontBody,
1236
+ theme.typography.fontCaption,
1237
+ theme.typography.fontMono,
1238
+ ]);
1239
+
1240
+ useEffect(() => {
1241
+ let cancelled = false;
1242
+
1243
+ async function loadLiveFonts(apiKey: string) {
1244
+ if (!apiKey) {
1245
+ setRemoteFonts([]);
1246
+ setFontFetchError('');
1247
+ return;
1248
+ }
1249
+
1250
+ setLoadingFonts(true);
1251
+ setFontFetchError('');
1252
+ try {
1253
+ const response = await fetch(
1254
+ `${GOOGLE_FONTS_API_URL}?sort=popularity&key=${encodeURIComponent(apiKey)}`
1255
+ );
1256
+ const payload = (await response.json()) as {
1257
+ items?: { family?: string }[];
1258
+ error?: { message?: string };
1259
+ };
1260
+ if (!response.ok) {
1261
+ throw new Error(payload?.error?.message ?? 'Could not load Google Fonts list.');
1262
+ }
1263
+
1264
+ const fetched = (payload.items ?? [])
1265
+ .map((item) => normalizeFontFamilyName(item.family ?? ''))
1266
+ .filter((value) => value.length > 0);
1267
+
1268
+ if (!cancelled) {
1269
+ setRemoteFonts(fetched);
1270
+ setFontFetchError('');
1271
+ }
1272
+ } catch (error) {
1273
+ if (!cancelled) {
1274
+ setRemoteFonts([]);
1275
+ setFontFetchError(
1276
+ error instanceof Error ? error.message : 'Could not load Google Fonts list.'
1277
+ );
1278
+ }
1279
+ } finally {
1280
+ if (!cancelled) {
1281
+ setLoadingFonts(false);
1282
+ }
1283
+ }
1284
+ }
1285
+
1286
+ void loadLiveFonts(storedApiKey);
1287
+
1288
+ return () => {
1289
+ cancelled = true;
1290
+ };
1291
+ }, [storedApiKey, fontRefreshIndex]);
1292
+
1293
+ function updateTheme(mutator: (prev: ExtendedStylistThemeTokens) => ExtendedStylistThemeTokens) {
1294
+ setTheme((prev) =>
1295
+ withFontFamilyAlias(
1296
+ normalizeThemeTypography(
1297
+ reconcileTheme(mutator(prev), colorInputMode, bgFamilies, bgFamilyShades)
1298
+ )
1299
+ )
1300
+ );
1301
+ }
1302
+
1303
+ function updateNumeric(path: string, raw: string) {
1304
+ const value = Number.parseFloat(raw);
1305
+ if (!Number.isFinite(value)) {
1306
+ return;
1307
+ }
1308
+
1309
+ updateTheme((prev) => {
1310
+ if (path === 'displaySize') {
1311
+ return {
1312
+ ...prev,
1313
+ typography: { ...prev.typography, displaySize: value },
1314
+ };
1315
+ }
1316
+ if (path === 'headingSize') {
1317
+ return {
1318
+ ...prev,
1319
+ typography: { ...prev.typography, headingSize: value },
1320
+ };
1321
+ }
1322
+ if (path === 'bodySize') {
1323
+ return { ...prev, typography: { ...prev.typography, bodySize: value } };
1324
+ }
1325
+ if (path === 'captionSize') {
1326
+ return {
1327
+ ...prev,
1328
+ typography: { ...prev.typography, captionSize: value },
1329
+ };
1330
+ }
1331
+ if (path === 'radius') {
1332
+ return { ...prev, layout: { ...prev.layout, radius: value } };
1333
+ }
1334
+
1335
+ return {
1336
+ ...prev,
1337
+ layout: {
1338
+ ...prev.layout,
1339
+ spacing: { ...prev.layout.spacing, [path]: value },
1340
+ },
1341
+ };
1342
+ });
1343
+ }
1344
+
1345
+ function updateColorMode(mode: StylistColorMode) {
1346
+ updateTheme((prev) => ({
1347
+ ...prev,
1348
+ colorSystem: {
1349
+ ...prev.colorSystem,
1350
+ mode,
1351
+ },
1352
+ }));
1353
+ }
1354
+
1355
+ function updatePreviewScheme(scheme: StylistColorScheme) {
1356
+ updateTheme((prev) => ({
1357
+ ...prev,
1358
+ colorSystem: {
1359
+ ...prev.colorSystem,
1360
+ previewScheme: scheme,
1361
+ },
1362
+ }));
1363
+ }
1364
+
1365
+ function updateFamilyMode(mode: StylistFamilyMode) {
1366
+ setBgFamilies((prev) => (mode === 'one' ? { ...prev, dark: { ...prev.light } } : prev));
1367
+ setBgFamilyShades((prev) => (mode === 'one' ? { ...prev, dark: { ...prev.light } } : prev));
1368
+ updateTheme((prev) => ({
1369
+ ...prev,
1370
+ colorSystem: {
1371
+ ...prev.colorSystem,
1372
+ familyMode: mode,
1373
+ },
1374
+ }));
1375
+ }
1376
+
1377
+ function updateFamily(key: SemanticColorKey, family: string) {
1378
+ updateTheme((prev) => {
1379
+ if (prev.colorSystem.familyMode === 'one') {
1380
+ const nextLight = { ...prev.families.light, [key]: family };
1381
+ return {
1382
+ ...prev,
1383
+ families: {
1384
+ light: nextLight,
1385
+ dark: { ...nextLight },
1386
+ },
1387
+ };
1388
+ }
1389
+
1390
+ return {
1391
+ ...prev,
1392
+ families: {
1393
+ ...prev.families,
1394
+ [activeScheme]: {
1395
+ ...prev.families[activeScheme],
1396
+ [key]: family,
1397
+ },
1398
+ },
1399
+ };
1400
+ });
1401
+ }
1402
+
1403
+ function updateBgFamily(key: PaletteColorKey, family: TailwindColorFamily) {
1404
+ setBgFamilies((prev) => {
1405
+ if (theme.colorSystem.familyMode === 'one') {
1406
+ const nextLight = { ...prev.light, [key]: family };
1407
+ return {
1408
+ light: nextLight,
1409
+ dark: { ...nextLight },
1410
+ };
1411
+ }
1412
+
1413
+ return {
1414
+ ...prev,
1415
+ [activeScheme]: {
1416
+ ...prev[activeScheme],
1417
+ [key]: family,
1418
+ },
1419
+ };
1420
+ });
1421
+ }
1422
+
1423
+ function updateBgShade(key: PaletteColorKey, shade: TailwindShade) {
1424
+ setBgFamilyShades((prev) => {
1425
+ if (theme.colorSystem.familyMode === 'one') {
1426
+ const nextLight = { ...prev.light, [key]: shade };
1427
+ return {
1428
+ light: nextLight,
1429
+ dark: { ...nextLight },
1430
+ };
1431
+ }
1432
+
1433
+ return {
1434
+ ...prev,
1435
+ [activeScheme]: {
1436
+ ...prev[activeScheme],
1437
+ [key]: shade,
1438
+ },
1439
+ };
1440
+ });
1441
+ }
1442
+
1443
+ function updateManualColor(key: PaletteColorKey, hex: string) {
1444
+ const targetPalette = theme.colorSystem.mode === 'automatic' ? 'automatic' : 'bg';
1445
+ updateTheme((prev) => ({
1446
+ ...prev,
1447
+ palettes: {
1448
+ ...prev.palettes,
1449
+ [targetPalette]: {
1450
+ ...prev.palettes[targetPalette],
1451
+ ...(prev.colorSystem.familyMode === 'one'
1452
+ ? {
1453
+ light: {
1454
+ ...prev.palettes[targetPalette].light,
1455
+ [key]: hex,
1456
+ },
1457
+ dark: {
1458
+ ...prev.palettes[targetPalette].dark,
1459
+ [key]: hex,
1460
+ },
1461
+ }
1462
+ : {
1463
+ [activeScheme]: {
1464
+ ...prev.palettes[targetPalette][activeScheme],
1465
+ [key]: hex,
1466
+ },
1467
+ }),
1468
+ },
1469
+ },
1470
+ }));
1471
+ }
1472
+
1473
+ function getPickerTargetKey(): PaletteColorKey {
1474
+ return isLockedAutomaticPickerKey(theme.colorSystem.mode, colorInputMode, selectedColor)
1475
+ ? 'primary'
1476
+ : selectedColor;
1477
+ }
1478
+
1479
+ function previewPickerColor(hex: string) {
1480
+ const nextPreview = {
1481
+ colorInputMode,
1482
+ colorMode: theme.colorSystem.mode,
1483
+ hex: hex.toLowerCase(),
1484
+ key: getPickerTargetKey(),
1485
+ scheme: activeScheme,
1486
+ };
1487
+ setLivePickerPreview((prev) => {
1488
+ if (
1489
+ prev?.hex === nextPreview.hex &&
1490
+ prev.key === nextPreview.key &&
1491
+ prev.scheme === nextPreview.scheme &&
1492
+ prev.colorMode === nextPreview.colorMode &&
1493
+ prev.colorInputMode === nextPreview.colorInputMode
1494
+ ) {
1495
+ return prev;
1496
+ }
1497
+
1498
+ return nextPreview;
1499
+ });
1500
+ }
1501
+
1502
+ function applyPickerColor(hex: string) {
1503
+ const targetKey = getPickerTargetKey();
1504
+ const nextHex = hex.toLowerCase();
1505
+ setLivePickerPreview(null);
1506
+ if (baseEditablePalette[targetKey].toLowerCase() === nextHex) {
1507
+ return;
1508
+ }
1509
+
1510
+ updateManualColor(targetKey, nextHex);
1511
+ }
1512
+
1513
+ function handlePickerHexChange(raw: string) {
1514
+ const nextDraft = sanitizeHexDraftInput(raw);
1515
+ setPickerHexDraft(nextDraft);
1516
+ if (!isImmediateApplyHex(nextDraft)) {
1517
+ return;
1518
+ }
1519
+ const nextHex = normalizeHexForTheme(nextDraft);
1520
+ setPickerHexDraft(nextHex);
1521
+ applyPickerColor(nextHex);
1522
+ }
1523
+
1524
+ function commitPickerHexDraft() {
1525
+ if (!isCommitReadyHex(pickerHexDraft)) {
1526
+ setPickerHexDraft(pickerDisplayHex);
1527
+ return;
1528
+ }
1529
+ const nextHex = normalizeHexForTheme(pickerHexDraft);
1530
+ setPickerHexDraft(nextHex);
1531
+ applyPickerColor(nextHex);
1532
+ }
1533
+
1534
+ function updateFontRole(role: FontRoleKey, fontFamily: string) {
1535
+ const normalized = normalizeFontFamilyName(fontFamily);
1536
+ if (!normalized) {
1537
+ return;
1538
+ }
1539
+
1540
+ setExplicitFontRoles((prev) => ({ ...prev, [role]: true }));
1541
+ updateTheme((prev) => ({
1542
+ ...prev,
1543
+ typography: {
1544
+ ...prev.typography,
1545
+ [role]: normalized,
1546
+ fontFamily: role === 'fontDisplay' ? normalized : prev.typography.fontFamily,
1547
+ },
1548
+ }));
1549
+ }
1550
+
1551
+ function applyNotoFonts() {
1552
+ const notoRoles: Record<FontRoleKey, string> = {
1553
+ fontDisplay: 'Noto Sans Display',
1554
+ fontTitle: 'Noto Sans Display',
1555
+ fontSubtitle: 'Noto Serif Display',
1556
+ fontBody: 'Noto Sans',
1557
+ fontCaption: 'Noto Serif',
1558
+ fontMono: 'Noto Sans Mono',
1559
+ };
1560
+
1561
+ setExplicitFontRoles({
1562
+ fontDisplay: true,
1563
+ fontTitle: true,
1564
+ fontSubtitle: true,
1565
+ fontBody: true,
1566
+ fontCaption: true,
1567
+ fontMono: true,
1568
+ });
1569
+ updateTheme((prev) => ({
1570
+ ...prev,
1571
+ typography: {
1572
+ ...prev.typography,
1573
+ ...notoRoles,
1574
+ fontFamily: 'Noto Sans Display',
1575
+ },
1576
+ }));
1577
+ }
1578
+
1579
+ async function saveFontApiSettings() {
1580
+ const nextKey = apiKeyDraft.trim();
1581
+ try {
1582
+ await AsyncStorage.setItem(GOOGLE_FONTS_KEY_STORAGE, nextKey);
1583
+ await AsyncStorage.setItem(
1584
+ GOOGLE_FONTS_BANNER_DISMISSED_STORAGE,
1585
+ nextKey ? 'false' : String(fontBannerDismissed)
1586
+ );
1587
+ setStoredApiKey(nextKey);
1588
+ setFontBannerDismissed(false);
1589
+ publishSaveMessage(
1590
+ nextKey ? 'success' : 'info',
1591
+ nextKey ? 'Google Fonts key saved. Live list is refreshing.' : 'Google Fonts key cleared.'
1592
+ );
1593
+ } catch {
1594
+ publishSaveMessage('error', 'Unable to save Google Fonts API key on this device.');
1595
+ }
1596
+ }
1597
+
1598
+ async function dismissFontBanner() {
1599
+ try {
1600
+ await AsyncStorage.setItem(GOOGLE_FONTS_BANNER_DISMISSED_STORAGE, 'true');
1601
+ } catch {
1602
+ // no-op
1603
+ }
1604
+ setFontBannerDismissed(true);
1605
+ }
1606
+
1607
+ async function saveTheme(policyOverride?: WritePolicy) {
1608
+ const payloadTheme = withFontFamilyAlias(
1609
+ normalizeThemeTypography(reconcileTheme(theme, colorInputMode, bgFamilies, bgFamilyShades))
1610
+ );
1611
+ const resolvedPolicy = policyOverride ?? writePolicy ?? 'managed';
1612
+ pulseSaveButton();
1613
+ setSaving(true);
1614
+ publishSaveMessage('info', 'Saving theme...');
1615
+ try {
1616
+ const response = await fetch('/exposition/stylist-sync', {
1617
+ method: 'POST',
1618
+ headers: { 'content-type': 'application/json' },
1619
+ body: JSON.stringify({
1620
+ theme: payloadTheme,
1621
+ metadata: {
1622
+ writePolicy: resolvedPolicy,
1623
+ },
1624
+ }),
1625
+ });
1626
+ const payload = await response.json();
1627
+ if (!response.ok) {
1628
+ throw new Error(payload?.error ?? 'Stylist sync failed.');
1629
+ }
1630
+ const readBackResponse = await fetch('/exposition/stylist-sync', {
1631
+ method: 'GET',
1632
+ headers: { 'cache-control': 'no-store' },
1633
+ });
1634
+ let canonicalTheme = payloadTheme;
1635
+ if (readBackResponse.ok) {
1636
+ const readBackPayload = (await readBackResponse.json()) as {
1637
+ theme?: StylistThemeTokens;
1638
+ writePolicy?: WritePolicy;
1639
+ };
1640
+ if (readBackPayload.theme) {
1641
+ canonicalTheme = withFontFamilyAlias(
1642
+ normalizeThemeTypography(
1643
+ reconcileTheme(readBackPayload.theme, colorInputMode, bgFamilies, bgFamilyShades)
1644
+ )
1645
+ );
1646
+ setTheme((prev) => (areThemesEqual(prev, canonicalTheme) ? prev : canonicalTheme));
1647
+ setAppTheme(canonicalTheme);
1648
+ setExplicitFontRoles({
1649
+ fontDisplay: true,
1650
+ fontTitle: true,
1651
+ fontSubtitle: true,
1652
+ fontBody: true,
1653
+ fontCaption: true,
1654
+ fontMono: true,
1655
+ });
1656
+ } else {
1657
+ setAppTheme(payloadTheme);
1658
+ }
1659
+ setWritePolicy(readBackPayload.writePolicy ?? payload.writePolicy ?? resolvedPolicy);
1660
+ } else {
1661
+ setWritePolicy(payload.writePolicy ?? resolvedPolicy);
1662
+ setAppTheme(payloadTheme);
1663
+ }
1664
+ setNativeDraft('');
1665
+ publishSyncMessage(`Synced ${payload.updatedFiles?.length ?? 0} files from Stylist.`);
1666
+ } catch (error) {
1667
+ if (Platform.OS !== 'web') {
1668
+ const draft = JSON.stringify(payloadTheme, null, 2);
1669
+ setNativeDraft(draft);
1670
+ setWritePolicy(resolvedPolicy);
1671
+ setAppTheme(payloadTheme);
1672
+ publishSaveMessage(
1673
+ 'success',
1674
+ 'Draft saved in Stylist. Run the sync command from your project root terminal.'
1675
+ );
1676
+ return;
1677
+ }
1678
+ const message = humanizeSaveError(
1679
+ error instanceof Error ? error.message : 'Unknown save error.'
1680
+ );
1681
+ Alert.alert('Stylist save failed', message);
1682
+ publishSaveMessage('error', message);
1683
+ } finally {
1684
+ setSaving(false);
1685
+ }
1686
+ }
1687
+
1688
+ function handleSaveThemePress() {
1689
+ if (saving) {
1690
+ return;
1691
+ }
1692
+
1693
+ if (Platform.OS === 'web' && writePolicyLoaded && !writePolicy) {
1694
+ setShowWritePolicyModal(true);
1695
+ return;
1696
+ }
1697
+
1698
+ void saveTheme();
1699
+ }
1700
+
1701
+ function chooseWritePolicyAndSave(nextPolicy: WritePolicy) {
1702
+ setWritePolicy(nextPolicy);
1703
+ setShowWritePolicyModal(false);
1704
+ void saveTheme(nextPolicy);
1705
+ }
1706
+
1707
+ const nativeSaveCommand = NATIVE_SAVE_COMMAND;
1708
+ const saveMessageColors =
1709
+ saveMessageTone === 'success'
1710
+ ? { backgroundColor: '#dcfce7', borderColor: '#86efac', color: '#166534' }
1711
+ : saveMessageTone === 'error'
1712
+ ? {
1713
+ backgroundColor: '#fee2e2',
1714
+ borderColor: '#fca5a5',
1715
+ color: '#991b1b',
1716
+ }
1717
+ : {
1718
+ backgroundColor: '#dbeafe',
1719
+ borderColor: '#93c5fd',
1720
+ color: '#1e3a8a',
1721
+ };
1722
+
1723
+ return (
1724
+ <View style={styles.root}>
1725
+ <ScrollView
1726
+ contentInsetAdjustmentBehavior="automatic"
1727
+ keyboardShouldPersistTaps="handled"
1728
+ contentContainerStyle={[
1729
+ styles.content,
1730
+ {
1731
+ paddingTop: Platform.OS === 'web' ? 220 : Math.max(insets.top + 132, 160),
1732
+ paddingBottom: Math.max(insets.bottom + 40, 96),
1733
+ },
1734
+ ]}
1735
+ style={[styles.screen, { backgroundColor: previewColors.background }]}
1736
+ >
1737
+ <View style={styles.titleRow}>
1738
+ <Text style={[styles.title, { color: previewColors.text }]}>
1739
+ __MDS_APP_NAME__ Stylist
1740
+ </Text>
1741
+ <Animated.View style={{ transform: [{ scale: saveButtonScale }] }}>
1742
+ <Pressable
1743
+ onPress={handleSaveThemePress}
1744
+ disabled={saving}
1745
+ style={[
1746
+ styles.saveButton,
1747
+ styles.saveButtonInline,
1748
+ { backgroundColor: previewColors.primary },
1749
+ ]}
1750
+ >
1751
+ <Text style={styles.saveButtonText}>{saving ? 'Saving...' : 'Save Theme'}</Text>
1752
+ </Pressable>
1753
+ </Animated.View>
1754
+ </View>
1755
+ <Text style={[styles.intro, { color: previewColors.text }]}>
1756
+ Adjust design tokens - do not forget to hit Save Theme so the changes will be applied to
1757
+ your app color theme files.
1758
+ </Text>
1759
+ {saveMessage ? (
1760
+ <Animated.View
1761
+ key={`save-message-${saveMessageNonce}`}
1762
+ style={[
1763
+ styles.saveMessageBanner,
1764
+ {
1765
+ backgroundColor: saveMessageColors.backgroundColor,
1766
+ borderColor: saveMessageColors.borderColor,
1767
+ opacity: saveBannerOpacity,
1768
+ },
1769
+ ]}
1770
+ >
1771
+ <Text style={[styles.saveMessageText, { color: saveMessageColors.color }]}>
1772
+ {saveMessage}
1773
+ </Text>
1774
+ </Animated.View>
1775
+ ) : null}
1776
+ {!fontBannerDismissed || !storedApiKey ? (
1777
+ <View style={[styles.fontBanner, { borderColor: previewColors.primary }]}>
1778
+ <View style={styles.fontBannerHeader}>
1779
+ <Text style={styles.fontBannerTitle}>Google Fonts API Key (Optional)</Text>
1780
+ <Pressable onPress={dismissFontBanner} style={styles.bannerDismissButton}>
1781
+ <Text style={styles.bannerDismissText}>X</Text>
1782
+ </Pressable>
1783
+ </View>
1784
+ <Text style={styles.fontBannerBody}>
1785
+ Embedded curated fonts are already built in. Add your Google Fonts API key only if you
1786
+ want the live catalog sync. System and common fallback fonts are included too.
1787
+ </Text>
1788
+ <TextInput
1789
+ value={apiKeyDraft}
1790
+ onChangeText={setApiKeyDraft}
1791
+ placeholder="Paste Google Fonts API key"
1792
+ autoCapitalize="none"
1793
+ autoCorrect={false}
1794
+ style={styles.input}
1795
+ />
1796
+ <View style={styles.fontBannerActions}>
1797
+ <Pressable
1798
+ onPress={saveFontApiSettings}
1799
+ style={[styles.bannerAction, { backgroundColor: previewColors.primary }]}
1800
+ >
1801
+ <Text style={styles.bannerActionText}>Save Key</Text>
1802
+ </Pressable>
1803
+ <Pressable
1804
+ onPress={() => setFontRefreshIndex((prev) => prev + 1)}
1805
+ style={[styles.bannerAction, styles.bannerActionGhost]}
1806
+ >
1807
+ <Text style={styles.bannerActionGhostText}>Refresh List</Text>
1808
+ </Pressable>
1809
+ </View>
1810
+ {loadingFonts ? (
1811
+ <Text style={styles.fontBannerBody}>Loading live Google Fonts list...</Text>
1812
+ ) : null}
1813
+ {fontFetchError ? <Text style={styles.fontError}>{fontFetchError}</Text> : null}
1814
+ </View>
1815
+ ) : null}
1816
+ <View
1817
+ style={[
1818
+ styles.section,
1819
+ styles.sectionOverlay,
1820
+ {
1821
+ backgroundColor: previewColors.surface,
1822
+ borderRadius: theme.layout.radius,
1823
+ },
1824
+ ]}
1825
+ >
1826
+ <View style={styles.sectionHeaderRow}>
1827
+ <Text style={[styles.sectionTitle, { color: controlTextColor }]}>Colors</Text>
1828
+ <View style={styles.inlineToggle}>
1829
+ {colorInputModeOptions.map((option) => {
1830
+ const isSelected = colorInputMode === option.value;
1831
+ return (
1832
+ <Pressable
1833
+ key={option.value}
1834
+ onPress={() => setColorInputMode(option.value)}
1835
+ style={[
1836
+ styles.inlineToggleOption,
1837
+ isSelected && styles.inlineToggleOptionSelected,
1838
+ ]}
1839
+ >
1840
+ <Text
1841
+ style={[
1842
+ styles.inlineToggleLabel,
1843
+ { color: isSelected ? '#f8fafc' : previewColors.text },
1844
+ ]}
1845
+ >
1846
+ {option.label}
1847
+ </Text>
1848
+ </Pressable>
1849
+ );
1850
+ })}
1851
+ </View>
1852
+ </View>
1853
+
1854
+ <Text style={[styles.helperText, { color: controlTextColor }]}>
1855
+ {theme.colorSystem.mode === 'automatic'
1856
+ ? 'Automatic mode derives background, surface, and text from the primary color in real time.'
1857
+ : theme.colorSystem.familyMode === 'one'
1858
+ ? 'BG mode lets you set one shared palette for both light and dark previews.'
1859
+ : `BG mode lets you set palette colors directly for the active ${activeScheme} scheme.`}
1860
+ </Text>
1861
+
1862
+ {theme.colorSystem.mode === 'automatic' && colorInputMode === 'picker' ? (
1863
+ <Text style={[styles.helperText, styles.lockedNote, { color: controlTextColor }]}>
1864
+ Background, surface, and text are disabled here because they are derived from the
1865
+ primary color.
1866
+ </Text>
1867
+ ) : null}
1868
+
1869
+ {colorInputMode === 'picker' ? (
1870
+ <View style={styles.colorRow}>
1871
+ {paletteColorKeys.map((key) => {
1872
+ const locked =
1873
+ theme.colorSystem.mode === 'automatic' &&
1874
+ colorInputMode === 'picker' &&
1875
+ automaticLockedKeys.includes(key);
1876
+ const isSelected = selectedColor === key;
1877
+
1878
+ return (
1879
+ <Pressable
1880
+ key={key}
1881
+ disabled={locked}
1882
+ onPress={() => setSelectedColor(key)}
1883
+ style={[
1884
+ styles.colorChip,
1885
+ {
1886
+ backgroundColor: editablePalette[key],
1887
+ borderColor: isSelected ? previewColors.text : '#9ca3af',
1888
+ opacity: locked ? 0.45 : 1,
1889
+ },
1890
+ ]}
1891
+ >
1892
+ <Text
1893
+ style={[
1894
+ styles.colorChipLabel,
1895
+ {
1896
+ color: getReadableChipTextColor(editablePalette[key]),
1897
+ },
1898
+ ]}
1899
+ >
1900
+ {key}
1901
+ </Text>
1902
+ </Pressable>
1903
+ );
1904
+ })}
1905
+ </View>
1906
+ ) : null}
1907
+
1908
+ {colorInputMode === 'picker' ? (
1909
+ <>
1910
+ <ColorPicker
1911
+ value={baseEditablePalette[pickerTargetKey]}
1912
+ onChangeJS={({ hex }: { hex: string }) => {
1913
+ previewPickerColor(hex);
1914
+ }}
1915
+ onCompleteJS={({ hex }: { hex: string }) => {
1916
+ applyPickerColor(hex);
1917
+ }}
1918
+ style={styles.picker}
1919
+ >
1920
+ <Preview hideInitialColor />
1921
+ <Panel1 />
1922
+ <HueSlider />
1923
+ </ColorPicker>
1924
+ <View style={styles.pickerHexRow}>
1925
+ <Text style={styles.pickerHexLabel}>Hex</Text>
1926
+ <TextInput
1927
+ value={pickerHexDraft}
1928
+ onChangeText={handlePickerHexChange}
1929
+ onBlur={commitPickerHexDraft}
1930
+ onSubmitEditing={commitPickerHexDraft}
1931
+ autoCorrect={false}
1932
+ autoCapitalize="none"
1933
+ maxLength={7}
1934
+ style={[
1935
+ styles.input,
1936
+ styles.pickerHexInput,
1937
+ { color: '#111827', borderColor: previewColors.primary },
1938
+ ]}
1939
+ placeholder="#rrggbb"
1940
+ placeholderTextColor="#6b7280"
1941
+ />
1942
+ </View>
1943
+ <View style={styles.manualSwatchesRow}>
1944
+ {pickerSwatches.map((swatch) => (
1945
+ <Pressable
1946
+ key={swatch}
1947
+ onPress={() => applyPickerColor(swatch)}
1948
+ style={[
1949
+ styles.manualSwatch,
1950
+ { backgroundColor: swatch },
1951
+ editablePalette[selectedColor].toLowerCase() === swatch
1952
+ ? styles.manualSwatchSelected
1953
+ : null,
1954
+ ]}
1955
+ />
1956
+ ))}
1957
+ </View>
1958
+ </>
1959
+ ) : (
1960
+ <View style={styles.familyBlock}>
1961
+ {(theme.colorSystem.mode === 'automatic' ? semanticColorKeys : paletteColorKeys).map(
1962
+ (key) => {
1963
+ const familyValue =
1964
+ theme.colorSystem.mode === 'automatic'
1965
+ ? activeFamilies[key as SemanticColorKey]
1966
+ : activeBgFamilies[key as PaletteColorKey];
1967
+ const shadeValue =
1968
+ theme.colorSystem.mode === 'automatic'
1969
+ ? null
1970
+ : activeBgFamilyShades[key as PaletteColorKey];
1971
+ const familyChipColor =
1972
+ theme.colorSystem.mode === 'automatic'
1973
+ ? previewColors[key as SemanticColorKey]
1974
+ : editablePalette[key as PaletteColorKey];
1975
+
1976
+ return (
1977
+ <View key={key} style={styles.familyRow}>
1978
+ <View style={styles.familyHeader}>
1979
+ <View
1980
+ style={[
1981
+ styles.familyTitleChip,
1982
+ {
1983
+ backgroundColor: familyChipColor,
1984
+ borderColor: previewColors.text,
1985
+ },
1986
+ ]}
1987
+ >
1988
+ <Text
1989
+ style={[
1990
+ styles.familyTitleChipText,
1991
+ {
1992
+ color: getReadableChipTextColor(familyChipColor),
1993
+ },
1994
+ ]}
1995
+ >
1996
+ {key}
1997
+ </Text>
1998
+ </View>
1999
+ {theme.colorSystem.familyMode === 'two' ? (
2000
+ <Text style={[styles.familySchemeHint, { color: previewColors.text }]}>
2001
+ ({activeFamilyScheme})
2002
+ </Text>
2003
+ ) : null}
2004
+ </View>
2005
+ <View style={styles.familyOptions}>
2006
+ {tailwindFamilies.map((family) => (
2007
+ <Pressable
2008
+ key={`${key}-${family}`}
2009
+ onPress={() => {
2010
+ if (theme.colorSystem.mode === 'automatic') {
2011
+ updateFamily(key as SemanticColorKey, family);
2012
+ } else {
2013
+ updateBgFamily(key as PaletteColorKey, family);
2014
+ }
2015
+ }}
2016
+ style={[
2017
+ styles.familyOption,
2018
+ familyValue === family && {
2019
+ backgroundColor: previewColors.primary,
2020
+ borderColor: previewColors.primary,
2021
+ },
2022
+ ]}
2023
+ >
2024
+ <Text
2025
+ style={[
2026
+ styles.familyOptionText,
2027
+ {
2028
+ color: familyValue === family ? '#f8fafc' : previewColors.text,
2029
+ },
2030
+ ]}
2031
+ >
2032
+ {family}
2033
+ </Text>
2034
+ </Pressable>
2035
+ ))}
2036
+ </View>
2037
+ {theme.colorSystem.mode === 'bg' ? (
2038
+ <View style={styles.shadeRow}>
2039
+ {shadeOptions.map((shade) => {
2040
+ const shadeColor = getTailwindColor(familyValue, shade);
2041
+ const isSelectedShade = shadeValue === shade;
2042
+ return (
2043
+ <Pressable
2044
+ key={`${key}-${shade}`}
2045
+ onPress={() => updateBgShade(key as PaletteColorKey, shade)}
2046
+ style={[
2047
+ styles.shadeDot,
2048
+ {
2049
+ backgroundColor: shadeColor,
2050
+ borderColor: isSelectedShade ? previewColors.text : '#cbd5e1',
2051
+ borderWidth: isSelectedShade ? 3 : 1,
2052
+ },
2053
+ ]}
2054
+ accessibilityLabel={`${key} shade ${shade}`}
2055
+ />
2056
+ );
2057
+ })}
2058
+ </View>
2059
+ ) : null}
2060
+ </View>
2061
+ );
2062
+ }
2063
+ )}
2064
+ </View>
2065
+ )}
2066
+ </View>
2067
+
2068
+ <View style={previewCard}>
2069
+ <Text
2070
+ style={{
2071
+ color: previewColors.text,
2072
+ fontFamily: resolvePreviewFontFamily(theme.typography.fontDisplay),
2073
+ fontSize: theme.typography.displaySize,
2074
+ fontWeight: resolvePreviewFontWeight(theme.typography.fontDisplay, '900'),
2075
+ }}
2076
+ >
2077
+ Display headline
2078
+ </Text>
2079
+ <Text
2080
+ style={{
2081
+ color: previewColors.text,
2082
+ fontFamily: resolvePreviewFontFamily(theme.typography.fontTitle),
2083
+ fontSize: theme.typography.headingSize,
2084
+ fontWeight: resolvePreviewFontWeight(theme.typography.fontTitle, '800'),
2085
+ }}
2086
+ >
2087
+ Section heading
2088
+ </Text>
2089
+ <Text
2090
+ style={{
2091
+ color: previewColors.text,
2092
+ fontFamily: resolvePreviewFontFamily(theme.typography.fontSubtitle),
2093
+ fontSize: theme.typography.bodySize + 1,
2094
+ fontWeight: resolvePreviewFontWeight(theme.typography.fontSubtitle, '600'),
2095
+ }}
2096
+ >
2097
+ Subtitle copy for card sections and hero support text.
2098
+ </Text>
2099
+ <Text
2100
+ style={{
2101
+ color: previewColors.text,
2102
+ fontFamily: resolvePreviewFontFamily(theme.typography.fontBody),
2103
+ fontSize: theme.typography.bodySize,
2104
+ }}
2105
+ >
2106
+ Readable body copy for product screens, onboarding, settings, and forms.
2107
+ </Text>
2108
+ <Text
2109
+ style={{
2110
+ color: previewColors.text,
2111
+ fontFamily: resolvePreviewFontFamily(theme.typography.fontCaption),
2112
+ fontSize: theme.typography.captionSize,
2113
+ textTransform: 'uppercase',
2114
+ }}
2115
+ >
2116
+ Caption and metadata text
2117
+ </Text>
2118
+ <Text
2119
+ style={{
2120
+ color: previewColors.text,
2121
+ fontFamily: resolvePreviewFontFamily(theme.typography.fontMono),
2122
+ fontSize: 11,
2123
+ textTransform: 'uppercase',
2124
+ opacity: 0.82,
2125
+ }}
2126
+ >
2127
+ Monospaced sample
2128
+ </Text>
2129
+ <Text
2130
+ style={{
2131
+ color: previewColors.text,
2132
+ fontFamily: resolvePreviewFontFamily(theme.typography.fontMono),
2133
+ fontSize: 12,
2134
+ backgroundColor: previewColors.background,
2135
+ padding: 8,
2136
+ borderRadius: 8,
2137
+ }}
2138
+ >
2139
+ {'const typography = "monospaced";'}
2140
+ </Text>
2141
+ <AnimatedPressable
2142
+ label="Call to Action"
2143
+ backgroundColor={previewColors.secondary}
2144
+ textColor={previewColors.text}
2145
+ />
2146
+ </View>
2147
+
2148
+ <View
2149
+ style={[
2150
+ styles.section,
2151
+ styles.typographySection,
2152
+ {
2153
+ backgroundColor: previewColors.surface,
2154
+ borderRadius: theme.layout.radius,
2155
+ },
2156
+ ]}
2157
+ >
2158
+ <View style={styles.sectionHeaderRow}>
2159
+ <Text style={[styles.sectionTitle, { color: controlTextColor }]}>Typography</Text>
2160
+ <Pressable
2161
+ onPress={applyNotoFonts}
2162
+ style={[styles.presetButton, { borderColor: controlTextColor }]}
2163
+ >
2164
+ <Text style={[styles.presetButtonText, { color: controlTextColor }]}>Noto</Text>
2165
+ </Pressable>
2166
+ </View>
2167
+ <Text style={[styles.helperText, styles.lockedNote, { color: controlTextColor }]}>
2168
+ Search and choose a font per role. Display also syncs `fontFamily` for compatibility.
2169
+ </Text>
2170
+ <View style={[styles.grid, styles.fontPickerGrid]}>
2171
+ {fontRoleFields.map((field) => (
2172
+ <FontFamilyCombobox
2173
+ key={field.key}
2174
+ grid
2175
+ label={field.label}
2176
+ labelColor={controlTextColor}
2177
+ value={explicitFontRoles[field.key] ? theme.typography[field.key] : ''}
2178
+ placeholder=""
2179
+ options={availableFontFamilies}
2180
+ onSelect={(fontFamily) => updateFontRole(field.key, fontFamily)}
2181
+ />
2182
+ ))}
2183
+ </View>
2184
+ {Platform.OS !== 'web' &&
2185
+ fontRoleFields.some((field) => isNativeUnsafeFont(theme.typography[field.key])) ? (
2186
+ <Text style={[styles.helperText, styles.lockedNote, { color: controlTextColor }]}>
2187
+ Custom fonts apply on native after you press Save Theme and reload. Until the sync
2188
+ downloads the font assets, previews may render with System.
2189
+ </Text>
2190
+ ) : null}
2191
+ <View style={[styles.grid, styles.tokenSizeGrid]}>
2192
+ <NumberField
2193
+ grid
2194
+ label="Display"
2195
+ labelColor={controlTextColor}
2196
+ value={theme.typography.displaySize}
2197
+ onChange={(value) => updateNumeric('displaySize', value)}
2198
+ />
2199
+ <NumberField
2200
+ grid
2201
+ label="Heading"
2202
+ labelColor={controlTextColor}
2203
+ value={theme.typography.headingSize}
2204
+ onChange={(value) => updateNumeric('headingSize', value)}
2205
+ />
2206
+ <NumberField
2207
+ grid
2208
+ label="Body"
2209
+ labelColor={controlTextColor}
2210
+ value={theme.typography.bodySize}
2211
+ onChange={(value) => updateNumeric('bodySize', value)}
2212
+ />
2213
+ <NumberField
2214
+ grid
2215
+ label="Caption"
2216
+ labelColor={controlTextColor}
2217
+ value={theme.typography.captionSize}
2218
+ onChange={(value) => updateNumeric('captionSize', value)}
2219
+ />
2220
+ </View>
2221
+ </View>
2222
+
2223
+ <View
2224
+ style={[
2225
+ styles.section,
2226
+ {
2227
+ backgroundColor: previewColors.surface,
2228
+ borderRadius: theme.layout.radius,
2229
+ padding: galleryTokens.sectionPadding,
2230
+ },
2231
+ ]}
2232
+ >
2233
+ <Text style={[styles.sectionTitle, { color: controlTextColor }]}>Component Gallery</Text>
2234
+ <Text style={[styles.helperText, { color: controlTextColor }]}>
2235
+ Token previews using cards, status views, input states, and spacing rhythm. These are
2236
+ just example components.
2237
+ </Text>
2238
+ <View style={[styles.galleryRow, { gap: galleryTokens.rowGap }]}>
2239
+ <View
2240
+ style={[
2241
+ styles.galleryCard,
2242
+ {
2243
+ borderColor: previewColors.primary,
2244
+ backgroundColor: previewColors.background,
2245
+ borderRadius: galleryTokens.radius,
2246
+ gap: galleryTokens.compactGap,
2247
+ padding: galleryTokens.cardPadding,
2248
+ },
2249
+ ]}
2250
+ >
2251
+ <Text style={[styles.galleryCardTitle, { color: previewColors.text }]}>
2252
+ Default Card
2253
+ </Text>
2254
+ <Text style={[styles.galleryCardBody, { color: previewColors.text }]}>
2255
+ Solid surface card for primary content blocks.
2256
+ </Text>
2257
+ </View>
2258
+ <View
2259
+ style={[
2260
+ styles.galleryCard,
2261
+ styles.galleryCardSoft,
2262
+ {
2263
+ borderColor: previewColors.secondary,
2264
+ borderRadius: galleryTokens.radius,
2265
+ gap: galleryTokens.compactGap,
2266
+ padding: galleryTokens.cardPadding,
2267
+ },
2268
+ ]}
2269
+ >
2270
+ <Text style={[styles.galleryCardTitle, { color: previewColors.text }]}>
2271
+ Soft Card
2272
+ </Text>
2273
+ <Text style={[styles.galleryCardBody, { color: previewColors.text }]}>
2274
+ Layered panel with lower contrast for secondary content.
2275
+ </Text>
2276
+ </View>
2277
+ </View>
2278
+ <View style={[styles.statusRow, { gap: galleryTokens.compactGap }]}>
2279
+ {[
2280
+ { label: 'Success', color: previewColors.success },
2281
+ { label: 'Warning', color: previewColors.warning },
2282
+ { label: 'Primary', color: previewColors.primary },
2283
+ { label: 'Secondary', color: previewColors.secondary },
2284
+ ].map((status) => (
2285
+ <View
2286
+ key={status.label}
2287
+ style={[
2288
+ styles.statusPill,
2289
+ {
2290
+ backgroundColor: status.color,
2291
+ borderRadius: galleryTokens.radius,
2292
+ paddingHorizontal: galleryTokens.pillPaddingHorizontal,
2293
+ paddingVertical: galleryTokens.pillPaddingVertical,
2294
+ },
2295
+ ]}
2296
+ >
2297
+ <Text style={styles.statusPillText}>{status.label}</Text>
2298
+ </View>
2299
+ ))}
2300
+ </View>
2301
+ <View style={[styles.inputStatesRow, { gap: galleryTokens.inputGap }]}>
2302
+ <TextInput
2303
+ style={[
2304
+ styles.input,
2305
+ {
2306
+ borderRadius: galleryTokens.radius,
2307
+ minHeight: galleryTokens.inputMinHeight,
2308
+ paddingHorizontal: galleryTokens.cardPadding,
2309
+ },
2310
+ ]}
2311
+ placeholder="Default input"
2312
+ />
2313
+ <TextInput
2314
+ style={[
2315
+ styles.input,
2316
+ {
2317
+ borderColor: previewColors.warning,
2318
+ borderRadius: galleryTokens.radius,
2319
+ minHeight: galleryTokens.inputMinHeight,
2320
+ paddingHorizontal: galleryTokens.cardPadding,
2321
+ },
2322
+ ]}
2323
+ placeholder="Warning state input"
2324
+ />
2325
+ <TextInput
2326
+ style={[
2327
+ styles.input,
2328
+ {
2329
+ borderRadius: galleryTokens.radius,
2330
+ minHeight: galleryTokens.inputMinHeight,
2331
+ opacity: 0.6,
2332
+ paddingHorizontal: galleryTokens.cardPadding,
2333
+ },
2334
+ ]}
2335
+ placeholder="Disabled input"
2336
+ editable={false}
2337
+ />
2338
+ </View>
2339
+ </View>
2340
+
2341
+ <View
2342
+ style={[
2343
+ styles.section,
2344
+ {
2345
+ backgroundColor: previewColors.surface,
2346
+ borderRadius: theme.layout.radius,
2347
+ },
2348
+ ]}
2349
+ >
2350
+ <Text style={[styles.sectionTitle, { color: controlTextColor }]}>Layout Tokens</Text>
2351
+ <Text style={[styles.helperText, styles.lockedNote, { color: controlTextColor }]}>
2352
+ Hit enter or click out of the text box to take effect.
2353
+ </Text>
2354
+ <NumberField
2355
+ label="Corner Radius"
2356
+ labelColor={controlTextColor}
2357
+ value={theme.layout.radius}
2358
+ onChange={(value) => updateNumeric('radius', value)}
2359
+ />
2360
+ <View style={styles.grid}>
2361
+ {spacingKeys.map((key) => (
2362
+ <NumberField
2363
+ grid
2364
+ key={key}
2365
+ label={`Spacing ${key}`}
2366
+ labelColor={controlTextColor}
2367
+ value={theme.layout.spacing[key]}
2368
+ onChange={(value) => updateNumeric(key, value)}
2369
+ />
2370
+ ))}
2371
+ </View>
2372
+ <Text style={[styles.helperText, styles.lockedNote, { color: controlTextColor }]}>
2373
+ Bar width mirrors each spacing token so scale jumps are easy to spot.
2374
+ </Text>
2375
+ <View style={styles.spacingPreview}>
2376
+ {spacingKeys.map((key) => (
2377
+ <View key={`preview-${key}`} style={styles.spacingPreviewRow}>
2378
+ <Text style={[styles.spacingLabel, { color: controlTextColor }]}>{key}</Text>
2379
+ <View
2380
+ style={[
2381
+ styles.spacingBar,
2382
+ {
2383
+ width: Math.max(10, theme.layout.spacing[key] * 3),
2384
+ backgroundColor: previewColors.primary,
2385
+ borderRadius: Math.max(4, theme.layout.radius / 2),
2386
+ },
2387
+ ]}
2388
+ />
2389
+ </View>
2390
+ ))}
2391
+ </View>
2392
+ <Text style={[styles.helperText, styles.lockedNote, { color: controlTextColor }]}>
2393
+ Practical preview: XS controls list gaps, SM pads the first 3 items, and MD pads the
2394
+ list container.
2395
+ </Text>
2396
+ <View
2397
+ style={[
2398
+ styles.layoutPreviewContainer,
2399
+ {
2400
+ borderColor: previewColors.primary,
2401
+ borderRadius: theme.layout.radius,
2402
+ padding: theme.layout.spacing.md,
2403
+ gap: theme.layout.spacing.xs,
2404
+ },
2405
+ ]}
2406
+ >
2407
+ {spacingKeys.map((spacingKey, index) => (
2408
+ <View
2409
+ key={`layout-preview-${spacingKey}`}
2410
+ style={[
2411
+ styles.layoutPreviewCard,
2412
+ {
2413
+ borderRadius: Math.max(6, theme.layout.radius - 2),
2414
+ padding: index < 3 ? theme.layout.spacing.sm : theme.layout.spacing[spacingKey],
2415
+ borderColor: previewColors.secondary,
2416
+ backgroundColor: previewColors.background,
2417
+ },
2418
+ ]}
2419
+ >
2420
+ <Text style={[styles.layoutPreviewTitle, { color: previewColors.text }]}>
2421
+ {spacingKey.toUpperCase()} -{' '}
2422
+ {spacingKey === 'xs'
2423
+ ? 'Extra Small'
2424
+ : spacingKey === 'sm'
2425
+ ? 'Small'
2426
+ : spacingKey === 'md'
2427
+ ? 'Medium'
2428
+ : spacingKey === 'lg'
2429
+ ? 'Large'
2430
+ : 'Extra Large'}
2431
+ </Text>
2432
+ <Text style={[styles.layoutPreviewBody, { color: previewColors.text }]}>
2433
+ {spacingKey === 'xs'
2434
+ ? 'Used for the gap between these list items.'
2435
+ : spacingKey === 'sm'
2436
+ ? 'Used for the padding for the first 3 list items.'
2437
+ : spacingKey === 'md'
2438
+ ? 'Used for the padding around the list.'
2439
+ : spacingKey === 'lg'
2440
+ ? "Reserved for roomier layout sections (this list item's padding)."
2441
+ : "Reserved for extra-roomy layout sections (this list item's padding)."}
2442
+ </Text>
2443
+ </View>
2444
+ ))}
2445
+ </View>
2446
+ </View>
2447
+
2448
+ <Animated.View style={{ transform: [{ scale: saveButtonScale }] }}>
2449
+ <Pressable
2450
+ onPress={handleSaveThemePress}
2451
+ disabled={saving}
2452
+ style={[styles.saveButton, { backgroundColor: previewColors.primary }]}
2453
+ >
2454
+ <Text style={styles.saveButtonText}>{saving ? 'Saving...' : 'Save Theme'}</Text>
2455
+ </Pressable>
2456
+ </Animated.View>
2457
+
2458
+ {saveMessage ? (
2459
+ <Animated.View
2460
+ key={`save-message-bottom-${saveMessageNonce}`}
2461
+ style={[
2462
+ styles.saveMessageBanner,
2463
+ {
2464
+ backgroundColor: saveMessageColors.backgroundColor,
2465
+ borderColor: saveMessageColors.borderColor,
2466
+ opacity: saveBannerOpacity,
2467
+ },
2468
+ ]}
2469
+ >
2470
+ <Text style={[styles.saveMessageText, { color: saveMessageColors.color }]}>
2471
+ {saveMessage}
2472
+ </Text>
2473
+ </Animated.View>
2474
+ ) : null}
2475
+ {Platform.OS !== 'web' ? (
2476
+ <View style={styles.nativeHelp}>
2477
+ <Text style={styles.nativeTitle}>Native fallback</Text>
2478
+ <Text style={styles.nativeBody}>Run this command in your app root terminal:</Text>
2479
+ <Text style={styles.command}>{nativeSaveCommand}</Text>
2480
+ {nativeDraft ? <Text style={styles.payload}>{nativeDraft}</Text> : null}
2481
+ </View>
2482
+ ) : null}
2483
+ </ScrollView>
2484
+
2485
+ <Modal
2486
+ transparent
2487
+ animationType="fade"
2488
+ visible={showWritePolicyModal}
2489
+ onRequestClose={() => setShowWritePolicyModal(false)}
2490
+ >
2491
+ <View style={styles.modalBackdrop}>
2492
+ <View style={styles.modalCard}>
2493
+ <Text style={styles.modalTitle}>Choose Save Behavior</Text>
2494
+ <Text style={styles.modalBody}>
2495
+ Managed updates only Stylist-owned token blocks. Overwrite regenerates the full
2496
+ style-library target file.
2497
+ </Text>
2498
+ <View style={styles.modalActions}>
2499
+ <Pressable
2500
+ onPress={() => chooseWritePolicyAndSave('managed')}
2501
+ style={[styles.modalButton, styles.modalButtonPrimary]}
2502
+ >
2503
+ <Text style={styles.modalButtonPrimaryText}>Managed (Recommended)</Text>
2504
+ </Pressable>
2505
+ <Pressable
2506
+ onPress={() => chooseWritePolicyAndSave('overwrite')}
2507
+ style={[styles.modalButton, styles.modalButtonSecondary]}
2508
+ >
2509
+ <Text style={styles.modalButtonSecondaryText}>Overwrite Full File</Text>
2510
+ </Pressable>
2511
+ </View>
2512
+ </View>
2513
+ </View>
2514
+ </Modal>
2515
+
2516
+ <View
2517
+ style={[
2518
+ styles.controls,
2519
+ {
2520
+ backgroundColor: previewColors.surface,
2521
+ borderColor: previewColors.primary,
2522
+ top: Platform.OS === 'web' ? 76 : Math.max(insets.top + 8, 14),
2523
+ },
2524
+ ]}
2525
+ >
2526
+ <ToggleRow
2527
+ label="Color Mode"
2528
+ options={colorModeOptions}
2529
+ value={theme.colorSystem.mode}
2530
+ onChange={(value) => updateColorMode(value as StylistColorMode)}
2531
+ infoText={topToggleHelpCopy.colorMode.body}
2532
+ onPressInfo={() => setActiveTopToggleHelp('colorMode')}
2533
+ />
2534
+ <ToggleRow
2535
+ label="Preview"
2536
+ options={schemeKeys.map((scheme) => ({
2537
+ label: scheme,
2538
+ value: scheme,
2539
+ }))}
2540
+ value={theme.colorSystem.previewScheme}
2541
+ onChange={(value) => updatePreviewScheme(value as StylistColorScheme)}
2542
+ infoText={topToggleHelpCopy.preview.body}
2543
+ onPressInfo={() => setActiveTopToggleHelp('preview')}
2544
+ />
2545
+ <ToggleRow
2546
+ label="Family Strategy"
2547
+ options={familyModeOptions}
2548
+ value={theme.colorSystem.familyMode}
2549
+ onChange={(value) => updateFamilyMode(value as StylistFamilyMode)}
2550
+ infoText={topToggleHelpCopy.familyStrategy.body}
2551
+ onPressInfo={() => setActiveTopToggleHelp('familyStrategy')}
2552
+ />
2553
+ </View>
2554
+
2555
+ <Modal
2556
+ transparent
2557
+ animationType="fade"
2558
+ visible={Boolean(activeTopToggleHelp)}
2559
+ onRequestClose={() => setActiveTopToggleHelp(null)}
2560
+ >
2561
+ <View style={styles.modalBackdrop}>
2562
+ <View style={styles.modalCard}>
2563
+ <Text style={styles.modalTitle}>
2564
+ {activeTopToggleHelp ? topToggleHelpCopy[activeTopToggleHelp].title : 'Toggle Help'}
2565
+ </Text>
2566
+ <Text style={styles.modalBody}>
2567
+ {activeTopToggleHelp
2568
+ ? topToggleHelpCopy[activeTopToggleHelp].body
2569
+ : 'No toggle help selected.'}
2570
+ </Text>
2571
+ <View style={styles.modalActions}>
2572
+ <Pressable
2573
+ onPress={() => setActiveTopToggleHelp(null)}
2574
+ style={[styles.modalButton, styles.modalButtonPrimary]}
2575
+ >
2576
+ <Text style={styles.modalButtonPrimaryText}>Close</Text>
2577
+ </Pressable>
2578
+ </View>
2579
+ </View>
2580
+ </View>
2581
+ </Modal>
2582
+ </View>
2583
+ );
2584
+ }
2585
+
2586
+ function ToggleRow(props: {
2587
+ label: string;
2588
+ infoText: string;
2589
+ value: string;
2590
+ options: { label: string; value: string }[];
2591
+ onChange: (value: string) => void;
2592
+ onPressInfo: () => void;
2593
+ }) {
2594
+ return (
2595
+ <View style={styles.toggleRow}>
2596
+ <View style={styles.toggleLabelRow}>
2597
+ <Text style={styles.toggleLabel}>{props.label}</Text>
2598
+ <Pressable
2599
+ accessibilityRole="button"
2600
+ accessibilityLabel={`${props.label} explanation`}
2601
+ accessibilityHint={props.infoText}
2602
+ onPress={props.onPressInfo}
2603
+ style={styles.toggleInfoButton}
2604
+ >
2605
+ <Text style={styles.toggleInfoButtonText}>i</Text>
2606
+ </Pressable>
2607
+ </View>
2608
+ <View style={styles.toggleOptions}>
2609
+ {props.options.map((option) => {
2610
+ const isSelected = props.value === option.value;
2611
+ return (
2612
+ <Pressable
2613
+ key={option.value}
2614
+ onPress={() => props.onChange(option.value)}
2615
+ style={[styles.toggleOption, isSelected && styles.toggleOptionSelected]}
2616
+ >
2617
+ <Text
2618
+ style={[styles.toggleOptionText, isSelected && styles.toggleOptionTextSelected]}
2619
+ >
2620
+ {option.label}
2621
+ </Text>
2622
+ </Pressable>
2623
+ );
2624
+ })}
2625
+ </View>
2626
+ </View>
2627
+ );
2628
+ }
2629
+
2630
+ function NumberField(props: {
2631
+ label: string;
2632
+ value: number;
2633
+ onChange: (value: string) => void;
2634
+ grid?: boolean;
2635
+ labelColor?: string;
2636
+ }) {
2637
+ const [draft, setDraft] = useState(String(props.value));
2638
+
2639
+ return (
2640
+ <View style={[styles.field, props.grid ? styles.gridField : styles.fullField]}>
2641
+ <Text style={[styles.fieldLabel, props.labelColor ? { color: props.labelColor } : null]}>
2642
+ {props.label}
2643
+ </Text>
2644
+ <TextInput
2645
+ value={draft}
2646
+ onChangeText={setDraft}
2647
+ onBlur={() => {
2648
+ props.onChange(draft);
2649
+ const parsed = Number.parseFloat(draft);
2650
+ setDraft(Number.isFinite(parsed) ? String(parsed) : String(props.value));
2651
+ }}
2652
+ keyboardType="numeric"
2653
+ style={styles.input}
2654
+ />
2655
+ </View>
2656
+ );
2657
+ }
2658
+
2659
+ function FontFamilyCombobox(props: {
2660
+ label: string;
2661
+ labelColor?: string;
2662
+ value: string;
2663
+ placeholder: string;
2664
+ options: string[];
2665
+ onSelect: (value: string) => void;
2666
+ grid?: boolean;
2667
+ }) {
2668
+ const [query, setQuery] = useState(props.value);
2669
+ const [isOpen, setIsOpen] = useState(false);
2670
+
2671
+ useEffect(() => {
2672
+ setQuery((prev) => (prev === props.value ? prev : props.value));
2673
+ }, [props.value]);
2674
+
2675
+ const filteredOptions = useMemo(() => {
2676
+ const q = query.trim().toLowerCase();
2677
+ if (!q) {
2678
+ return props.options.filter((option) => option.toLowerCase() !== 'system');
2679
+ }
2680
+ return props.options.filter((option) => option.toLowerCase().includes(q));
2681
+ }, [props.options, query]);
2682
+
2683
+ useEffect(() => {
2684
+ if (!isOpen || Platform.OS !== 'web') {
2685
+ return;
2686
+ }
2687
+
2688
+ for (const option of filteredOptions.slice(0, 80)) {
2689
+ ensureWebFontLoaded(option);
2690
+ }
2691
+ }, [filteredOptions, isOpen]);
2692
+
2693
+ function commitValue(next: string) {
2694
+ const normalized = normalizeFontFamilyName(next);
2695
+ if (!normalized) {
2696
+ setQuery(props.value);
2697
+ setIsOpen(false);
2698
+ return;
2699
+ }
2700
+ props.onSelect(normalized);
2701
+ setQuery(normalized);
2702
+ setIsOpen(false);
2703
+ }
2704
+
2705
+ function openDropdown() {
2706
+ setIsOpen(true);
2707
+ }
2708
+
2709
+ return (
2710
+ <View
2711
+ style={[
2712
+ styles.field,
2713
+ props.grid ? styles.gridField : styles.fullField,
2714
+ isOpen ? styles.fieldOverlayOpen : null,
2715
+ ]}
2716
+ >
2717
+ <Text style={[styles.fieldLabel, props.labelColor ? { color: props.labelColor } : null]}>
2718
+ {props.label}
2719
+ </Text>
2720
+ <View style={styles.comboboxWrap}>
2721
+ <View style={styles.inputWithAction}>
2722
+ <TextInput
2723
+ value={query}
2724
+ onChangeText={(value) => {
2725
+ setQuery(value);
2726
+ if (!isOpen) {
2727
+ openDropdown();
2728
+ }
2729
+ }}
2730
+ onFocus={() => {
2731
+ openDropdown();
2732
+ }}
2733
+ onSubmitEditing={() => commitValue(query)}
2734
+ blurOnSubmit
2735
+ returnKeyType="done"
2736
+ placeholder={props.placeholder}
2737
+ autoCorrect={false}
2738
+ autoCapitalize="none"
2739
+ style={[styles.input, styles.inputWithTrailingAction]}
2740
+ />
2741
+ {query.trim().length > 0 ? (
2742
+ <Pressable
2743
+ accessibilityRole="button"
2744
+ accessibilityLabel={`Clear ${props.label} font`}
2745
+ onPress={() => {
2746
+ commitValue('System');
2747
+ }}
2748
+ style={styles.clearInputButton}
2749
+ >
2750
+ <Text style={styles.clearInputButtonText}>x</Text>
2751
+ </Pressable>
2752
+ ) : null}
2753
+ </View>
2754
+ {isOpen ? (
2755
+ <View style={styles.comboboxDropdown}>
2756
+ <View style={styles.comboboxHeader}>
2757
+ <Text style={styles.comboboxHeaderText}>Choose a font</Text>
2758
+ <Pressable onPress={() => setIsOpen(false)} style={styles.comboboxCloseButton}>
2759
+ <Text style={styles.comboboxCloseText}>Close</Text>
2760
+ </Pressable>
2761
+ </View>
2762
+ <ScrollView
2763
+ keyboardShouldPersistTaps="handled"
2764
+ nestedScrollEnabled
2765
+ style={styles.comboboxScroll}
2766
+ >
2767
+ {filteredOptions.length === 0 ? (
2768
+ <Text style={styles.comboboxEmpty}>No matching fonts</Text>
2769
+ ) : (
2770
+ filteredOptions.map((option) => (
2771
+ <Pressable
2772
+ key={`${props.label}-${option}`}
2773
+ onPress={() => {
2774
+ commitValue(option);
2775
+ }}
2776
+ style={styles.comboboxOption}
2777
+ >
2778
+ <Text
2779
+ style={[
2780
+ styles.comboboxOptionText,
2781
+ { fontFamily: resolvePreviewFontFamily(option) },
2782
+ ]}
2783
+ >
2784
+ {option}
2785
+ </Text>
2786
+ </Pressable>
2787
+ ))
2788
+ )}
2789
+ </ScrollView>
2790
+ </View>
2791
+ ) : null}
2792
+ </View>
2793
+ </View>
2794
+ );
2795
+ }
2796
+
2797
+ const styles = StyleSheet.create({
2798
+ root: {
2799
+ flex: 1,
2800
+ },
2801
+ screen: {
2802
+ flex: 1,
2803
+ },
2804
+ content: {
2805
+ flexGrow: 1,
2806
+ gap: 18,
2807
+ padding: 20,
2808
+ },
2809
+ controls: {
2810
+ alignItems: 'stretch',
2811
+ backgroundColor: '#ffffff',
2812
+ borderColor: '#d1d5db',
2813
+ borderRadius: 12,
2814
+ borderWidth: 1,
2815
+ flexDirection: 'row',
2816
+ gap: 10,
2817
+ left: 12,
2818
+ padding: 8,
2819
+ position: 'absolute',
2820
+ right: 12,
2821
+ zIndex: 30,
2822
+ },
2823
+ toggleRow: {
2824
+ backgroundColor: '#ffffff',
2825
+ borderColor: '#cbd5e1',
2826
+ borderRadius: 12,
2827
+ borderWidth: 1,
2828
+ flex: 1,
2829
+ gap: 6,
2830
+ padding: 8,
2831
+ },
2832
+ toggleLabel: {
2833
+ color: '#111827',
2834
+ fontSize: 11,
2835
+ fontWeight: '700',
2836
+ textTransform: 'uppercase',
2837
+ },
2838
+ toggleLabelRow: {
2839
+ alignItems: 'center',
2840
+ flexDirection: 'row',
2841
+ justifyContent: 'space-between',
2842
+ },
2843
+ toggleInfoButton: {
2844
+ alignItems: 'center',
2845
+ borderColor: '#cbd5e1',
2846
+ borderRadius: 999,
2847
+ borderWidth: 1,
2848
+ height: 20,
2849
+ justifyContent: 'center',
2850
+ width: 20,
2851
+ },
2852
+ toggleInfoButtonText: {
2853
+ color: '#111827',
2854
+ fontSize: 11,
2855
+ fontWeight: '800',
2856
+ },
2857
+ toggleOptions: {
2858
+ flexDirection: 'row',
2859
+ flexWrap: 'wrap',
2860
+ gap: 6,
2861
+ },
2862
+ toggleOption: {
2863
+ borderColor: '#cbd5e1',
2864
+ borderRadius: 999,
2865
+ borderWidth: 1,
2866
+ paddingHorizontal: 10,
2867
+ paddingVertical: 6,
2868
+ },
2869
+ toggleOptionSelected: {
2870
+ backgroundColor: '#111827',
2871
+ borderColor: '#111827',
2872
+ },
2873
+ toggleOptionText: {
2874
+ color: '#111827',
2875
+ fontSize: 12,
2876
+ fontWeight: '700',
2877
+ },
2878
+ toggleOptionTextSelected: {
2879
+ color: '#f9fafb',
2880
+ },
2881
+ title: {
2882
+ fontSize: 30,
2883
+ fontWeight: '900',
2884
+ },
2885
+ titleRow: {
2886
+ alignItems: 'center',
2887
+ flexDirection: 'row',
2888
+ gap: 12,
2889
+ justifyContent: 'space-between',
2890
+ },
2891
+ intro: {
2892
+ fontSize: 15,
2893
+ lineHeight: 22,
2894
+ },
2895
+ fontBanner: {
2896
+ backgroundColor: '#ffffff',
2897
+ borderRadius: 12,
2898
+ borderWidth: 1,
2899
+ gap: 10,
2900
+ padding: 12,
2901
+ },
2902
+ fontBannerHeader: {
2903
+ alignItems: 'center',
2904
+ flexDirection: 'row',
2905
+ justifyContent: 'space-between',
2906
+ },
2907
+ fontBannerTitle: {
2908
+ color: '#111827',
2909
+ fontSize: 14,
2910
+ fontWeight: '800',
2911
+ },
2912
+ fontBannerBody: {
2913
+ color: '#374151',
2914
+ fontSize: 12,
2915
+ lineHeight: 18,
2916
+ },
2917
+ bannerDismissButton: {
2918
+ borderColor: '#cbd5e1',
2919
+ borderRadius: 999,
2920
+ borderWidth: 1,
2921
+ height: 26,
2922
+ width: 26,
2923
+ alignItems: 'center',
2924
+ justifyContent: 'center',
2925
+ },
2926
+ bannerDismissText: {
2927
+ color: '#111827',
2928
+ fontSize: 12,
2929
+ fontWeight: '800',
2930
+ },
2931
+ fontBannerActions: {
2932
+ flexDirection: 'row',
2933
+ flexWrap: 'wrap',
2934
+ gap: 8,
2935
+ },
2936
+ bannerAction: {
2937
+ alignItems: 'center',
2938
+ borderRadius: 10,
2939
+ justifyContent: 'center',
2940
+ minHeight: 36,
2941
+ paddingHorizontal: 12,
2942
+ },
2943
+ bannerActionText: {
2944
+ color: '#ffffff',
2945
+ fontSize: 12,
2946
+ fontWeight: '800',
2947
+ },
2948
+ bannerActionGhost: {
2949
+ backgroundColor: '#ffffff',
2950
+ borderColor: '#cbd5e1',
2951
+ borderWidth: 1,
2952
+ },
2953
+ bannerActionGhostText: {
2954
+ color: '#111827',
2955
+ fontSize: 12,
2956
+ fontWeight: '700',
2957
+ },
2958
+ fontError: {
2959
+ color: '#b91c1c',
2960
+ fontSize: 12,
2961
+ fontWeight: '600',
2962
+ },
2963
+ section: {
2964
+ gap: 12,
2965
+ padding: 16,
2966
+ },
2967
+ sectionOverlay: {
2968
+ overflow: 'visible',
2969
+ zIndex: 40,
2970
+ },
2971
+ typographySection: {
2972
+ overflow: 'visible',
2973
+ zIndex: 90,
2974
+ },
2975
+ sectionHeaderRow: {
2976
+ alignItems: 'center',
2977
+ flexDirection: 'row',
2978
+ gap: 12,
2979
+ justifyContent: 'space-between',
2980
+ },
2981
+ sectionTitle: {
2982
+ fontSize: 18,
2983
+ fontWeight: '800',
2984
+ },
2985
+ inlineToggle: {
2986
+ flexDirection: 'row',
2987
+ flexWrap: 'wrap',
2988
+ gap: 6,
2989
+ justifyContent: 'flex-end',
2990
+ },
2991
+ inlineToggleOption: {
2992
+ borderColor: '#cbd5e1',
2993
+ borderRadius: 999,
2994
+ borderWidth: 1,
2995
+ paddingHorizontal: 10,
2996
+ paddingVertical: 6,
2997
+ },
2998
+ inlineToggleOptionSelected: {
2999
+ backgroundColor: '#111827',
3000
+ borderColor: '#111827',
3001
+ },
3002
+ inlineToggleLabel: {
3003
+ fontSize: 12,
3004
+ fontWeight: '700',
3005
+ },
3006
+ presetButton: {
3007
+ borderRadius: 999,
3008
+ borderWidth: 1,
3009
+ paddingHorizontal: 12,
3010
+ paddingVertical: 7,
3011
+ },
3012
+ presetButtonText: {
3013
+ fontSize: 12,
3014
+ fontWeight: '800',
3015
+ },
3016
+ helperText: {
3017
+ fontSize: 13,
3018
+ lineHeight: 19,
3019
+ opacity: 0.9,
3020
+ },
3021
+ lockedNote: {
3022
+ fontSize: 12,
3023
+ opacity: 0.75,
3024
+ },
3025
+ colorRow: {
3026
+ flexDirection: 'row',
3027
+ flexWrap: 'wrap',
3028
+ gap: 8,
3029
+ },
3030
+ colorChip: {
3031
+ borderRadius: 999,
3032
+ borderWidth: 2,
3033
+ minWidth: 100,
3034
+ paddingHorizontal: 10,
3035
+ paddingVertical: 8,
3036
+ },
3037
+ colorChipLabel: {
3038
+ color: '#d1d5db',
3039
+ fontSize: 12,
3040
+ fontWeight: '700',
3041
+ textTransform: 'capitalize',
3042
+ },
3043
+ picker: {
3044
+ gap: 12,
3045
+ width: '100%',
3046
+ },
3047
+ pickerHexRow: {
3048
+ alignItems: 'center',
3049
+ flexDirection: 'row',
3050
+ gap: 10,
3051
+ },
3052
+ pickerHexLabel: {
3053
+ color: '#111827',
3054
+ fontSize: 12,
3055
+ fontWeight: '800',
3056
+ textTransform: 'uppercase',
3057
+ },
3058
+ pickerHexInput: {
3059
+ flex: 1,
3060
+ fontFamily: 'monospace',
3061
+ minHeight: 38,
3062
+ },
3063
+ manualSwatchesRow: {
3064
+ flexDirection: 'row',
3065
+ flexWrap: 'nowrap',
3066
+ justifyContent: 'space-between',
3067
+ width: '100%',
3068
+ },
3069
+ manualSwatch: {
3070
+ borderColor: '#d1d5db',
3071
+ borderRadius: 999,
3072
+ borderWidth: 1,
3073
+ flexShrink: 1,
3074
+ height: 32,
3075
+ width: 32,
3076
+ },
3077
+ manualSwatchSelected: {
3078
+ borderColor: '#111827',
3079
+ borderWidth: 3,
3080
+ },
3081
+ familyBlock: {
3082
+ gap: 12,
3083
+ },
3084
+ familyRow: {
3085
+ gap: 6,
3086
+ },
3087
+ shadeRow: {
3088
+ alignItems: 'center',
3089
+ flexDirection: 'row',
3090
+ flexWrap: 'wrap',
3091
+ gap: 8,
3092
+ },
3093
+ shadeDot: {
3094
+ borderRadius: 999,
3095
+ height: 18,
3096
+ width: 18,
3097
+ },
3098
+ familyHeader: {
3099
+ alignItems: 'center',
3100
+ flexDirection: 'row',
3101
+ flexWrap: 'wrap',
3102
+ gap: 8,
3103
+ },
3104
+ familyTitle: {
3105
+ fontSize: 12,
3106
+ fontWeight: '700',
3107
+ textTransform: 'capitalize',
3108
+ },
3109
+ familyTitleChip: {
3110
+ borderRadius: 999,
3111
+ borderWidth: 1,
3112
+ paddingHorizontal: 12,
3113
+ paddingVertical: 7,
3114
+ },
3115
+ familyTitleChipText: {
3116
+ fontSize: 12,
3117
+ fontWeight: '800',
3118
+ textTransform: 'capitalize',
3119
+ },
3120
+ familySchemeHint: {
3121
+ fontSize: 12,
3122
+ fontWeight: '700',
3123
+ opacity: 0.72,
3124
+ textTransform: 'capitalize',
3125
+ },
3126
+ familyOptions: {
3127
+ flexDirection: 'row',
3128
+ flexWrap: 'wrap',
3129
+ gap: 6,
3130
+ },
3131
+ familyOption: {
3132
+ borderColor: '#cbd5e1',
3133
+ borderRadius: 999,
3134
+ borderWidth: 1,
3135
+ paddingHorizontal: 10,
3136
+ paddingVertical: 6,
3137
+ },
3138
+ familyOptionText: {
3139
+ fontSize: 12,
3140
+ fontWeight: '700',
3141
+ textTransform: 'capitalize',
3142
+ },
3143
+ galleryRow: {
3144
+ flexDirection: 'row',
3145
+ flexWrap: 'wrap',
3146
+ gap: 10,
3147
+ },
3148
+ galleryCard: {
3149
+ borderRadius: 12,
3150
+ borderWidth: 1,
3151
+ flex: 1,
3152
+ gap: 4,
3153
+ minWidth: 220,
3154
+ padding: 12,
3155
+ },
3156
+ galleryCardSoft: {
3157
+ backgroundColor: 'rgba(148,163,184,0.14)',
3158
+ },
3159
+ galleryCardTitle: {
3160
+ fontSize: 14,
3161
+ fontWeight: '800',
3162
+ },
3163
+ galleryCardBody: {
3164
+ fontSize: 12,
3165
+ lineHeight: 18,
3166
+ opacity: 0.92,
3167
+ },
3168
+ statusRow: {
3169
+ flexDirection: 'row',
3170
+ flexWrap: 'wrap',
3171
+ gap: 8,
3172
+ },
3173
+ statusPill: {
3174
+ borderRadius: 999,
3175
+ paddingHorizontal: 10,
3176
+ paddingVertical: 6,
3177
+ },
3178
+ statusPillText: {
3179
+ color: '#f8fafc',
3180
+ fontSize: 11,
3181
+ fontWeight: '800',
3182
+ textTransform: 'uppercase',
3183
+ },
3184
+ inputStatesRow: {
3185
+ gap: 8,
3186
+ },
3187
+ spacingPreview: {
3188
+ gap: 8,
3189
+ },
3190
+ spacingPreviewRow: {
3191
+ alignItems: 'center',
3192
+ flexDirection: 'row',
3193
+ gap: 8,
3194
+ },
3195
+ spacingLabel: {
3196
+ color: '#374151',
3197
+ fontSize: 11,
3198
+ fontWeight: '700',
3199
+ textTransform: 'uppercase',
3200
+ width: 32,
3201
+ },
3202
+ spacingBar: {
3203
+ height: 12,
3204
+ },
3205
+ layoutPreviewContainer: {
3206
+ borderWidth: 1,
3207
+ },
3208
+ layoutPreviewCard: {
3209
+ borderWidth: 1,
3210
+ },
3211
+ layoutPreviewTitle: {
3212
+ fontSize: 13,
3213
+ fontWeight: '800',
3214
+ marginBottom: 4,
3215
+ },
3216
+ layoutPreviewBody: {
3217
+ fontSize: 12,
3218
+ lineHeight: 17,
3219
+ opacity: 0.9,
3220
+ },
3221
+ grid: {
3222
+ flexDirection: 'row',
3223
+ flexWrap: 'wrap',
3224
+ gap: 10,
3225
+ },
3226
+ fontPickerGrid: {
3227
+ overflow: 'visible',
3228
+ zIndex: 120,
3229
+ },
3230
+ tokenSizeGrid: {
3231
+ zIndex: 1,
3232
+ },
3233
+ field: {
3234
+ gap: 6,
3235
+ },
3236
+ fieldOverlayOpen: {
3237
+ zIndex: 500,
3238
+ },
3239
+ fullField: {
3240
+ width: '100%',
3241
+ },
3242
+ gridField: {
3243
+ flexBasis: '48%',
3244
+ flexGrow: 1,
3245
+ minWidth: 220,
3246
+ },
3247
+ fieldLabel: {
3248
+ color: '#374151',
3249
+ fontSize: 12,
3250
+ fontWeight: '700',
3251
+ },
3252
+ comboboxWrap: {
3253
+ position: 'relative',
3254
+ },
3255
+ comboboxDropdown: {
3256
+ backgroundColor: '#ffffff',
3257
+ borderColor: '#cbd5e1',
3258
+ borderRadius: 10,
3259
+ borderWidth: 1,
3260
+ elevation: 4,
3261
+ marginTop: 6,
3262
+ maxHeight: 220,
3263
+ overflow: 'hidden',
3264
+ },
3265
+ comboboxScroll: {
3266
+ maxHeight: 168,
3267
+ },
3268
+ comboboxHeader: {
3269
+ alignItems: 'center',
3270
+ borderBottomColor: '#e5e7eb',
3271
+ borderBottomWidth: 1,
3272
+ flexDirection: 'row',
3273
+ justifyContent: 'space-between',
3274
+ paddingHorizontal: 12,
3275
+ paddingVertical: 8,
3276
+ },
3277
+ comboboxHeaderText: {
3278
+ color: '#374151',
3279
+ fontSize: 12,
3280
+ fontWeight: '800',
3281
+ },
3282
+ comboboxCloseButton: {
3283
+ borderColor: '#cbd5e1',
3284
+ borderRadius: 999,
3285
+ borderWidth: 1,
3286
+ paddingHorizontal: 10,
3287
+ paddingVertical: 5,
3288
+ },
3289
+ comboboxCloseText: {
3290
+ color: '#111827',
3291
+ fontSize: 12,
3292
+ fontWeight: '800',
3293
+ },
3294
+ comboboxOption: {
3295
+ borderBottomColor: '#e5e7eb',
3296
+ borderBottomWidth: 1,
3297
+ paddingHorizontal: 12,
3298
+ paddingVertical: 10,
3299
+ },
3300
+ comboboxOptionText: {
3301
+ color: '#111827',
3302
+ fontSize: 13,
3303
+ },
3304
+ comboboxEmpty: {
3305
+ color: '#6b7280',
3306
+ fontSize: 12,
3307
+ paddingHorizontal: 12,
3308
+ paddingVertical: 10,
3309
+ },
3310
+ input: {
3311
+ backgroundColor: '#ffffff',
3312
+ borderColor: '#d1d5db',
3313
+ borderRadius: 10,
3314
+ borderWidth: 1,
3315
+ minHeight: 42,
3316
+ paddingHorizontal: 12,
3317
+ },
3318
+ inputWithAction: {
3319
+ position: 'relative',
3320
+ },
3321
+ inputWithTrailingAction: {
3322
+ paddingRight: 38,
3323
+ },
3324
+ clearInputButton: {
3325
+ alignItems: 'center',
3326
+ borderColor: '#cbd5e1',
3327
+ borderRadius: 999,
3328
+ borderWidth: 1,
3329
+ height: 24,
3330
+ justifyContent: 'center',
3331
+ position: 'absolute',
3332
+ right: 10,
3333
+ top: 9,
3334
+ width: 24,
3335
+ },
3336
+ clearInputButtonText: {
3337
+ color: '#111827',
3338
+ fontSize: 12,
3339
+ fontWeight: '800',
3340
+ lineHeight: 14,
3341
+ textTransform: 'uppercase',
3342
+ },
3343
+ saveButton: {
3344
+ borderRadius: 12,
3345
+ minHeight: 48,
3346
+ alignItems: 'center',
3347
+ justifyContent: 'center',
3348
+ },
3349
+ saveButtonInline: {
3350
+ minHeight: 40,
3351
+ paddingHorizontal: 14,
3352
+ paddingVertical: 8,
3353
+ },
3354
+ saveButtonText: {
3355
+ color: '#ffffff',
3356
+ fontSize: 16,
3357
+ fontWeight: '800',
3358
+ },
3359
+ saveMessageBanner: {
3360
+ borderRadius: 10,
3361
+ borderWidth: 1,
3362
+ paddingHorizontal: 12,
3363
+ paddingVertical: 10,
3364
+ },
3365
+ saveMessageText: {
3366
+ fontSize: 13,
3367
+ fontWeight: '700',
3368
+ lineHeight: 18,
3369
+ },
3370
+ nativeHelp: {
3371
+ backgroundColor: '#ffffff',
3372
+ borderColor: '#e5e7eb',
3373
+ borderRadius: 12,
3374
+ borderWidth: 1,
3375
+ gap: 8,
3376
+ padding: 12,
3377
+ },
3378
+ nativeTitle: {
3379
+ color: '#111827',
3380
+ fontSize: 14,
3381
+ fontWeight: '800',
3382
+ },
3383
+ nativeBody: {
3384
+ color: '#374151',
3385
+ fontSize: 12,
3386
+ },
3387
+ command: {
3388
+ backgroundColor: '#111827',
3389
+ borderRadius: 8,
3390
+ color: '#f9fafb',
3391
+ fontFamily: 'monospace',
3392
+ fontSize: 12,
3393
+ padding: 10,
3394
+ },
3395
+ payload: {
3396
+ color: '#1f2937',
3397
+ fontFamily: 'monospace',
3398
+ fontSize: 11,
3399
+ lineHeight: 16,
3400
+ },
3401
+ modalBackdrop: {
3402
+ backgroundColor: 'rgba(15, 23, 42, 0.48)',
3403
+ flex: 1,
3404
+ alignItems: 'center',
3405
+ justifyContent: 'center',
3406
+ padding: 20,
3407
+ },
3408
+ modalCard: {
3409
+ backgroundColor: '#ffffff',
3410
+ borderColor: '#cbd5e1',
3411
+ borderRadius: 14,
3412
+ borderWidth: 1,
3413
+ gap: 10,
3414
+ maxWidth: 420,
3415
+ padding: 16,
3416
+ width: '100%',
3417
+ },
3418
+ modalTitle: {
3419
+ color: '#0f172a',
3420
+ fontSize: 18,
3421
+ fontWeight: '800',
3422
+ },
3423
+ modalBody: {
3424
+ color: '#334155',
3425
+ fontSize: 13,
3426
+ lineHeight: 19,
3427
+ },
3428
+ modalActions: {
3429
+ gap: 8,
3430
+ },
3431
+ modalButton: {
3432
+ borderRadius: 10,
3433
+ minHeight: 42,
3434
+ alignItems: 'center',
3435
+ justifyContent: 'center',
3436
+ paddingHorizontal: 12,
3437
+ },
3438
+ modalButtonPrimary: {
3439
+ backgroundColor: '#0f172a',
3440
+ },
3441
+ modalButtonSecondary: {
3442
+ backgroundColor: '#ffffff',
3443
+ borderColor: '#cbd5e1',
3444
+ borderWidth: 1,
3445
+ },
3446
+ modalButtonPrimaryText: {
3447
+ color: '#ffffff',
3448
+ fontSize: 13,
3449
+ fontWeight: '800',
3450
+ },
3451
+ modalButtonSecondaryText: {
3452
+ color: '#0f172a',
3453
+ fontSize: 13,
3454
+ fontWeight: '700',
3455
+ },
3456
+ });