@omercnet/paseo-queens 0.1.0-next.126.2

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,761 @@
1
+ import { type PluginSurfaceProps, usePaseo } from "@getpaseo/plugin/client";
2
+ import { useToast } from "@getpaseo/plugin/client/react-native";
3
+ import { useEffect, useMemo, useRef, useState } from "react";
4
+ import {
5
+ ActivityIndicator,
6
+ Pressable,
7
+ ScrollView,
8
+ StyleSheet,
9
+ Text,
10
+ type TextStyle,
11
+ View,
12
+ type ViewStyle,
13
+ } from "react-native";
14
+ import { CompletionFeedback } from "./completion-feedback";
15
+ import { findConflicts } from "./game";
16
+ import type { CuratedPuzzleDeck } from "./game/curated";
17
+ import { GameControls } from "./game-controls";
18
+ import { GameMark } from "./game-mark";
19
+ import { PuzzleSelector } from "./puzzle-selector";
20
+ import { QueensBoard } from "./queens-board";
21
+ import { usePersistedGame } from "./use-persisted-game";
22
+ import { type PuzzleCatalogState, usePuzzleCatalog } from "./use-puzzle-catalog";
23
+
24
+ const EMPTY_CONFLICTS: ReadonlySet<number> = new Set();
25
+ const AGENT_PAGE_LIMIT = 200;
26
+ const AGENT_MAX_PAGES = 10;
27
+ const AGENT_BACKSTOP_REFRESH_MS = 30_000;
28
+
29
+ export function PaseoQueensSurface(props: PluginSurfaceProps) {
30
+ return <QueensGame key={props.host.id} {...props} />;
31
+ }
32
+
33
+ function QueensGame(props: PluginSurfaceProps) {
34
+ const catalog = usePuzzleCatalog();
35
+ if (!catalog.deck) {
36
+ return (
37
+ <View
38
+ style={{
39
+ flex: 1,
40
+ alignItems: "center",
41
+ justifyContent: "center",
42
+ gap: 10,
43
+ padding: 24,
44
+ backgroundColor: props.theme.colors.surface0,
45
+ }}
46
+ >
47
+ {catalog.loading ? <ActivityIndicator color={props.theme.colors.accent} /> : null}
48
+ <Text
49
+ accessibilityRole={catalog.error ? "alert" : undefined}
50
+ style={{
51
+ color: catalog.error
52
+ ? props.theme.colors.statusDanger
53
+ : props.theme.colors.foregroundMuted,
54
+ }}
55
+ >
56
+ {catalog.error ?? "Loading curated puzzles…"}
57
+ </Text>
58
+ {catalog.error ? (
59
+ <Pressable
60
+ accessibilityRole="button"
61
+ accessibilityLabel="Retry puzzle download"
62
+ onPress={catalog.retry}
63
+ style={{
64
+ minHeight: 40,
65
+ justifyContent: "center",
66
+ paddingHorizontal: 16,
67
+ borderWidth: StyleSheet.hairlineWidth,
68
+ borderColor: props.theme.colors.border,
69
+ borderRadius: 8,
70
+ backgroundColor: props.theme.colors.surface2,
71
+ }}
72
+ >
73
+ <Text style={{ color: props.theme.colors.foreground, fontWeight: "700" }}>Retry</Text>
74
+ </Pressable>
75
+ ) : null}
76
+ </View>
77
+ );
78
+ }
79
+ return (
80
+ <QueensLoadedGame
81
+ key={`${props.host.id}:${catalog.deck.key}`}
82
+ {...props}
83
+ deck={catalog.deck}
84
+ catalog={catalog}
85
+ />
86
+ );
87
+ }
88
+
89
+ function QueensLoadedGame({
90
+ theme,
91
+ layout,
92
+ host,
93
+ deck,
94
+ catalog,
95
+ }: PluginSurfaceProps & {
96
+ readonly deck: CuratedPuzzleDeck;
97
+ readonly catalog: PuzzleCatalogState;
98
+ }) {
99
+ const session = usePersistedGame(`${host.id}:${deck.key}`, deck.puzzles, deck.solutions);
100
+ const paseo = usePaseo();
101
+ const toast = useToast();
102
+ const [runningAgentCount, setRunningAgentCount] = useState<number | null>(null);
103
+ const previousRunningAgentCount = useRef<number | null>(null);
104
+ const [boardGestureActive, setBoardGestureActive] = useState(false);
105
+ const styles = useMemo(() => createStyles(theme, layout.compact), [layout.compact, theme]);
106
+ const state = session.status === "ready" ? session.state : null;
107
+ const puzzle = state?.puzzles[state.activePuzzleIndex];
108
+ const progress = state?.progress[state.activePuzzleIndex];
109
+ const cells = progress?.cells;
110
+ const conflicts = useMemo(
111
+ () => (puzzle && cells ? findConflicts(puzzle, cells) : EMPTY_CONFLICTS),
112
+ [cells, puzzle],
113
+ );
114
+ const dispatch = session.status === "ready" ? session.dispatch : null;
115
+ const timerRunning = progress?.timer.startedAt !== null && progress?.timer.completedAt === null;
116
+
117
+ useEffect(() => {
118
+ if (!dispatch || !timerRunning) return;
119
+
120
+ const interval = setInterval(() => {
121
+ dispatch({ type: "tick", now: Date.now() });
122
+ }, 1_000);
123
+
124
+ return () => clearInterval(interval);
125
+ }, [dispatch, timerRunning]);
126
+
127
+ useEffect(() => {
128
+ let mounted = true;
129
+ let request = 0;
130
+
131
+ const refresh = async () => {
132
+ const currentRequest = ++request;
133
+ let running = 0;
134
+ let cursor: string | undefined;
135
+ try {
136
+ for (let page = 0; page < AGENT_MAX_PAGES; page += 1) {
137
+ const result = await paseo.agents.list({
138
+ sort: [{ key: "updated_at", direction: "desc" }],
139
+ page: { limit: AGENT_PAGE_LIMIT, ...(cursor ? { cursor } : {}) },
140
+ });
141
+ for (const entry of result.entries) {
142
+ if (entry.agent.status === "running" || entry.agent.status === "initializing") {
143
+ running += 1;
144
+ }
145
+ }
146
+ cursor = result.pageInfo.hasMore ? (result.pageInfo.nextCursor ?? undefined) : undefined;
147
+ if (!cursor) break;
148
+ }
149
+ if (mounted && currentRequest === request) setRunningAgentCount(running);
150
+ } catch {
151
+ // Preserve the last known count when the host is temporarily unavailable.
152
+ }
153
+ };
154
+
155
+ void refresh();
156
+ const unsubscribe = paseo.agents.subscribe(() => void refresh());
157
+ const backstop = setInterval(() => void refresh(), AGENT_BACKSTOP_REFRESH_MS);
158
+ return () => {
159
+ mounted = false;
160
+ clearInterval(backstop);
161
+ unsubscribe();
162
+ };
163
+ }, [paseo]);
164
+
165
+ useEffect(() => {
166
+ const previous = previousRunningAgentCount.current;
167
+ if (previous !== null && previous > 0 && runningAgentCount === 0) {
168
+ toast.show("All agents are idle. Back to work!", { variant: "success" });
169
+ }
170
+ previousRunningAgentCount.current = runningAgentCount;
171
+ }, [runningAgentCount, toast]);
172
+
173
+ if (session.status === "loading") {
174
+ return (
175
+ <View style={styles.screen}>
176
+ <ScrollView
177
+ style={styles.messageScroll}
178
+ contentContainerStyle={styles.messageScrollContent}
179
+ >
180
+ <View accessibilityLiveRegion="polite" style={styles.messageCard}>
181
+ <ActivityIndicator color={theme.colors.accent} />
182
+ <Text accessibilityRole="header" style={styles.messageTitle}>
183
+ Loading saved game
184
+ </Text>
185
+ <Text style={styles.messageBody}>Restoring your puzzle progress.</Text>
186
+ {session.saveError ? (
187
+ <Text accessibilityRole="alert" style={styles.errorText}>
188
+ {session.saveError}
189
+ </Text>
190
+ ) : null}
191
+ </View>
192
+ </ScrollView>
193
+ </View>
194
+ );
195
+ }
196
+
197
+ if (session.status === "error") {
198
+ return (
199
+ <View style={styles.screen}>
200
+ <ScrollView
201
+ style={styles.messageScroll}
202
+ contentContainerStyle={styles.messageScrollContent}
203
+ >
204
+ <View style={styles.messageCard}>
205
+ <Text accessibilityRole="header" style={styles.messageTitle}>
206
+ Saved game unavailable
207
+ </Text>
208
+ <Text accessibilityRole="alert" style={styles.errorText}>
209
+ {session.error}
210
+ </Text>
211
+ <RecoveryButton
212
+ label="Reload"
213
+ disabled={session.saving}
214
+ onPress={() => void session.reload()}
215
+ styles={styles}
216
+ />
217
+ </View>
218
+ </ScrollView>
219
+ </View>
220
+ );
221
+ }
222
+
223
+ if (session.status === "invalid") {
224
+ return (
225
+ <View style={styles.screen}>
226
+ <ScrollView
227
+ style={styles.messageScroll}
228
+ contentContainerStyle={styles.messageScrollContent}
229
+ >
230
+ <View style={styles.messageCard}>
231
+ <Text accessibilityRole="header" style={styles.messageTitle}>
232
+ Saved game needs attention
233
+ </Text>
234
+ <Text accessibilityRole="alert" style={styles.errorText}>
235
+ {session.error}
236
+ </Text>
237
+ <Text style={styles.messageBody}>
238
+ Reload to try again, or reset the invalid data and start fresh.
239
+ </Text>
240
+ <View style={styles.recoveryActions}>
241
+ <RecoveryButton
242
+ label="Reload"
243
+ disabled={session.saving}
244
+ onPress={() => void session.reload()}
245
+ styles={styles}
246
+ />
247
+ <RecoveryButton
248
+ label="Reset saved game"
249
+ disabled={session.saving}
250
+ onPress={() => void session.reset()}
251
+ styles={styles}
252
+ />
253
+ </View>
254
+ </View>
255
+ </ScrollView>
256
+ </View>
257
+ );
258
+ }
259
+
260
+ if (!state || !puzzle || !progress) {
261
+ return (
262
+ <View style={styles.screen}>
263
+ <ScrollView
264
+ style={styles.messageScroll}
265
+ contentContainerStyle={styles.messageScrollContent}
266
+ >
267
+ <View style={styles.messageCard}>
268
+ <Text accessibilityRole="header" style={styles.messageTitle}>
269
+ Puzzle unavailable
270
+ </Text>
271
+ <Text accessibilityRole="alert" style={styles.errorText}>
272
+ The saved game does not contain a playable puzzle.
273
+ </Text>
274
+ <RecoveryButton
275
+ label="Reload"
276
+ disabled={session.saving}
277
+ onPress={() => void session.reload()}
278
+ styles={styles}
279
+ />
280
+ </View>
281
+ </ScrollView>
282
+ </View>
283
+ );
284
+ }
285
+ const readyState = state;
286
+ const readyDispatch = session.dispatch;
287
+ const hintUsed = session.hintUsed;
288
+
289
+ const markedCount = progress.cells.reduce(
290
+ (count, cell) => count + (cell === "marked" ? 1 : 0),
291
+ 0,
292
+ );
293
+ const hasProgress =
294
+ progress.timer.startedAt !== null || progress.cells.some((cell) => cell !== "empty");
295
+ const puzzleNumber = state.activePuzzleIndex + 1;
296
+ const puzzleLabel = puzzle.id.split("-").pop() ?? String(puzzleNumber);
297
+ const statusText = progress.solved
298
+ ? hintUsed
299
+ ? "Assisted — solved with every queen in a safe place."
300
+ : "Solved — every queen has a safe place."
301
+ : conflicts.size > 0
302
+ ? `${conflicts.size} conflicting ${conflicts.size === 1 ? "square" : "squares"}`
303
+ : `${markedCount} of ${puzzle.size} queens placed`;
304
+ const statusColor = progress.solved
305
+ ? theme.colors.statusSuccess
306
+ : conflicts.size > 0
307
+ ? theme.colors.statusDanger
308
+ : theme.colors.foregroundMuted;
309
+ const agentStatusText =
310
+ runningAgentCount === null
311
+ ? "Checking agents…"
312
+ : runningAgentCount === 0
313
+ ? "All agents idle"
314
+ : `${runningAgentCount} ${runningAgentCount === 1 ? "agent" : "agents"} running`;
315
+ const agentStatusColor =
316
+ runningAgentCount === null
317
+ ? theme.colors.foregroundMuted
318
+ : runningAgentCount === 0
319
+ ? theme.colors.statusSuccess
320
+ : theme.colors.accent;
321
+
322
+ const selectPuzzle = (index: number) => {
323
+ const clampedIndex = Math.min(Math.max(index, 0), readyState.puzzles.length - 1);
324
+ readyDispatch({ type: "select-puzzle", index: clampedIndex, now: Date.now() });
325
+ };
326
+
327
+ return (
328
+ <View style={styles.screen}>
329
+ <ScrollView scrollEnabled={!boardGestureActive} contentContainerStyle={styles.scrollContent}>
330
+ <View style={styles.gameShell}>
331
+ <View style={styles.heading}>
332
+ <View style={styles.eyebrowRow}>
333
+ <View style={styles.eyebrowLine} />
334
+ <Text style={styles.eyebrow}>
335
+ LOGIC, {puzzle.size} BY {puzzle.size}
336
+ </Text>
337
+ <View style={styles.eyebrowLine} />
338
+ </View>
339
+ <View style={styles.titleRow}>
340
+ <GameMark size={layout.compact ? 28 : 34} color={theme.colors.accent} />
341
+ <Text accessibilityRole="header" style={styles.title}>
342
+ Queens
343
+ </Text>
344
+ </View>
345
+ <Text style={styles.instructions}>
346
+ Place one queen in every row, column, and region. Queens cannot touch, even
347
+ diagonally. Tap once to toggle an X, double tap to toggle a queen, or drag from an
348
+ empty square to mark Xs and from an X to erase them.
349
+ </Text>
350
+ </View>
351
+
352
+ <PuzzleSelector
353
+ size={catalog.size}
354
+ difficulty={catalog.difficulty}
355
+ disabled={catalog.loading || session.saving || boardGestureActive}
356
+ compact={layout.compact}
357
+ theme={theme}
358
+ onChange={(size, difficulty) => {
359
+ void catalog.select(size, difficulty);
360
+ }}
361
+ />
362
+ {catalog.error ? (
363
+ <Text accessibilityRole="alert" style={styles.errorText}>
364
+ {catalog.error}
365
+ </Text>
366
+ ) : null}
367
+
368
+ <View style={styles.puzzleMeta}>
369
+ <View style={styles.metaItem}>
370
+ <Text style={styles.puzzleMetaLabel}>PUZZLE</Text>
371
+ <Text style={styles.puzzleMetaValue}>
372
+ #{puzzleLabel} · {puzzleNumber}/{state.puzzles.length}
373
+ </Text>
374
+ </View>
375
+ <View style={styles.metaDivider} />
376
+ <View style={styles.metaItem}>
377
+ <Text style={styles.puzzleMetaLabel}>TIME</Text>
378
+ <Text
379
+ accessibilityLabel={`Elapsed time ${formatElapsedAccessible(progress.timer.elapsedMs)}`}
380
+ style={styles.timerValue}
381
+ >
382
+ {formatElapsed(progress.timer.elapsedMs)}
383
+ </Text>
384
+ </View>
385
+ <View style={styles.metaDivider} />
386
+ <View style={styles.metaItem}>
387
+ <Text style={styles.puzzleMetaLabel}>SYNC</Text>
388
+ <Text style={styles.puzzleMetaValue}>{session.saving ? "Saving…" : "Saved"}</Text>
389
+ </View>
390
+ {hintUsed ? (
391
+ <>
392
+ <View style={styles.metaDivider} />
393
+ <View style={styles.metaItem}>
394
+ <Text style={styles.puzzleMetaLabel}>RUN</Text>
395
+ <Text style={styles.puzzleMetaValue}>Assisted</Text>
396
+ </View>
397
+ </>
398
+ ) : null}
399
+ </View>
400
+
401
+ {session.saveError ? (
402
+ <View accessibilityRole="alert" style={styles.saveErrorCard}>
403
+ <View style={styles.saveErrorCopy}>
404
+ <Text style={styles.saveErrorTitle}>Progress not saved</Text>
405
+ <Text style={styles.saveErrorBody}>{session.saveError}</Text>
406
+ </View>
407
+ <RecoveryButton
408
+ label="Reload saved game"
409
+ disabled={session.saving}
410
+ onPress={() => void session.reload()}
411
+ styles={styles}
412
+ />
413
+ </View>
414
+ ) : null}
415
+
416
+ <CompletionFeedback
417
+ solved={progress.solved}
418
+ color={theme.colors.statusSuccess}
419
+ style={styles.boardFeedback}
420
+ >
421
+ <QueensBoard
422
+ key={puzzle.id}
423
+ puzzle={puzzle}
424
+ cells={progress.cells}
425
+ conflicts={conflicts}
426
+ solved={progress.solved}
427
+ disabled={progress.solved}
428
+ compact={layout.compact}
429
+ theme={theme}
430
+ onGestureActiveChange={setBoardGestureActive}
431
+ onSetCells={(indexes, cellState) => {
432
+ readyDispatch({ type: "set-cells", indexes, state: cellState, now: Date.now() });
433
+ }}
434
+ />
435
+ </CompletionFeedback>
436
+
437
+ <View style={styles.statusRail}>
438
+ <View style={styles.statusCard} accessibilityLiveRegion="polite">
439
+ <View style={[styles.statusDot, { backgroundColor: statusColor }]} />
440
+ <Text style={[styles.statusText, { color: statusColor }]}>{statusText}</Text>
441
+ </View>
442
+ <View style={styles.statusCard} accessibilityLiveRegion="polite">
443
+ <View style={[styles.statusDot, { backgroundColor: agentStatusColor }]} />
444
+ <Text style={[styles.statusText, { color: agentStatusColor }]}>
445
+ {agentStatusText}
446
+ </Text>
447
+ </View>
448
+ </View>
449
+
450
+ <GameControls
451
+ canUndo={progress.history.length > 0}
452
+ canHint={!progress.solved}
453
+ canReset={hasProgress}
454
+ canGoPrevious={state.activePuzzleIndex > 0}
455
+ canGoNext={state.activePuzzleIndex < state.puzzles.length - 1}
456
+ saving={session.saving}
457
+ disabled={false}
458
+ theme={theme}
459
+ onUndo={() => {
460
+ readyDispatch({ type: "undo", now: Date.now() });
461
+ }}
462
+ onHint={() => {
463
+ readyDispatch({ type: "hint", now: Date.now() });
464
+ }}
465
+ onReset={() => {
466
+ readyDispatch({ type: "reset", now: Date.now() });
467
+ }}
468
+ onPrevious={() => selectPuzzle(readyState.activePuzzleIndex - 1)}
469
+ onNext={() => selectPuzzle(readyState.activePuzzleIndex + 1)}
470
+ />
471
+ </View>
472
+ </ScrollView>
473
+ </View>
474
+ );
475
+ }
476
+
477
+ interface RecoveryStyles {
478
+ readonly recoveryButton: ViewStyle;
479
+ readonly disabled: ViewStyle;
480
+ readonly pressed: ViewStyle;
481
+ readonly recoveryButtonText: TextStyle;
482
+ }
483
+
484
+ interface RecoveryButtonProps {
485
+ readonly label: string;
486
+ readonly disabled: boolean;
487
+ readonly onPress: () => void;
488
+ readonly styles: RecoveryStyles;
489
+ }
490
+
491
+ function RecoveryButton({ label, disabled, onPress, styles }: RecoveryButtonProps) {
492
+ return (
493
+ <Pressable
494
+ accessibilityRole="button"
495
+ accessibilityLabel={label}
496
+ accessibilityState={{ disabled }}
497
+ disabled={disabled}
498
+ onPress={onPress}
499
+ style={({ pressed }) => [
500
+ styles.recoveryButton,
501
+ disabled && styles.disabled,
502
+ pressed && styles.pressed,
503
+ ]}
504
+ >
505
+ <Text style={styles.recoveryButtonText}>{label}</Text>
506
+ </Pressable>
507
+ );
508
+ }
509
+
510
+ function formatElapsed(elapsedMs: number): string {
511
+ const totalSeconds = Math.max(0, Math.floor(elapsedMs / 1_000));
512
+ const hours = Math.floor(totalSeconds / 3_600);
513
+ const minutes = Math.floor((totalSeconds % 3_600) / 60);
514
+ const seconds = totalSeconds % 60;
515
+
516
+ return hours > 0
517
+ ? `${hours}:${String(minutes).padStart(2, "0")}:${String(seconds).padStart(2, "0")}`
518
+ : `${String(minutes).padStart(2, "0")}:${String(seconds).padStart(2, "0")}`;
519
+ }
520
+
521
+ function formatElapsedAccessible(elapsedMs: number): string {
522
+ const totalSeconds = Math.max(0, Math.floor(elapsedMs / 1_000));
523
+ const hours = Math.floor(totalSeconds / 3_600);
524
+ const minutes = Math.floor((totalSeconds % 3_600) / 60);
525
+ const seconds = totalSeconds % 60;
526
+ const parts = [];
527
+ if (hours > 0) parts.push(`${hours} ${hours === 1 ? "hour" : "hours"}`);
528
+ if (minutes > 0) parts.push(`${minutes} ${minutes === 1 ? "minute" : "minutes"}`);
529
+ parts.push(`${seconds} ${seconds === 1 ? "second" : "seconds"}`);
530
+ return parts.join(", ");
531
+ }
532
+
533
+ function createStyles(theme: PluginSurfaceProps["theme"], compact: boolean) {
534
+ return StyleSheet.create({
535
+ screen: {
536
+ flex: 1,
537
+ alignItems: "center",
538
+ justifyContent: "center",
539
+ backgroundColor: theme.colors.surface0,
540
+ },
541
+ messageScroll: {
542
+ width: "100%",
543
+ },
544
+ messageScrollContent: {
545
+ flexGrow: 1,
546
+ alignItems: "center",
547
+ justifyContent: "center",
548
+ padding: 24,
549
+ },
550
+ scrollContent: {
551
+ flexGrow: 1,
552
+ alignItems: compact ? "stretch" : "center",
553
+ justifyContent: compact ? "flex-start" : "center",
554
+ paddingHorizontal: compact ? 14 : 28,
555
+ paddingVertical: compact ? 18 : 32,
556
+ },
557
+ gameShell: {
558
+ width: compact ? undefined : "100%",
559
+ maxWidth: 620,
560
+ alignSelf: compact ? "stretch" : "center",
561
+ alignItems: "center",
562
+ gap: compact ? 14 : 20,
563
+ },
564
+ heading: {
565
+ width: "100%",
566
+ alignItems: "center",
567
+ gap: compact ? 7 : 9,
568
+ },
569
+ eyebrowRow: {
570
+ width: "100%",
571
+ maxWidth: 330,
572
+ flexDirection: "row",
573
+ alignItems: "center",
574
+ gap: 10,
575
+ },
576
+ eyebrowLine: {
577
+ flex: 1,
578
+ height: StyleSheet.hairlineWidth,
579
+ backgroundColor: theme.colors.border,
580
+ },
581
+ eyebrow: {
582
+ color: theme.colors.foregroundMuted,
583
+ fontSize: 10,
584
+ fontWeight: "700",
585
+ letterSpacing: 1.6,
586
+ },
587
+ titleRow: {
588
+ flexDirection: "row",
589
+ alignItems: "center",
590
+ gap: compact ? 9 : 12,
591
+ },
592
+ title: {
593
+ color: theme.colors.foreground,
594
+ fontSize: compact ? 30 : 38,
595
+ lineHeight: compact ? 36 : 45,
596
+ fontWeight: "800",
597
+ letterSpacing: -1,
598
+ },
599
+ instructions: {
600
+ maxWidth: 540,
601
+ color: theme.colors.foregroundMuted,
602
+ fontSize: compact ? 12 : 13,
603
+ lineHeight: compact ? 17 : 19,
604
+ textAlign: "center",
605
+ },
606
+ puzzleMeta: {
607
+ flexDirection: "row",
608
+ alignItems: "center",
609
+ justifyContent: "center",
610
+ gap: compact ? 8 : 12,
611
+ },
612
+ metaItem: {
613
+ alignItems: "center",
614
+ gap: 2,
615
+ },
616
+ puzzleMetaLabel: {
617
+ color: theme.colors.foregroundMuted,
618
+ fontSize: compact ? 9 : 11,
619
+ fontWeight: "700",
620
+ letterSpacing: 1.1,
621
+ },
622
+ puzzleMetaValue: {
623
+ color: theme.colors.foreground,
624
+ minWidth: compact ? 52 : 60,
625
+ fontSize: compact ? 11 : 13,
626
+ fontWeight: "700",
627
+ textAlign: "center",
628
+ },
629
+ timerValue: {
630
+ color: theme.colors.foreground,
631
+ fontSize: compact ? 12 : 15,
632
+ fontWeight: "800",
633
+ fontVariant: ["tabular-nums"],
634
+ },
635
+ metaDivider: {
636
+ width: StyleSheet.hairlineWidth,
637
+ height: 28,
638
+ backgroundColor: theme.colors.border,
639
+ },
640
+ boardFeedback: {
641
+ width: "100%",
642
+ maxWidth: compact ? 360 : 440,
643
+ },
644
+ statusRail: {
645
+ width: "100%",
646
+ flexDirection: "row",
647
+ flexWrap: "wrap",
648
+ alignItems: "center",
649
+ justifyContent: "center",
650
+ gap: 8,
651
+ },
652
+ statusCard: {
653
+ minHeight: 34,
654
+ flexDirection: "row",
655
+ alignItems: "center",
656
+ justifyContent: "center",
657
+ gap: 8,
658
+ paddingHorizontal: 14,
659
+ paddingVertical: 8,
660
+ borderWidth: StyleSheet.hairlineWidth,
661
+ borderColor: theme.colors.border,
662
+ borderRadius: 999,
663
+ backgroundColor: theme.colors.surface1,
664
+ },
665
+ statusDot: {
666
+ width: 7,
667
+ height: 7,
668
+ borderRadius: 4,
669
+ },
670
+ statusText: {
671
+ fontSize: 12,
672
+ fontWeight: "600",
673
+ },
674
+ messageCard: {
675
+ width: "100%",
676
+ maxWidth: 460,
677
+ alignItems: "center",
678
+ gap: 12,
679
+ padding: compact ? 20 : 28,
680
+ borderWidth: StyleSheet.hairlineWidth,
681
+ borderColor: theme.colors.border,
682
+ borderRadius: 14,
683
+ backgroundColor: theme.colors.surface1,
684
+ },
685
+ messageTitle: {
686
+ color: theme.colors.foreground,
687
+ fontSize: compact ? 20 : 24,
688
+ lineHeight: compact ? 25 : 30,
689
+ fontWeight: "800",
690
+ textAlign: "center",
691
+ },
692
+ messageBody: {
693
+ color: theme.colors.foregroundMuted,
694
+ fontSize: 13,
695
+ lineHeight: 19,
696
+ textAlign: "center",
697
+ },
698
+ errorText: {
699
+ color: theme.colors.statusDanger,
700
+ fontSize: 12,
701
+ lineHeight: 18,
702
+ textAlign: "center",
703
+ },
704
+ recoveryActions: {
705
+ flexDirection: "row",
706
+ flexWrap: "wrap",
707
+ justifyContent: "center",
708
+ gap: 8,
709
+ },
710
+ recoveryButton: {
711
+ minHeight: 40,
712
+ justifyContent: "center",
713
+ paddingHorizontal: 15,
714
+ paddingVertical: 9,
715
+ borderWidth: StyleSheet.hairlineWidth,
716
+ borderColor: theme.colors.border,
717
+ borderRadius: 9,
718
+ backgroundColor: theme.colors.surface2,
719
+ },
720
+ recoveryButtonText: {
721
+ color: theme.colors.foreground,
722
+ fontSize: 12,
723
+ fontWeight: "700",
724
+ textAlign: "center",
725
+ },
726
+ saveErrorCard: {
727
+ width: "100%",
728
+ flexDirection: compact ? "column" : "row",
729
+ alignItems: "center",
730
+ justifyContent: "space-between",
731
+ gap: 12,
732
+ padding: 12,
733
+ borderWidth: StyleSheet.hairlineWidth,
734
+ borderColor: theme.colors.statusDanger,
735
+ borderRadius: 10,
736
+ backgroundColor: theme.colors.surface1,
737
+ },
738
+ saveErrorCopy: {
739
+ flex: compact ? undefined : 1,
740
+ alignItems: compact ? "center" : "flex-start",
741
+ gap: 3,
742
+ },
743
+ saveErrorBody: {
744
+ color: theme.colors.statusDanger,
745
+ fontSize: 12,
746
+ lineHeight: 18,
747
+ textAlign: compact ? "center" : "left",
748
+ },
749
+ saveErrorTitle: {
750
+ color: theme.colors.statusDanger,
751
+ fontSize: 12,
752
+ fontWeight: "800",
753
+ },
754
+ disabled: {
755
+ opacity: 0.45,
756
+ },
757
+ pressed: {
758
+ opacity: 0.7,
759
+ },
760
+ });
761
+ }