@tapthrough/expo 0.0.0-stage → 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (135) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +105 -2
  3. package/app.plugin.d.ts +37 -0
  4. package/app.plugin.js +79 -0
  5. package/dist/Annotator.d.ts +14 -0
  6. package/dist/Annotator.d.ts.map +1 -0
  7. package/dist/Annotator.js +160 -0
  8. package/dist/Annotator.js.map +1 -0
  9. package/dist/Bubble.d.ts +27 -0
  10. package/dist/Bubble.d.ts.map +1 -0
  11. package/dist/Bubble.js +234 -0
  12. package/dist/Bubble.js.map +1 -0
  13. package/dist/Feedback.d.ts +14 -0
  14. package/dist/Feedback.d.ts.map +1 -0
  15. package/dist/Feedback.js +977 -0
  16. package/dist/Feedback.js.map +1 -0
  17. package/dist/Overlay.d.ts +13 -0
  18. package/dist/Overlay.d.ts.map +1 -0
  19. package/dist/Overlay.js +44 -0
  20. package/dist/Overlay.js.map +1 -0
  21. package/dist/Popover.d.ts +98 -0
  22. package/dist/Popover.d.ts.map +1 -0
  23. package/dist/Popover.js +531 -0
  24. package/dist/Popover.js.map +1 -0
  25. package/dist/Tapthrough.d.ts +27 -0
  26. package/dist/Tapthrough.d.ts.map +1 -0
  27. package/dist/Tapthrough.js +111 -0
  28. package/dist/Tapthrough.js.map +1 -0
  29. package/dist/core/annotations.d.ts +63 -0
  30. package/dist/core/annotations.d.ts.map +1 -0
  31. package/dist/core/annotations.js +76 -0
  32. package/dist/core/annotations.js.map +1 -0
  33. package/dist/core/bubble.d.ts +21 -0
  34. package/dist/core/bubble.d.ts.map +1 -0
  35. package/dist/core/bubble.js +25 -0
  36. package/dist/core/bubble.js.map +1 -0
  37. package/dist/core/controller.d.ts +46 -0
  38. package/dist/core/controller.d.ts.map +1 -0
  39. package/dist/core/controller.js +103 -0
  40. package/dist/core/controller.js.map +1 -0
  41. package/dist/core/diagnostics.d.ts +47 -0
  42. package/dist/core/diagnostics.d.ts.map +1 -0
  43. package/dist/core/diagnostics.js +101 -0
  44. package/dist/core/diagnostics.js.map +1 -0
  45. package/dist/core/feedback.d.ts +302 -0
  46. package/dist/core/feedback.d.ts.map +1 -0
  47. package/dist/core/feedback.js +345 -0
  48. package/dist/core/feedback.js.map +1 -0
  49. package/dist/core/flags.d.ts +13 -0
  50. package/dist/core/flags.d.ts.map +1 -0
  51. package/dist/core/flags.js +25 -0
  52. package/dist/core/flags.js.map +1 -0
  53. package/dist/core/ideas.d.ts +166 -0
  54. package/dist/core/ideas.d.ts.map +1 -0
  55. package/dist/core/ideas.js +159 -0
  56. package/dist/core/ideas.js.map +1 -0
  57. package/dist/core/launch.d.ts +43 -0
  58. package/dist/core/launch.d.ts.map +1 -0
  59. package/dist/core/launch.js +81 -0
  60. package/dist/core/launch.js.map +1 -0
  61. package/dist/core/reset.d.ts +17 -0
  62. package/dist/core/reset.d.ts.map +1 -0
  63. package/dist/core/reset.js +19 -0
  64. package/dist/core/reset.js.map +1 -0
  65. package/dist/core/review.d.ts +49 -0
  66. package/dist/core/review.d.ts.map +1 -0
  67. package/dist/core/review.js +92 -0
  68. package/dist/core/review.js.map +1 -0
  69. package/dist/core/url.d.ts +9 -0
  70. package/dist/core/url.d.ts.map +1 -0
  71. package/dist/core/url.js +23 -0
  72. package/dist/core/url.js.map +1 -0
  73. package/dist/hooks.d.ts +6 -0
  74. package/dist/hooks.d.ts.map +1 -0
  75. package/dist/hooks.js +13 -0
  76. package/dist/hooks.js.map +1 -0
  77. package/dist/index.d.ts +9 -0
  78. package/dist/index.d.ts.map +1 -0
  79. package/dist/index.js +7 -0
  80. package/dist/index.js.map +1 -0
  81. package/dist/native-intent.d.ts +18 -0
  82. package/dist/native-intent.d.ts.map +1 -0
  83. package/dist/native-intent.js +32 -0
  84. package/dist/native-intent.js.map +1 -0
  85. package/dist/runtime.d.ts +24 -0
  86. package/dist/runtime.d.ts.map +1 -0
  87. package/dist/runtime.js +17 -0
  88. package/dist/runtime.js.map +1 -0
  89. package/dist/screenshot.d.ts +7 -0
  90. package/dist/screenshot.d.ts.map +1 -0
  91. package/dist/screenshot.js +48 -0
  92. package/dist/screenshot.js.map +1 -0
  93. package/dist/supabase.d.ts +20 -0
  94. package/dist/supabase.d.ts.map +1 -0
  95. package/dist/supabase.js +18 -0
  96. package/dist/supabase.js.map +1 -0
  97. package/dist/ui/native.d.ts +34 -0
  98. package/dist/ui/native.d.ts.map +1 -0
  99. package/dist/ui/native.js +76 -0
  100. package/dist/ui/native.js.map +1 -0
  101. package/dist/ui/parts.d.ts +62 -0
  102. package/dist/ui/parts.d.ts.map +1 -0
  103. package/dist/ui/parts.js +110 -0
  104. package/dist/ui/parts.js.map +1 -0
  105. package/dist/ui/theme.d.ts +90 -0
  106. package/dist/ui/theme.d.ts.map +1 -0
  107. package/dist/ui/theme.js +61 -0
  108. package/dist/ui/theme.js.map +1 -0
  109. package/package.json +82 -3
  110. package/src/Annotator.tsx +279 -0
  111. package/src/Bubble.tsx +331 -0
  112. package/src/Feedback.tsx +1739 -0
  113. package/src/Overlay.tsx +61 -0
  114. package/src/Popover.tsx +1393 -0
  115. package/src/Tapthrough.tsx +171 -0
  116. package/src/core/annotations.ts +116 -0
  117. package/src/core/bubble.ts +45 -0
  118. package/src/core/controller.ts +119 -0
  119. package/src/core/diagnostics.ts +128 -0
  120. package/src/core/feedback.ts +589 -0
  121. package/src/core/flags.ts +31 -0
  122. package/src/core/ideas.ts +337 -0
  123. package/src/core/launch.ts +119 -0
  124. package/src/core/reset.ts +28 -0
  125. package/src/core/review.ts +148 -0
  126. package/src/core/url.ts +21 -0
  127. package/src/hooks.ts +15 -0
  128. package/src/index.ts +8 -0
  129. package/src/native-intent.ts +36 -0
  130. package/src/runtime.ts +30 -0
  131. package/src/screenshot.ts +64 -0
  132. package/src/supabase.ts +27 -0
  133. package/src/ui/native.ts +116 -0
  134. package/src/ui/parts.tsx +249 -0
  135. package/src/ui/theme.ts +75 -0
@@ -0,0 +1,1393 @@
1
+ import { type ReactNode, useEffect, useRef, useState } from 'react'
2
+ import {
3
+ ActivityIndicator,
4
+ Animated,
5
+ Linking,
6
+ Pressable,
7
+ ScrollView,
8
+ StyleSheet,
9
+ Text,
10
+ TextInput,
11
+ View,
12
+ type ViewStyle,
13
+ } from 'react-native'
14
+ import type { Bounds } from './core/bubble.js'
15
+ import type {
16
+ Checklist,
17
+ ChecklistReset,
18
+ ChecklistScenario,
19
+ NewerBuild,
20
+ RecheckItem,
21
+ SetupLine,
22
+ } from './core/feedback.js'
23
+ import { resetMessage } from './core/feedback.js'
24
+ import { type Idea, type IdeaStage, type OpenPr, stepTime } from './core/ideas.js'
25
+ import {
26
+ nextOpenScenario,
27
+ otherStates,
28
+ type Placement,
29
+ type Results,
30
+ type ScenarioProgress,
31
+ type SetupProgress,
32
+ scenarioProgress,
33
+ setupLineStates,
34
+ stepId,
35
+ } from './core/review.js'
36
+ import { useBackPress } from './Overlay.js'
37
+ import { useReduceMotion } from './ui/native.js'
38
+ import { Capsule, CircleButton, Icon, type IconName, Surface } from './ui/parts.js'
39
+ import { type Colors, SPRING, text, useColors } from './ui/theme.js'
40
+
41
+ /** Where the reviewer is inside the popover. Kept by the parent so it resumes there. */
42
+ export type Nav =
43
+ | { view: 'list' }
44
+ | { view: 'brief'; key: string }
45
+ | { view: 'setup'; key: string }
46
+ /** `index` is the step's position in the scenario's `steps`. */
47
+ | { view: 'step'; key: string; index: number }
48
+ | { view: 'done'; key: string }
49
+ | { view: 'explore'; key: string }
50
+ | { view: 'recheck'; id: string }
51
+ | { view: 'idea'; id: string }
52
+ /** The repo's other open PRs, to switch builds. */
53
+ | { view: 'prs' }
54
+
55
+ export type Direction = 'forward' | 'back'
56
+
57
+ export interface SetupStatus {
58
+ key: string
59
+ progress: SetupProgress
60
+ error?: string
61
+ }
62
+
63
+ export interface ReviewPopoverProps {
64
+ checklist: Checklist
65
+ results: Results
66
+ rechecked: Record<string, 'fixed' | 'still_broken'>
67
+ nav: Nav
68
+ direction: Direction
69
+ onNav: (next: Nav, direction: Direction) => void
70
+ placement: Placement
71
+ bounds: Bounds
72
+ full: boolean
73
+ onToggleFull: () => void
74
+ onClose: () => void
75
+ /** False when the app has no seed hook, so setup can only be described. */
76
+ canSetUp: boolean
77
+ setup: SetupStatus | null
78
+ onSetUp: (key: string) => void
79
+ /** Begins a scenario's steps (or, for another state, exploring) without setting up. */
80
+ onStart: (key: string) => void
81
+ onPass: (key: string, index: number) => void
82
+ onFail: (key: string, index: number) => void
83
+ onRecheck: (item: RecheckItem, result: 'fixed' | 'still_broken') => void
84
+ onReportOther: () => void
85
+ /**
86
+ * A staging or TestFlight build, with its version for the header: no PR to
87
+ * check, so the panel is just feedback and ideas.
88
+ */
89
+ staff: string | null
90
+ /** The app's recent ideas, newest first. */
91
+ ideas: Idea[]
92
+ onShareIdea: () => void
93
+ onAnswerIdea: (id: string, answers: string[]) => void
94
+ onIdeaAction: (id: string, action: 'build' | 'retry' | 'approve') => void
95
+ /** The idea whose request is in flight, to show a spinner on its button. */
96
+ ideaBusy: string | null
97
+ /** Open PRs with previews; null until loaded. */
98
+ pulls: OpenPr[] | null
99
+ onShowPulls: () => void
100
+ onSwitchPr: (pr: OpenPr) => void
101
+ name: string
102
+ onNameChange: (name: string) => void
103
+ error: string | null
104
+ }
105
+
106
+ interface Entry {
107
+ key: string
108
+ title: string
109
+ why?: string | null | undefined
110
+ setup?: SetupLine[] | undefined
111
+ scenario?: ChecklistScenario
112
+ /** A state from the library rather than the PR's plan. */
113
+ other: boolean
114
+ }
115
+
116
+ /** Scenario reasons are written for the PR comment; drop its code marks here. */
117
+ function plain(text: string | null | undefined) {
118
+ return text?.replace(/`([^`]*)`/g, '$1')
119
+ }
120
+
121
+ function entryFor(checklist: Checklist, key: string): Entry | undefined {
122
+ const scenario = checklist.scenarios.find((s) => s.key === key)
123
+ if (scenario) {
124
+ return {
125
+ key,
126
+ title: scenario.title,
127
+ why: plain(scenario.why),
128
+ setup: scenario.setup,
129
+ scenario,
130
+ other: false,
131
+ }
132
+ }
133
+ const state = checklist.library?.find((s) => s.key === key)
134
+ if (state)
135
+ return { key, title: state.title, why: plain(state.why), setup: state.setup, other: true }
136
+ return undefined
137
+ }
138
+
139
+ const navKey = (nav: Nav) =>
140
+ nav.view === 'list' || nav.view === 'prs'
141
+ ? nav.view
142
+ : nav.view === 'recheck' || nav.view === 'idea'
143
+ ? `${nav.view}:${nav.id}`
144
+ : nav.view === 'step'
145
+ ? `step:${nav.key}:${nav.index}`
146
+ : `${nav.view}:${nav.key}`
147
+
148
+ interface Screen {
149
+ title: string
150
+ subtitle?: string
151
+ /** Shows ‹ and goes back to the list. */
152
+ back?: boolean
153
+ body: ReactNode
154
+ footer?: ReactNode
155
+ }
156
+
157
+ /**
158
+ * The review popover: grows out of the Review button, lists what to test,
159
+ * sets the app up for a scenario and walks through its steps. The middle
160
+ * scrolls when it's long; the expand button turns it into a full-height sheet.
161
+ */
162
+ export function ReviewPopover(props: ReviewPopoverProps) {
163
+ const { nav, full, placement, bounds, onNav, onClose } = props
164
+ const colors = useColors()
165
+ const reduceMotion = useReduceMotion()
166
+ const shown = useRef(new Animated.Value(0)).current
167
+ const slide = useRef(new Animated.Value(0)).current
168
+ const fade = useRef(new Animated.Value(1)).current
169
+ const key = navKey(nav)
170
+ const firstKey = useRef(key)
171
+
172
+ useEffect(() => {
173
+ if (reduceMotion) {
174
+ shown.setValue(1)
175
+ return
176
+ }
177
+ Animated.spring(shown, { toValue: 1, ...SPRING, useNativeDriver: true }).start()
178
+ }, [shown, reduceMotion])
179
+
180
+ // Deeper slides in from the right, back from the left.
181
+ // biome-ignore lint/correctness/useExhaustiveDependencies: runs when the view changes
182
+ useEffect(() => {
183
+ if (key === firstKey.current || reduceMotion) return
184
+ firstKey.current = ''
185
+ slide.setValue(props.direction === 'back' ? -18 : 18)
186
+ fade.setValue(0)
187
+ Animated.parallel([
188
+ Animated.timing(slide, { toValue: 0, duration: 220, useNativeDriver: true }),
189
+ Animated.timing(fade, { toValue: 1, duration: 180, useNativeDriver: true }),
190
+ ]).start()
191
+ }, [key])
192
+
193
+ useBackPress(() => {
194
+ if (full) props.onToggleFull()
195
+ else if (nav.view === 'list' || nav.view === 'setup') onClose()
196
+ else onNav({ view: 'list' }, 'back')
197
+ })
198
+
199
+ const view = renderView(props, colors)
200
+ const frame: ViewStyle = full
201
+ ? { left: 8, right: 8, top: Math.max(bounds.insetTop - 8, 8), bottom: 8 }
202
+ : {
203
+ width: placement.width,
204
+ maxHeight: placement.maxHeight,
205
+ [placement.side]: placement.inset,
206
+ ...(placement.vertical === 'above'
207
+ ? { bottom: placement.offset }
208
+ : { top: placement.offset }),
209
+ }
210
+
211
+ return (
212
+ <Animated.View
213
+ style={[
214
+ styles.frame,
215
+ frame,
216
+ {
217
+ // No opacity here: fading a view that holds glass stops the glass rendering.
218
+ transform: [{ scale: shown.interpolate({ inputRange: [0, 1], outputRange: [0.6, 1] }) }],
219
+ transformOrigin: `${placement.side} ${placement.vertical === 'above' ? 'bottom' : 'top'}`,
220
+ },
221
+ ]}
222
+ accessibilityViewIsModal={full}
223
+ >
224
+ <Surface radius={full ? 38 : 30} style={[styles.surface, full && styles.grow]}>
225
+ <Animated.View
226
+ style={[
227
+ styles.column,
228
+ full && styles.grow,
229
+ { opacity: Animated.multiply(fade, shown), transform: [{ translateX: slide }] },
230
+ ]}
231
+ >
232
+ <View style={styles.header}>
233
+ {view.back ? (
234
+ <CircleButton
235
+ icon="chevron.left"
236
+ label="Back"
237
+ onPress={() => onNav({ view: 'list' }, 'back')}
238
+ />
239
+ ) : null}
240
+ <View style={[styles.headerText, !view.back && styles.headerTextAlone]}>
241
+ <Text style={[text.headline, { color: colors.label }]} numberOfLines={2}>
242
+ {view.title}
243
+ </Text>
244
+ {view.subtitle ? (
245
+ <Text style={[text.footnote, { color: colors.secondary }]} numberOfLines={1}>
246
+ {view.subtitle}
247
+ </Text>
248
+ ) : null}
249
+ </View>
250
+ <CircleButton
251
+ icon={
252
+ full ? 'arrow.down.right.and.arrow.up.left' : 'arrow.up.left.and.arrow.down.right'
253
+ }
254
+ label={full ? 'Shrink' : 'Expand to full screen'}
255
+ onPress={props.onToggleFull}
256
+ />
257
+ {full ? <CircleButton icon="xmark" label="Close" onPress={onClose} /> : null}
258
+ </View>
259
+ <ScrollView
260
+ style={full ? styles.grow : styles.scroll}
261
+ contentContainerStyle={styles.body}
262
+ keyboardShouldPersistTaps="handled"
263
+ showsVerticalScrollIndicator
264
+ >
265
+ {view.body}
266
+ {props.error ? (
267
+ <Text style={[text.footnote, { color: colors.fail }]} accessibilityRole="alert">
268
+ {props.error}
269
+ </Text>
270
+ ) : null}
271
+ </ScrollView>
272
+ {view.footer ? (
273
+ <View style={[styles.footer, full && styles.footerFull]}>{view.footer}</View>
274
+ ) : null}
275
+ </Animated.View>
276
+ </Surface>
277
+ </Animated.View>
278
+ )
279
+ }
280
+
281
+ function renderView(p: ReviewPopoverProps, colors: Colors): Screen {
282
+ const { nav, checklist } = p
283
+ if (nav.view === 'list') return listView(p, colors)
284
+ if (nav.view === 'prs') return prsView(p, colors)
285
+ if (nav.view === 'idea') {
286
+ const idea = p.ideas.find((i) => i.id === nav.id)
287
+ return idea ? ideaView(p, idea, colors) : listView(p, colors)
288
+ }
289
+ if (nav.view === 'recheck') {
290
+ const item = checklist.recheck?.find((r) => r.id === nav.id)
291
+ return item ? recheckView(p, item, colors) : listView(p, colors)
292
+ }
293
+ const entry = entryFor(checklist, nav.key)
294
+ if (!entry) return listView(p, colors)
295
+ switch (nav.view) {
296
+ case 'brief':
297
+ return briefView(p, entry, colors)
298
+ case 'setup':
299
+ return setupView(p, entry, colors)
300
+ case 'step':
301
+ return entry.scenario
302
+ ? stepView(p, entry, entry.scenario, nav.index, colors)
303
+ : listView(p, colors)
304
+ case 'done':
305
+ return doneView(p, entry, colors)
306
+ case 'explore':
307
+ return exploreView(p, entry, colors)
308
+ }
309
+ }
310
+
311
+ /* ---------------------------------- views --------------------------------- */
312
+
313
+ function listView(p: ReviewPopoverProps, colors: Colors): Screen {
314
+ const { checklist, results } = p
315
+ const scenarios = checklist.scenarios.filter((s) => s.steps.length > 0)
316
+ const recheck = checklist.recheck ?? []
317
+ const others = otherStates(checklist)
318
+ const counts = [
319
+ scenarios.length ? `${scenarios.length} to test` : null,
320
+ recheck.length ? `${recheck.length} to re-check` : null,
321
+ ].filter(Boolean)
322
+ return {
323
+ title: p.staff ? 'Feedback' : `PR #${checklist.pr}`,
324
+ subtitle: p.staff ?? (counts.join(' · ') || 'Nothing planned for this PR'),
325
+ body: (
326
+ <>
327
+ {checklist.newerBuild ? <NewerRow build={checklist.newerBuild} colors={colors} /> : null}
328
+ {checklist.reset ? <ResetRow reset={checklist.reset} colors={colors} /> : null}
329
+ {recheck.map((item) => {
330
+ const done = p.rechecked[item.id] ?? (item.stillBrokenHere ? 'still_broken' : undefined)
331
+ return (
332
+ <Row
333
+ key={item.id}
334
+ colors={colors}
335
+ lead={
336
+ done ? (
337
+ <Mark kind={done === 'fixed' ? 'pass' : 'fail'} colors={colors} />
338
+ ) : (
339
+ <Badge icon="arrow.clockwise" colors={colors} />
340
+ )
341
+ }
342
+ title={`Re-check: ${item.excerpt}`}
343
+ subtitle={
344
+ done === 'fixed' ? 'Fixed here' : done ? 'Still broken here' : reportedBy(item)
345
+ }
346
+ onPress={() => p.onNav({ view: 'recheck', id: item.id }, 'forward')}
347
+ />
348
+ )
349
+ })}
350
+ {scenarios.map((scenario) => {
351
+ const progress = scenarioProgress(scenario, results)
352
+ return (
353
+ <Row
354
+ key={scenario.key}
355
+ colors={colors}
356
+ lead={<ProgressMark progress={progress} colors={colors} />}
357
+ title={scenario.title}
358
+ subtitle={scenarioSubtitle(scenario, progress, p.canSetUp)}
359
+ onPress={() => p.onNav(openScenario(scenario, results), 'forward')}
360
+ />
361
+ )
362
+ })}
363
+ {others.length ? (
364
+ <>
365
+ <Text style={[text.footnote, styles.section, { color: colors.secondary }]}>
366
+ Other states
367
+ </Text>
368
+ {others.map((state) => (
369
+ <Row
370
+ key={state.key}
371
+ colors={colors}
372
+ lead={<Badge icon="arrow.right.circle" colors={colors} />}
373
+ title={state.title}
374
+ subtitle={
375
+ plain(state.why) ??
376
+ (p.canSetUp && state.setup?.length ? 'Sets itself up' : 'Look around')
377
+ }
378
+ onPress={() => p.onNav({ view: 'brief', key: state.key }, 'forward')}
379
+ />
380
+ ))}
381
+ </>
382
+ ) : null}
383
+ {p.ideas.length ? (
384
+ <>
385
+ <Text style={[text.footnote, styles.section, { color: colors.secondary }]}>Ideas</Text>
386
+ {p.ideas.slice(0, 5).map((idea) => (
387
+ <Row
388
+ key={idea.id}
389
+ colors={colors}
390
+ lead={
391
+ <Badge
392
+ icon="lightbulb"
393
+ colors={colors}
394
+ attention={
395
+ idea.status === 'needs_answers' ||
396
+ idea.status === 'ready_to_test' ||
397
+ Boolean(idea.warning)
398
+ }
399
+ />
400
+ }
401
+ title={ideaTitle(idea)}
402
+ subtitle={
403
+ idea.warning
404
+ ? `${idea.statusLine} · taking longer than usual`
405
+ : idea.status === 'failed' || idea.status === 'not_set_up'
406
+ ? `${idea.statusLine} · tap to see why`
407
+ : idea.statusLine
408
+ }
409
+ onPress={() => p.onNav({ view: 'idea', id: idea.id }, 'forward')}
410
+ />
411
+ ))}
412
+ </>
413
+ ) : null}
414
+ {p.staff ? (
415
+ p.ideas.length ? null : (
416
+ <Text style={[text.subhead, { color: colors.secondary }]}>
417
+ Something wrong? Report it, and it becomes a GitHub issue with a screenshot. Got an
418
+ idea? Claude writes it up, and can build it.
419
+ </Text>
420
+ )
421
+ ) : (
422
+ <Row
423
+ colors={colors}
424
+ lead={<Badge icon="arrow.triangle.branch" colors={colors} />}
425
+ title="Switch PR"
426
+ subtitle="Open another pull request’s build"
427
+ onPress={() => {
428
+ p.onShowPulls()
429
+ p.onNav({ view: 'prs' }, 'forward')
430
+ }}
431
+ />
432
+ )}
433
+ <ReviewerName name={p.name} onChange={p.onNameChange} colors={colors} />
434
+ </>
435
+ ),
436
+ footer: (
437
+ <View style={styles.pair}>
438
+ <Capsule title="Report Issue" kind="plain" onPress={p.onReportOther} style={styles.half} />
439
+ <Capsule title="Share Idea" kind="plain" onPress={p.onShareIdea} style={styles.half} />
440
+ </View>
441
+ ),
442
+ }
443
+ }
444
+
445
+ function ideaTitle(idea: Idea) {
446
+ if (idea.title) return idea.title
447
+ const flat = idea.body.replace(/\s+/g, ' ')
448
+ return flat.length > 60 ? `${flat.slice(0, 59)}…` : flat
449
+ }
450
+
451
+ function ideaView(p: ReviewPopoverProps, idea: Idea, colors: Colors): Screen {
452
+ const busy = p.ideaBusy === idea.id
453
+ const here = idea.prNumber !== null && idea.prNumber === p.checklist.pr
454
+ const links = [
455
+ idea.prUrl ? { title: `PR #${idea.prNumber}`, url: idea.prUrl } : null,
456
+ idea.issueUrl ? { title: `Issue #${idea.issueNumber}`, url: idea.issueUrl } : null,
457
+ ].filter((l): l is { title: string; url: string } => l !== null)
458
+ let footer: ReactNode = null
459
+ if (idea.actions.includes('build')) {
460
+ footer = (
461
+ <Capsule title="Build It" busy={busy} onPress={() => p.onIdeaAction(idea.id, 'build')} />
462
+ )
463
+ } else if (idea.actions.includes('approve')) {
464
+ footer = here ? (
465
+ <Capsule title="Approve" busy={busy} onPress={() => p.onIdeaAction(idea.id, 'approve')} />
466
+ ) : (
467
+ <Capsule
468
+ title="Open Build to Test"
469
+ onPress={() => {
470
+ const pr = p.pulls?.find((pull) => pull.number === idea.prNumber)
471
+ if (pr?.openUrl) p.onSwitchPr(pr)
472
+ else if (idea.previewUrl) Linking.openURL(idea.previewUrl)
473
+ }}
474
+ />
475
+ )
476
+ } else if (idea.actions.includes('retry')) {
477
+ footer = (
478
+ <Capsule
479
+ title="Try Again"
480
+ kind="plain"
481
+ busy={busy}
482
+ onPress={() => p.onIdeaAction(idea.id, 'retry')}
483
+ />
484
+ )
485
+ }
486
+ return {
487
+ title: ideaTitle(idea),
488
+ subtitle: idea.statusLine,
489
+ back: true,
490
+ body: (
491
+ <>
492
+ {idea.summary ? (
493
+ <Text style={[text.subhead, { color: colors.label }]}>{idea.summary}</Text>
494
+ ) : (
495
+ <Text style={[text.subhead, { color: colors.label }]}>“{idea.body}”</Text>
496
+ )}
497
+ {idea.warning ? (
498
+ <View style={[styles.newer, { backgroundColor: colors.fill }]}>
499
+ <Icon name="flag.fill" size={16} color={colors.fail} />
500
+ <View style={styles.rowText}>
501
+ <Text style={[text.footnote, { color: colors.label }]}>{idea.warning}</Text>
502
+ <LinkRow
503
+ links={[
504
+ idea.runUrl ? { title: 'View Run', url: idea.runUrl } : null,
505
+ idea.actionsUrl ? { title: 'Open Actions', url: idea.actionsUrl } : null,
506
+ ]}
507
+ colors={colors}
508
+ />
509
+ </View>
510
+ </View>
511
+ ) : null}
512
+ {idea.stages?.length ? (
513
+ <Timeline stages={idea.stages} colors={colors} />
514
+ ) : idea.note ? (
515
+ <Text style={[text.footnote, { color: colors.secondary }]}>{idea.note}</Text>
516
+ ) : null}
517
+ {!idea.stages?.length && (idea.status === 'grooming' || idea.status === 'building') ? (
518
+ <View style={styles.pair}>
519
+ <ActivityIndicator color={colors.secondary} />
520
+ <Text style={[text.footnote, styles.rowText, { color: colors.secondary }]}>
521
+ {idea.status === 'grooming'
522
+ ? 'Claude is reading the code and writing it up. This takes a minute or two.'
523
+ : 'Claude is building it. You’ll get a build to test when the PR’s preview is ready.'}
524
+ </Text>
525
+ </View>
526
+ ) : null}
527
+ {idea.actions.includes('answer') ? (
528
+ <IdeaAnswers
529
+ key={idea.id}
530
+ questions={idea.questions}
531
+ busy={busy}
532
+ onSend={(answers) => p.onAnswerIdea(idea.id, answers)}
533
+ colors={colors}
534
+ />
535
+ ) : null}
536
+ {idea.steps.length && idea.status !== 'needs_answers' ? (
537
+ <Section title="How to test it" colors={colors}>
538
+ <Numbered
539
+ items={idea.steps.map((s) => ({ title: s.action, detail: s.expect }))}
540
+ colors={colors}
541
+ />
542
+ </Section>
543
+ ) : null}
544
+ {idea.actions.includes('approve') ? (
545
+ <Text style={[text.footnote, { color: colors.secondary }]}>
546
+ {here
547
+ ? 'You’re on this idea’s build. Try the steps; if it’s right, approve it.'
548
+ : 'Open its build, try the steps, then approve it from there.'}
549
+ </Text>
550
+ ) : null}
551
+ <LinkRow
552
+ links={[
553
+ ...links,
554
+ idea.runUrl && !idea.warning ? { title: 'Workflow Run', url: idea.runUrl } : null,
555
+ ]}
556
+ colors={colors}
557
+ />
558
+ </>
559
+ ),
560
+ footer,
561
+ }
562
+ }
563
+
564
+ /** Small text links in a row, e.g. the issue, the PR and the workflow run. */
565
+ function LinkRow({
566
+ links,
567
+ colors,
568
+ }: {
569
+ links: ({ title: string; url: string } | null)[]
570
+ colors: Colors
571
+ }) {
572
+ const shown = links.filter((l): l is { title: string; url: string } => l !== null)
573
+ if (!shown.length) return null
574
+ return (
575
+ <View style={styles.links}>
576
+ {shown.map((link) => (
577
+ <Pressable
578
+ key={link.url}
579
+ onPress={() => Linking.openURL(link.url)}
580
+ accessibilityRole="link"
581
+ hitSlop={8}
582
+ >
583
+ <Text style={[text.footnote, styles.rowTitle, { color: colors.tint }]}>{link.title}</Text>
584
+ </Pressable>
585
+ ))}
586
+ </View>
587
+ )
588
+ }
589
+
590
+ /**
591
+ * Every step from sent to merged with its state, so it's always clear whether
592
+ * the idea reached GitHub, what's running now, and where it stopped.
593
+ */
594
+ function Timeline({ stages, colors }: { stages: IdeaStage[]; colors: Colors }) {
595
+ return (
596
+ <View style={styles.timeline} accessibilityRole="list">
597
+ {stages.map((stage, i) => {
598
+ const time = stage.state === 'done' ? stepTime(stage.at) : null
599
+ const last = i === stages.length - 1
600
+ return (
601
+ <View
602
+ key={stage.key}
603
+ style={styles.stage}
604
+ accessible
605
+ accessibilityLabel={`${stage.title}: ${STATE_LABEL[stage.state]}${stage.detail ? `. ${stage.detail}` : ''}`}
606
+ >
607
+ <View style={styles.stageRail}>
608
+ <StageMark state={stage.state} colors={colors} />
609
+ {last ? null : (
610
+ <View
611
+ style={[
612
+ styles.stageLine,
613
+ { backgroundColor: stage.state === 'done' ? colors.pass : colors.separator },
614
+ ]}
615
+ />
616
+ )}
617
+ </View>
618
+ <View style={[styles.rowText, !last && styles.stageGap]}>
619
+ <View style={styles.stageHead}>
620
+ <Text
621
+ style={[
622
+ text.subhead,
623
+ stage.state === 'waiting' ? null : styles.rowTitle,
624
+ { color: stage.state === 'waiting' ? colors.secondary : colors.label },
625
+ ]}
626
+ >
627
+ {stage.title}
628
+ </Text>
629
+ {time ? (
630
+ <Text style={[text.footnote, { color: colors.secondary }]}>{time}</Text>
631
+ ) : null}
632
+ </View>
633
+ {stage.detail ? (
634
+ <Text
635
+ style={[
636
+ text.footnote,
637
+ { color: stage.state === 'failed' ? colors.fail : colors.secondary },
638
+ ]}
639
+ >
640
+ {stage.detail}
641
+ </Text>
642
+ ) : null}
643
+ </View>
644
+ </View>
645
+ )
646
+ })}
647
+ </View>
648
+ )
649
+ }
650
+
651
+ const STATE_LABEL: Record<IdeaStage['state'], string> = {
652
+ done: 'done',
653
+ active: 'in progress',
654
+ waiting: 'not started',
655
+ failed: 'stopped',
656
+ }
657
+
658
+ function StageMark({ state, colors }: { state: IdeaStage['state']; colors: Colors }) {
659
+ if (state === 'done') return <Mark kind="pass" colors={colors} size={20} />
660
+ if (state === 'failed') return <Mark kind="fail" colors={colors} size={20} />
661
+ if (state === 'active') {
662
+ return (
663
+ <View style={styles.stageDot}>
664
+ <ActivityIndicator size="small" color={colors.tint} />
665
+ </View>
666
+ )
667
+ }
668
+ return <View style={[styles.stageDot, styles.stageHollow, { borderColor: colors.separator }]} />
669
+ }
670
+
671
+ /** One field per question; sending is enabled once every question has an answer. */
672
+ function IdeaAnswers({
673
+ questions,
674
+ busy,
675
+ onSend,
676
+ colors,
677
+ }: {
678
+ questions: string[]
679
+ busy: boolean
680
+ onSend: (answers: string[]) => void
681
+ colors: Colors
682
+ }) {
683
+ const [answers, setAnswers] = useState<string[]>(() => questions.map(() => ''))
684
+ const ready = answers.every((a) => a.trim() !== '')
685
+ return (
686
+ <View style={styles.sectionBlock}>
687
+ {questions.map((question, i) => (
688
+ // biome-ignore lint/suspicious/noArrayIndexKey: questions are fixed for the round
689
+ <View key={i} style={styles.question}>
690
+ <Text style={[text.subhead, styles.rowTitle, { color: colors.label }]}>{question}</Text>
691
+ <TextInput
692
+ value={answers[i]}
693
+ onChangeText={(value) =>
694
+ setAnswers((current) => current.map((a, j) => (j === i ? value : a)))
695
+ }
696
+ placeholder="Your answer"
697
+ placeholderTextColor={colors.secondary}
698
+ multiline
699
+ style={[
700
+ text.subhead,
701
+ styles.nameInput,
702
+ { color: colors.label, backgroundColor: colors.fill },
703
+ ]}
704
+ accessibilityLabel={question}
705
+ />
706
+ </View>
707
+ ))}
708
+ <Capsule
709
+ title="Send Answers"
710
+ busy={busy}
711
+ disabled={!ready}
712
+ onPress={() => onSend(answers.map((a) => a.trim()))}
713
+ />
714
+ </View>
715
+ )
716
+ }
717
+
718
+ function prsView(p: ReviewPopoverProps, colors: Colors): Screen {
719
+ const { pulls } = p
720
+ return {
721
+ title: 'Switch PR',
722
+ subtitle: pulls ? `${pulls.length} open with a preview` : 'Loading…',
723
+ back: true,
724
+ body: !pulls ? (
725
+ <ActivityIndicator color={colors.secondary} />
726
+ ) : pulls.length === 0 ? (
727
+ <Text style={[text.subhead, { color: colors.secondary }]}>
728
+ No other pull requests have a preview yet.
729
+ </Text>
730
+ ) : (
731
+ pulls.map((pr) => (
732
+ <Row
733
+ key={pr.number}
734
+ colors={colors}
735
+ lead={
736
+ pr.current ? (
737
+ <Mark kind="pass" colors={colors} />
738
+ ) : (
739
+ <Badge icon={pr.fromIdea ? 'lightbulb' : 'arrow.triangle.branch'} colors={colors} />
740
+ )
741
+ }
742
+ title={`#${pr.number} ${pr.title}`}
743
+ subtitle={
744
+ pr.current
745
+ ? 'Open now'
746
+ : pr.needsNewBuild
747
+ ? 'Changes native code: needs a new build of this app'
748
+ : [pr.fromIdea ? 'From an idea' : null, pr.author, pr.draft ? 'Draft' : null]
749
+ .filter(Boolean)
750
+ .join(' · ') || 'Open its build'
751
+ }
752
+ onPress={() => {
753
+ if (pr.current) p.onNav({ view: 'list' }, 'back')
754
+ else if (pr.openUrl) p.onSwitchPr(pr)
755
+ }}
756
+ />
757
+ ))
758
+ ),
759
+ }
760
+ }
761
+
762
+ /** A scenario not started opens its brief; one in progress opens its next step. */
763
+ function openScenario(scenario: ChecklistScenario, results: Results): Nav {
764
+ const progress = scenarioProgress(scenario, results)
765
+ if (progress.checked === 0) return { view: 'brief', key: scenario.key }
766
+ const open = scenario.steps.findIndex((step) => !results[stepId(scenario.key, step.index)])
767
+ return open >= 0
768
+ ? { view: 'step', key: scenario.key, index: open }
769
+ : { view: 'done', key: scenario.key }
770
+ }
771
+
772
+ function scenarioSubtitle(
773
+ scenario: ChecklistScenario,
774
+ progress: ScenarioProgress,
775
+ canSetUp: boolean,
776
+ ) {
777
+ const steps = `${progress.total} step${progress.total === 1 ? '' : 's'}`
778
+ if (progress.state === 'new') {
779
+ if (!scenario.setup?.length) return steps
780
+ return `${steps} · ${canSetUp ? 'sets itself up' : 'set up by hand'}`
781
+ }
782
+ if (progress.state === 'partial') return `${progress.checked} of ${progress.total} checked`
783
+ if (progress.state === 'failed') return `${progress.failed} failed`
784
+ return 'All passed'
785
+ }
786
+
787
+ function briefView(p: ReviewPopoverProps, entry: Entry, colors: Colors): Screen {
788
+ const steps = entry.scenario?.steps ?? []
789
+ const lines = entry.setup ?? []
790
+ const auto = p.canSetUp && lines.length > 0
791
+ const manual = !auto && lines.length > 0
792
+ return {
793
+ title: entry.title,
794
+ subtitle: entry.other
795
+ ? 'Another state of the app'
796
+ : `${steps.length} step${steps.length === 1 ? '' : 's'}`,
797
+ back: true,
798
+ body: (
799
+ <>
800
+ {entry.why ? (
801
+ <Text style={[text.subhead, { color: colors.label }]}>{entry.why}</Text>
802
+ ) : null}
803
+ {auto ? (
804
+ <Section title="Tapthrough will" colors={colors}>
805
+ {lines.map((line) => (
806
+ <SetupRow key={line.auto} line={line} state="waiting" colors={colors} plain />
807
+ ))}
808
+ </Section>
809
+ ) : null}
810
+ {manual ? (
811
+ <Section title="Set up by hand" colors={colors}>
812
+ <Numbered items={lines.map((line) => ({ title: line.manual }))} colors={colors} />
813
+ </Section>
814
+ ) : null}
815
+ {steps.length ? (
816
+ <Section title="You’ll check" colors={colors}>
817
+ <Numbered
818
+ items={steps.map((s) => ({ title: s.action, detail: s.expect }))}
819
+ colors={colors}
820
+ />
821
+ </Section>
822
+ ) : null}
823
+ {auto ? (
824
+ <Text style={[text.caption, { color: colors.secondary }]}>
825
+ This replaces the app’s local data and signs out whoever is signed in.
826
+ </Text>
827
+ ) : null}
828
+ </>
829
+ ),
830
+ footer: auto ? (
831
+ <>
832
+ <Capsule
833
+ title={entry.other ? 'Set Up' : 'Set Up and Start'}
834
+ onPress={() => p.onSetUp(entry.key)}
835
+ />
836
+ <Capsule title="Skip Setup" kind="plain" onPress={() => p.onStart(entry.key)} />
837
+ </>
838
+ ) : (
839
+ <Capsule
840
+ title={manual ? 'I’m Ready' : entry.other ? 'Look Around' : 'Start'}
841
+ onPress={() => p.onStart(entry.key)}
842
+ />
843
+ ),
844
+ }
845
+ }
846
+
847
+ function setupView(p: ReviewPopoverProps, entry: Entry, colors: Colors): Screen {
848
+ const lines = entry.setup ?? []
849
+ const setup = p.setup?.key === entry.key ? p.setup : null
850
+ const error = setup?.error
851
+ const states = setupLineStates(lines, setup?.progress ?? 'requesting')
852
+ return {
853
+ title: error ? 'Couldn’t set up' : 'Setting up',
854
+ subtitle: entry.title,
855
+ back: Boolean(error),
856
+ body: (
857
+ <>
858
+ {lines.map((line, i) => (
859
+ <SetupRow
860
+ key={line.auto}
861
+ line={line}
862
+ state={error ? 'waiting' : (states[i] ?? 'waiting')}
863
+ colors={colors}
864
+ />
865
+ ))}
866
+ {error ? (
867
+ <>
868
+ <Text style={[text.subhead, { color: colors.fail }]} accessibilityRole="alert">
869
+ {error}
870
+ </Text>
871
+ <Section title="Or set it up by hand" colors={colors}>
872
+ <Numbered items={lines.map((line) => ({ title: line.manual }))} colors={colors} />
873
+ </Section>
874
+ </>
875
+ ) : null}
876
+ </>
877
+ ),
878
+ footer: error ? (
879
+ <>
880
+ <Capsule title="Try Again" onPress={() => p.onSetUp(entry.key)} />
881
+ <Capsule title="Continue Without Setup" kind="plain" onPress={() => p.onStart(entry.key)} />
882
+ </>
883
+ ) : undefined,
884
+ }
885
+ }
886
+
887
+ function stepView(
888
+ p: ReviewPopoverProps,
889
+ entry: Entry,
890
+ scenario: ChecklistScenario,
891
+ index: number,
892
+ colors: Colors,
893
+ ): Screen {
894
+ const step = scenario.steps[index]
895
+ if (!step) return listView(p, colors)
896
+ const result = p.results[stepId(scenario.key, step.index)]
897
+ return {
898
+ title: entry.title,
899
+ subtitle: `Step ${index + 1} of ${scenario.steps.length}`,
900
+ back: true,
901
+ body: (
902
+ <>
903
+ <Text style={[text.headline, { color: colors.label }]}>{step.action}</Text>
904
+ <Text style={[text.subhead, { color: colors.secondary }]}>Expect: {step.expect}</Text>
905
+ {step.result?.carriedFrom && result === step.result.result ? (
906
+ <Text style={[text.footnote, { color: colors.secondary }]}>
907
+ {`${step.result.result === 'pass' ? 'Passed' : step.result.result === 'fail' ? 'Failed' : 'Skipped'} on ${step.result.carriedFrom.slice(0, 7)}. Kept because later pushes didn’t change this screen.`}
908
+ </Text>
909
+ ) : null}
910
+ </>
911
+ ),
912
+ footer: (
913
+ <View style={styles.pair}>
914
+ <Capsule
915
+ title="Fail"
916
+ kind="gray"
917
+ icon="xmark"
918
+ iconColor={colors.fail}
919
+ fillColor={result === 'fail' ? colors.fail : undefined}
920
+ accessibilityLabel={`Fail: ${step.action}`}
921
+ onPress={() => p.onFail(scenario.key, index)}
922
+ style={styles.half}
923
+ />
924
+ <Capsule
925
+ title="Pass"
926
+ kind="gray"
927
+ icon="checkmark"
928
+ iconColor={colors.pass}
929
+ fillColor={result === 'pass' ? colors.pass : undefined}
930
+ accessibilityLabel={`Pass: ${step.action}`}
931
+ onPress={() => p.onPass(scenario.key, index)}
932
+ style={styles.half}
933
+ />
934
+ </View>
935
+ ),
936
+ }
937
+ }
938
+
939
+ function recheckView(p: ReviewPopoverProps, item: RecheckItem, colors: Colors): Screen {
940
+ const done = p.rechecked[item.id] ?? (item.stillBrokenHere ? 'still_broken' : undefined)
941
+ return {
942
+ title: 'Re-check',
943
+ subtitle: reportedBy(item),
944
+ back: true,
945
+ body: (
946
+ <>
947
+ <Text style={[text.headline, { color: colors.label }]}>{item.excerpt}</Text>
948
+ <Text style={[text.subhead, { color: colors.secondary }]}>
949
+ Try it again on this build. Is it fixed?
950
+ </Text>
951
+ </>
952
+ ),
953
+ footer: (
954
+ <View style={styles.pair}>
955
+ <Capsule
956
+ title="Still Broken"
957
+ kind="gray"
958
+ icon="xmark"
959
+ iconColor={colors.fail}
960
+ fillColor={done === 'still_broken' ? colors.fail : undefined}
961
+ onPress={() => p.onRecheck(item, 'still_broken')}
962
+ style={styles.half}
963
+ />
964
+ <Capsule
965
+ title="Fixed"
966
+ kind="gray"
967
+ icon="checkmark"
968
+ iconColor={colors.pass}
969
+ fillColor={done === 'fixed' ? colors.pass : undefined}
970
+ onPress={() => p.onRecheck(item, 'fixed')}
971
+ style={styles.half}
972
+ />
973
+ </View>
974
+ ),
975
+ }
976
+ }
977
+
978
+ function doneView(p: ReviewPopoverProps, entry: Entry, colors: Colors): Screen {
979
+ const progress = entry.scenario ? scenarioProgress(entry.scenario, p.results) : null
980
+ const next = nextOpenScenario(p.checklist, p.results, entry.key)
981
+ return {
982
+ title: entry.title,
983
+ subtitle: 'Checked',
984
+ back: true,
985
+ body: (
986
+ <View style={styles.done}>
987
+ <Mark kind={progress?.failed ? 'fail' : 'pass'} colors={colors} size={52} />
988
+ <Text style={[text.title2, { color: colors.label }]}>
989
+ {progress?.failed ? `${progress.failed} failed` : 'All passed'}
990
+ </Text>
991
+ {progress ? (
992
+ <Text style={[text.subhead, { color: colors.secondary }]}>
993
+ {`${progress.passed} of ${progress.total} passed`}
994
+ </Text>
995
+ ) : null}
996
+ </View>
997
+ ),
998
+ footer: (
999
+ <>
1000
+ {next ? (
1001
+ <Capsule
1002
+ title={`Next: ${next.title}`}
1003
+ onPress={() => p.onNav(openScenario(next, p.results), 'forward')}
1004
+ />
1005
+ ) : (
1006
+ <Capsule title="All Done" onPress={p.onClose} />
1007
+ )}
1008
+ <Capsule
1009
+ title="Back to List"
1010
+ kind="plain"
1011
+ onPress={() => p.onNav({ view: 'list' }, 'back')}
1012
+ />
1013
+ </>
1014
+ ),
1015
+ }
1016
+ }
1017
+
1018
+ function exploreView(p: ReviewPopoverProps, entry: Entry, colors: Colors): Screen {
1019
+ return {
1020
+ title: entry.title,
1021
+ subtitle: 'Ready to look around',
1022
+ back: true,
1023
+ body: (
1024
+ <Text style={[text.subhead, { color: colors.label }]}>
1025
+ The app is in this state now. Look around, and report anything that seems off.
1026
+ </Text>
1027
+ ),
1028
+ footer: (
1029
+ <>
1030
+ <Capsule title="Report Something" onPress={p.onReportOther} />
1031
+ <Capsule
1032
+ title="Back to List"
1033
+ kind="plain"
1034
+ onPress={() => p.onNav({ view: 'list' }, 'back')}
1035
+ />
1036
+ </>
1037
+ ),
1038
+ }
1039
+ }
1040
+
1041
+ /* --------------------------------- pieces --------------------------------- */
1042
+
1043
+ function reportedBy(item: RecheckItem) {
1044
+ return [item.reporter, `reported on ${item.sha.slice(0, 7)}`].filter(Boolean).join(' · ')
1045
+ }
1046
+
1047
+ function Row({
1048
+ lead,
1049
+ title,
1050
+ subtitle,
1051
+ onPress,
1052
+ colors,
1053
+ }: {
1054
+ lead: ReactNode
1055
+ title: string
1056
+ subtitle: string
1057
+ onPress: () => void
1058
+ colors: Colors
1059
+ }) {
1060
+ return (
1061
+ <Pressable
1062
+ onPress={onPress}
1063
+ accessibilityRole="button"
1064
+ accessibilityLabel={`${title}. ${subtitle}`}
1065
+ style={({ pressed }) => [styles.row, pressed && { backgroundColor: colors.fill }]}
1066
+ >
1067
+ {lead}
1068
+ <View style={styles.rowText}>
1069
+ <Text style={[text.subhead, styles.rowTitle, { color: colors.label }]} numberOfLines={2}>
1070
+ {title}
1071
+ </Text>
1072
+ <Text style={[text.footnote, { color: colors.secondary }]} numberOfLines={1}>
1073
+ {subtitle}
1074
+ </Text>
1075
+ </View>
1076
+ <Icon name="chevron.right" size={13} color={colors.tertiary} />
1077
+ </Pressable>
1078
+ )
1079
+ }
1080
+
1081
+ function ResetRow({ reset, colors }: { reset: ChecklistReset; colors: Colors }) {
1082
+ return (
1083
+ <View style={[styles.newer, { backgroundColor: colors.fill }]}>
1084
+ <Icon name="arrow.clockwise" size={20} color={colors.secondary} />
1085
+ <View style={styles.rowText}>
1086
+ <Text style={[text.subhead, styles.rowTitle, { color: colors.label }]}>
1087
+ Checklist reset for this build
1088
+ </Text>
1089
+ <Text style={[text.footnote, { color: colors.secondary }]}>{resetMessage(reset)}</Text>
1090
+ </View>
1091
+ </View>
1092
+ )
1093
+ }
1094
+
1095
+ function NewerRow({ build, colors }: { build: NewerBuild; colors: Colors }) {
1096
+ return (
1097
+ <View style={[styles.newer, { backgroundColor: colors.fill }]}>
1098
+ <Icon name="arrow.down.circle.fill" size={26} color={colors.tint} />
1099
+ <View style={styles.rowText}>
1100
+ <Text style={[text.subhead, styles.rowTitle, { color: colors.label }]}>
1101
+ {build.hasFix ? 'Claude pushed a fix' : 'A newer build is ready'}
1102
+ </Text>
1103
+ <Text style={[text.footnote, { color: colors.secondary }]}>
1104
+ {`${build.sha.slice(0, 7)} · you’re on an older build`}
1105
+ </Text>
1106
+ </View>
1107
+ {build.openUrl ? (
1108
+ <Pressable
1109
+ onPress={() => Linking.openURL(build.openUrl as string)}
1110
+ accessibilityRole="link"
1111
+ accessibilityLabel="Open the newer build"
1112
+ style={({ pressed }) => [
1113
+ styles.pill,
1114
+ { backgroundColor: colors.tintFill },
1115
+ pressed && styles.pressed,
1116
+ ]}
1117
+ >
1118
+ <Text style={[text.subhead, styles.rowTitle, { color: colors.tint }]}>Open</Text>
1119
+ </Pressable>
1120
+ ) : null}
1121
+ </View>
1122
+ )
1123
+ }
1124
+
1125
+ function Mark({
1126
+ kind,
1127
+ colors,
1128
+ size = 26,
1129
+ }: {
1130
+ kind: 'pass' | 'fail'
1131
+ colors: Colors
1132
+ size?: number
1133
+ }) {
1134
+ return (
1135
+ <View
1136
+ style={[
1137
+ styles.mark,
1138
+ { width: size, height: size, borderRadius: size / 2 },
1139
+ { backgroundColor: kind === 'pass' ? colors.pass : colors.fail },
1140
+ ]}
1141
+ >
1142
+ <Icon
1143
+ name={kind === 'pass' ? 'checkmark' : 'xmark'}
1144
+ size={size * 0.45}
1145
+ color="#FFFFFF"
1146
+ weight="bold"
1147
+ />
1148
+ </View>
1149
+ )
1150
+ }
1151
+
1152
+ function Badge({
1153
+ icon,
1154
+ colors,
1155
+ attention = false,
1156
+ }: {
1157
+ icon: IconName
1158
+ colors: Colors
1159
+ /** Adds the tape-colored dot: something here waits on you. */
1160
+ attention?: boolean
1161
+ }) {
1162
+ return (
1163
+ <View style={[styles.mark, { backgroundColor: colors.tintFill }]}>
1164
+ <Icon name={icon} size={13} color={colors.tint} />
1165
+ {attention ? <View style={styles.attention} /> : null}
1166
+ </View>
1167
+ )
1168
+ }
1169
+
1170
+ function ProgressMark({ progress, colors }: { progress: ScenarioProgress; colors: Colors }) {
1171
+ if (progress.state === 'passed') return <Mark kind="pass" colors={colors} />
1172
+ if (progress.state === 'failed') return <Mark kind="fail" colors={colors} />
1173
+ const started = progress.state === 'partial'
1174
+ return (
1175
+ <View
1176
+ style={[styles.mark, styles.ring, { borderColor: started ? colors.tint : colors.tertiary }]}
1177
+ >
1178
+ {started ? (
1179
+ <Text style={[styles.ringText, { color: colors.tint }]} allowFontScaling={false}>
1180
+ {progress.checked}
1181
+ </Text>
1182
+ ) : null}
1183
+ </View>
1184
+ )
1185
+ }
1186
+
1187
+ const LINE_ICON: Record<SetupLine['kind'], IconName> = {
1188
+ account: 'person.fill',
1189
+ data: 'tray.full.fill',
1190
+ flag: 'flag.fill',
1191
+ screen: 'arrow.right',
1192
+ }
1193
+
1194
+ function SetupRow({
1195
+ line,
1196
+ state,
1197
+ colors,
1198
+ plain = false,
1199
+ }: {
1200
+ line: SetupLine
1201
+ state: 'waiting' | 'active' | 'done'
1202
+ colors: Colors
1203
+ /** In the brief: just the icon, no progress. */
1204
+ plain?: boolean
1205
+ }) {
1206
+ return (
1207
+ <View style={[styles.setupRow, !plain && state === 'waiting' && styles.waiting]}>
1208
+ <View
1209
+ style={[
1210
+ styles.mark,
1211
+ {
1212
+ backgroundColor:
1213
+ !plain && state === 'done' ? colors.pass : plain ? colors.tintFill : colors.fill,
1214
+ },
1215
+ ]}
1216
+ >
1217
+ {!plain && state === 'active' ? (
1218
+ <ActivityIndicator size="small" />
1219
+ ) : !plain && state === 'done' ? (
1220
+ <Icon name="checkmark" size={12} color="#FFFFFF" weight="bold" />
1221
+ ) : (
1222
+ <Icon
1223
+ name={LINE_ICON[line.kind]}
1224
+ size={12}
1225
+ color={plain ? colors.tint : colors.secondary}
1226
+ />
1227
+ )}
1228
+ </View>
1229
+ <Text style={[text.subhead, styles.rowText, { color: colors.label }]}>{line.auto}</Text>
1230
+ </View>
1231
+ )
1232
+ }
1233
+
1234
+ function Section({
1235
+ title,
1236
+ colors,
1237
+ children,
1238
+ }: {
1239
+ title: string
1240
+ colors: Colors
1241
+ children: ReactNode
1242
+ }) {
1243
+ return (
1244
+ <View style={styles.sectionBlock}>
1245
+ <Text style={[text.footnote, styles.sectionTitle, { color: colors.secondary }]}>{title}</Text>
1246
+ {children}
1247
+ </View>
1248
+ )
1249
+ }
1250
+
1251
+ function Numbered({
1252
+ items,
1253
+ colors,
1254
+ }: {
1255
+ items: { title: string; detail?: string }[]
1256
+ colors: Colors
1257
+ }) {
1258
+ return (
1259
+ <View style={styles.numbered}>
1260
+ {items.map((item, i) => (
1261
+ // biome-ignore lint/suspicious/noArrayIndexKey: a fixed list; the number is part of the content
1262
+ <View key={`${i}:${item.title}`} style={styles.numberedRow}>
1263
+ <Text
1264
+ style={[text.subhead, styles.number, { color: colors.secondary }]}
1265
+ >{`${i + 1}.`}</Text>
1266
+ <View style={styles.rowText}>
1267
+ <Text style={[text.subhead, { color: colors.label }]}>{item.title}</Text>
1268
+ {item.detail ? (
1269
+ <Text style={[text.footnote, { color: colors.secondary }]}>{item.detail}</Text>
1270
+ ) : null}
1271
+ </View>
1272
+ </View>
1273
+ ))}
1274
+ </View>
1275
+ )
1276
+ }
1277
+
1278
+ /** Who's reviewing: a slim line once known, a field until then. Shown on the PR. */
1279
+ export function ReviewerName({
1280
+ name,
1281
+ onChange,
1282
+ colors,
1283
+ }: {
1284
+ name: string
1285
+ onChange: (name: string) => void
1286
+ colors: Colors
1287
+ }) {
1288
+ const [editing, setEditing] = useState(name.trim() === '')
1289
+ if (!editing) {
1290
+ return (
1291
+ <View style={styles.nameRow}>
1292
+ <Text style={[text.footnote, { color: colors.secondary }]}>
1293
+ Reviewing as <Text style={{ color: colors.label, fontWeight: '600' }}>{name.trim()}</Text>
1294
+ </Text>
1295
+ <Pressable onPress={() => setEditing(true)} accessibilityRole="button" hitSlop={12}>
1296
+ <Text style={[text.footnote, styles.rowTitle, { color: colors.tint }]}>Change</Text>
1297
+ </Pressable>
1298
+ </View>
1299
+ )
1300
+ }
1301
+ return (
1302
+ <TextInput
1303
+ value={name}
1304
+ onChangeText={onChange}
1305
+ onBlur={() => setEditing(name.trim() === '')}
1306
+ placeholder="Your name (shown on the PR)"
1307
+ placeholderTextColor={colors.secondary}
1308
+ autoCapitalize="words"
1309
+ returnKeyType="done"
1310
+ style={[
1311
+ text.subhead,
1312
+ styles.nameInput,
1313
+ { color: colors.label, backgroundColor: colors.fill },
1314
+ ]}
1315
+ accessibilityLabel="Your name"
1316
+ />
1317
+ )
1318
+ }
1319
+
1320
+ const styles = StyleSheet.create({
1321
+ frame: { position: 'absolute' },
1322
+ surface: { padding: 8, flexShrink: 1 },
1323
+ grow: { flex: 1 },
1324
+ column: { flexShrink: 1 },
1325
+ header: { flexDirection: 'row', alignItems: 'center', gap: 10, paddingBottom: 6 },
1326
+ headerText: { flex: 1, minWidth: 0 },
1327
+ headerTextAlone: { paddingLeft: 10 },
1328
+ scroll: { flexGrow: 0, flexShrink: 1 },
1329
+ body: { paddingHorizontal: 10, paddingTop: 4, paddingBottom: 10, gap: 10 },
1330
+ footer: { gap: 2, paddingTop: 4 },
1331
+ footerFull: { paddingBottom: 18 },
1332
+ row: {
1333
+ flexDirection: 'row',
1334
+ alignItems: 'center',
1335
+ gap: 12,
1336
+ paddingVertical: 9,
1337
+ paddingHorizontal: 10,
1338
+ marginHorizontal: -10,
1339
+ borderRadius: 22,
1340
+ },
1341
+ rowText: { flex: 1, minWidth: 0 },
1342
+ rowTitle: { fontWeight: '600' },
1343
+ section: { fontWeight: '600', marginTop: 8 },
1344
+ sectionBlock: { gap: 8, marginTop: 4 },
1345
+ sectionTitle: { fontWeight: '600' },
1346
+ newer: {
1347
+ flexDirection: 'row',
1348
+ alignItems: 'center',
1349
+ gap: 10,
1350
+ padding: 10,
1351
+ borderRadius: 22,
1352
+ marginHorizontal: -4,
1353
+ },
1354
+ pill: { paddingVertical: 6, paddingHorizontal: 16, borderRadius: 999 },
1355
+ pressed: { opacity: 0.7 },
1356
+ mark: { width: 26, height: 26, borderRadius: 13, alignItems: 'center', justifyContent: 'center' },
1357
+ ring: { borderWidth: 2, backgroundColor: 'transparent' },
1358
+ ringText: { fontSize: 11, fontWeight: '700' },
1359
+ setupRow: { flexDirection: 'row', alignItems: 'center', gap: 12 },
1360
+ waiting: { opacity: 0.45 },
1361
+ numbered: { gap: 8 },
1362
+ numberedRow: { flexDirection: 'row', gap: 8 },
1363
+ number: { width: 20, fontWeight: '600', fontVariant: ['tabular-nums'] },
1364
+ pair: { flexDirection: 'row', gap: 8 },
1365
+ half: { flex: 1 },
1366
+ done: { alignItems: 'center', gap: 4, paddingVertical: 10 },
1367
+ nameRow: {
1368
+ flexDirection: 'row',
1369
+ justifyContent: 'space-between',
1370
+ alignItems: 'center',
1371
+ marginTop: 6,
1372
+ },
1373
+ nameInput: { borderRadius: 14, paddingHorizontal: 12, paddingVertical: 10, marginTop: 6 },
1374
+ question: { gap: 2 },
1375
+ links: { flexDirection: 'row', flexWrap: 'wrap', gap: 14, marginTop: 4 },
1376
+ timeline: { marginTop: 2 },
1377
+ stage: { flexDirection: 'row', gap: 10 },
1378
+ stageRail: { width: 20, alignItems: 'center' },
1379
+ stageLine: { width: 2, flex: 1, marginVertical: 2, borderRadius: 1 },
1380
+ stageGap: { paddingBottom: 12 },
1381
+ stageHead: { flexDirection: 'row', justifyContent: 'space-between', gap: 8 },
1382
+ stageDot: { width: 20, height: 20, alignItems: 'center', justifyContent: 'center' },
1383
+ stageHollow: { borderWidth: 2, borderRadius: 10, width: 16, height: 16, margin: 2 },
1384
+ attention: {
1385
+ position: 'absolute',
1386
+ top: -1,
1387
+ right: -1,
1388
+ width: 9,
1389
+ height: 9,
1390
+ borderRadius: 5,
1391
+ backgroundColor: '#ecd88e',
1392
+ },
1393
+ })