@mr.dj2u/cli 0.1.7 → 0.1.8

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