paseo-beads 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.
@@ -0,0 +1,666 @@
1
+ import type { PluginTheme } from "@getpaseo/plugin";
2
+ import { Icon } from "@getpaseo/plugin/client/react-native";
3
+ import { type ReactNode } from "react";
4
+ import { Pressable, Text, View } from "react-native";
5
+ import type { Alert, Blocker, IssueDetail, Recommendation, SearchResult, Track } from "../shared/beads";
6
+ import {
7
+ alertHeadline,
8
+ percentDone,
9
+ priorityLabel,
10
+ priorityTone,
11
+ severityTone,
12
+ stateIconName,
13
+ stateLabel,
14
+ stateTone,
15
+ statusIconName,
16
+ statusLabel,
17
+ toneColor,
18
+ waitsOnLabel,
19
+ type Tone,
20
+ } from "./format";
21
+ import { MarkdownView } from "./markdown-view";
22
+ import type { ProjectModel, WorkItem, WorkState } from "./project";
23
+ import type { PanelStyles } from "./styles";
24
+
25
+ interface Common {
26
+ readonly styles: PanelStyles;
27
+ readonly theme: PluginTheme;
28
+ }
29
+
30
+ export function SectionHeader({ styles, title, meta }: Common & { title: string; meta?: string | null }) {
31
+ return (
32
+ <View style={styles.sectionHeader}>
33
+ <Text style={styles.sectionTitle}>{title}</Text>
34
+ {meta === undefined || meta === null ? null : <Text style={styles.sectionMeta}>{meta}</Text>}
35
+ </View>
36
+ );
37
+ }
38
+
39
+ /** The issue identifier: the one facet that gets a container so it anchors a row. */
40
+ export function IdentFacet({ styles, id }: Common & { id: string }) {
41
+ return (
42
+ <View style={styles.facetIdent}>
43
+ <Text style={styles.facetIdentText}>{id}</Text>
44
+ </View>
45
+ );
46
+ }
47
+
48
+ /**
49
+ * Status as icon plus text. Status never carries colour — priority owns the
50
+ * colour channel — so the icon is drawn in the muted foreground.
51
+ */
52
+ export function StatusFacet({ styles, theme, status }: Common & { status: string }) {
53
+ return (
54
+ <View style={styles.statusChip}>
55
+ <Icon name={statusIconName(status)} size={12} color={theme.colors.foregroundMuted} />
56
+ <Text style={styles.statusChipText}>{statusLabel(status)}</Text>
57
+ </View>
58
+ );
59
+ }
60
+
61
+ /** Priority as its tone colour plus its label; renders nothing when absent. */
62
+ export function PriorityFacet({ styles, theme, priority }: Common & { priority: number | null }) {
63
+ const label = priorityLabel(priority);
64
+ if (label === null) return null;
65
+ return (
66
+ <View style={styles.facet}>
67
+ <View style={[styles.facetDot, { backgroundColor: toneColor(theme, priorityTone(priority)) }]} />
68
+ <Text style={styles.facetStrongText}>{label}</Text>
69
+ </View>
70
+ );
71
+ }
72
+
73
+ /** A plain `label value` facet. Renders nothing when the value is absent. */
74
+ export function Facet({
75
+ styles,
76
+ label,
77
+ value,
78
+ strong,
79
+ }: Common & { label?: string; value: string | null; strong?: boolean }) {
80
+ if (value === null || value.length === 0) return null;
81
+ return (
82
+ <View style={styles.facet}>
83
+ {label === undefined ? null : <Text style={styles.facetLabel}>{label}</Text>}
84
+ <Text style={strong === true ? styles.facetStrongText : styles.facetText}>{value}</Text>
85
+ </View>
86
+ );
87
+ }
88
+
89
+ /** Wrapping container for facets; keeps every list row on the same metadata grid. */
90
+ export function FacetRow({ styles, children }: Common & { children: ReactNode }) {
91
+ return <View style={styles.facetRow}>{children}</View>;
92
+ }
93
+
94
+ /**
95
+ * The one shared row shape: a coloured rail plus a bold title, a structured facet
96
+ * row, and an optional note. The rail encodes priority for issue rows and
97
+ * severity/risk where no priority exists.
98
+ */
99
+ export function RailRow({
100
+ styles,
101
+ theme,
102
+ tone,
103
+ title,
104
+ meta,
105
+ facets,
106
+ note,
107
+ selected,
108
+ accessibilityLabel,
109
+ onPress,
110
+ children,
111
+ }: Common & {
112
+ tone: Tone;
113
+ title: string;
114
+ meta?: string | null;
115
+ /** Structured facets; preferred over `meta` for issue-shaped rows. */
116
+ facets?: ReactNode;
117
+ note?: string | null;
118
+ selected?: boolean;
119
+ accessibilityLabel?: string;
120
+ onPress?: () => void;
121
+ children?: ReactNode;
122
+ }) {
123
+ const body = (
124
+ <View style={styles.railBody}>
125
+ <Text style={styles.rowTitle} numberOfLines={2}>
126
+ {title}
127
+ </Text>
128
+ {facets === undefined || facets === null ? null : <View style={styles.facetRow}>{facets}</View>}
129
+ {meta === undefined || meta === null ? null : <Text style={styles.rowMeta}>{meta}</Text>}
130
+ {note === undefined || note === null ? null : (
131
+ <Text style={styles.rowNote} numberOfLines={2}>
132
+ {note}
133
+ </Text>
134
+ )}
135
+ {children}
136
+ </View>
137
+ );
138
+ const rail = <View style={[styles.rail, { backgroundColor: toneColor(theme, tone) }]} />;
139
+
140
+ if (onPress === undefined) {
141
+ return (
142
+ <View style={styles.railRow}>
143
+ {rail}
144
+ {body}
145
+ </View>
146
+ );
147
+ }
148
+ return (
149
+ <Pressable
150
+ accessibilityRole="button"
151
+ accessibilityLabel={accessibilityLabel ?? title}
152
+ accessibilityState={{ selected: selected === true }}
153
+ onPress={onPress}
154
+ style={({ pressed }) => [
155
+ styles.railRow,
156
+ selected === true || pressed ? styles.railRowSelected : null,
157
+ ]}
158
+ >
159
+ {rail}
160
+ {body}
161
+ </Pressable>
162
+ );
163
+ }
164
+
165
+ /** Derived work state as icon plus text, drawn in the state's own tone. */
166
+ export function WorkStateFacet({ styles, theme, state }: Common & { state: WorkState }) {
167
+ const tone = stateTone(state);
168
+ const color = tone === "neutral" ? theme.colors.foregroundMuted : toneColor(theme, tone);
169
+ return (
170
+ <View style={styles.statusChip}>
171
+ <Icon name={stateIconName(state)} size={12} color={color} />
172
+ <Text style={styles.statusChipText}>{stateLabel(state)}</Text>
173
+ </View>
174
+ );
175
+ }
176
+
177
+ /** Raw statuses that the derived state already says, so repeating them is noise. */
178
+ const PLAIN_STATUSES: readonly string[] = ["open", "in_progress", "closed"];
179
+
180
+ /**
181
+ * What the whole project has in common, so a facet row can leave it out: a
182
+ * priority, type or label that every live item shares tells nothing apart.
183
+ */
184
+ export interface FacetContext {
185
+ readonly showPriority: boolean;
186
+ readonly showType: boolean;
187
+ readonly commonLabels: ReadonlySet<string>;
188
+ }
189
+
190
+ export function facetContext(project: ProjectModel): FacetContext {
191
+ return {
192
+ showPriority: project.priorityVaries,
193
+ showType: project.typeVaries,
194
+ commonLabels: project.commonLabels,
195
+ };
196
+ }
197
+
198
+ /** Labels shown per row before the rest is summarised as `+N`. */
199
+ const ROW_LABEL_LIMIT = 3;
200
+
201
+ /**
202
+ * The facets that tell one piece of work from another. Whatever the whole
203
+ * project shares is left out. Labels are the project's own vocabulary, so they
204
+ * appear exactly as written and are never interpreted.
205
+ */
206
+ export function WorkFacets({
207
+ styles,
208
+ theme,
209
+ item,
210
+ showState,
211
+ context,
212
+ }: Common & { item: WorkItem; showState: boolean; context: FacetContext }) {
213
+ const rawStatus = item.status.trim().toLowerCase();
214
+ const labels = [...new Set(item.labels)].filter((label) => !context.commonLabels.has(label));
215
+ return (
216
+ <>
217
+ <IdentFacet styles={styles} theme={theme} id={item.id} />
218
+ {showState ? <WorkStateFacet styles={styles} theme={theme} state={item.state} /> : null}
219
+ {context.showPriority ? <PriorityFacet styles={styles} theme={theme} priority={item.priority} /> : null}
220
+ <Facet
221
+ styles={styles}
222
+ theme={theme}
223
+ value={PLAIN_STATUSES.includes(rawStatus) ? null : statusLabel(item.status)}
224
+ />
225
+ <Facet styles={styles} theme={theme} value={item.assignee === null ? null : `@${item.assignee}`} />
226
+ {item.critical ? <Text style={styles.facetAccentText}>critical chain</Text> : null}
227
+ <Facet styles={styles} theme={theme} value={context.showType ? item.type : null} />
228
+ {labels.slice(0, ROW_LABEL_LIMIT).map((label) => (
229
+ <Text key={label} style={styles.labelFacet}>
230
+ {label}
231
+ </Text>
232
+ ))}
233
+ {labels.length > ROW_LABEL_LIMIT ? (
234
+ <Text style={styles.facetText}>+{labels.length - ROW_LABEL_LIMIT}</Text>
235
+ ) : null}
236
+ <Facet
237
+ styles={styles}
238
+ theme={theme}
239
+ value={
240
+ item.state === "done"
241
+ ? null
242
+ : item.blockedBy.length > 0
243
+ ? waitsOnLabel(item.blockedBy)
244
+ : item.heldVia === null
245
+ ? null
246
+ : `${waitsOnLabel(item.inheritedBlockedBy)} via ${item.heldVia}`
247
+ }
248
+ />
249
+ <Facet
250
+ styles={styles}
251
+ theme={theme}
252
+ value={item.unblocksCount === 0 || item.state === "done" ? null : `unblocks ${item.unblocksCount}`}
253
+ />
254
+ </>
255
+ );
256
+ }
257
+
258
+ /** Spoken form of a work item: every fact the facets show, in words. */
259
+ export function workAccessibility(item: WorkItem): string {
260
+ return accessibilityFacts([
261
+ `${item.id}, ${item.title}`,
262
+ stateLabel(item.state),
263
+ priorityLabel(item.priority) === null ? null : `priority ${priorityLabel(item.priority)}`,
264
+ item.assignee === null ? null : `assigned to ${item.assignee}`,
265
+ item.labels.length === 0 ? null : `labels ${item.labels.join(", ")}`,
266
+ item.critical ? "on the critical chain" : null,
267
+ item.blockedBy.length === 0 ? null : `waits on ${item.blockedBy.join(", ")}`,
268
+ item.heldVia === null ? null : `its parent ${item.heldVia} waits on ${item.inheritedBlockedBy.join(", ")}`,
269
+ item.unblocksCount === 0 ? null : `unblocks ${item.unblocksCount}`,
270
+ ]);
271
+ }
272
+
273
+ /** One piece of work as a list row, coloured by its derived state. */
274
+ export function WorkRow({
275
+ styles,
276
+ theme,
277
+ item,
278
+ showState,
279
+ context,
280
+ note,
281
+ selected,
282
+ onSelect,
283
+ }: Common & {
284
+ item: WorkItem;
285
+ showState: boolean;
286
+ context: FacetContext;
287
+ note?: string | null;
288
+ selected: boolean;
289
+ onSelect: (issueId: string) => void;
290
+ }) {
291
+ return (
292
+ <RailRow
293
+ styles={styles}
294
+ theme={theme}
295
+ tone={stateTone(item.state)}
296
+ title={item.title}
297
+ facets={
298
+ <WorkFacets styles={styles} theme={theme} item={item} showState={showState} context={context} />
299
+ }
300
+ note={note ?? null}
301
+ selected={selected}
302
+ accessibilityLabel={workAccessibility(item)}
303
+ onPress={() => onSelect(item.id)}
304
+ />
305
+ );
306
+ }
307
+
308
+ /** A thin done/total bar; the numbers beside it stay the source of truth. */
309
+ export function ProgressBar({
310
+ styles,
311
+ theme,
312
+ done,
313
+ total,
314
+ wide,
315
+ }: Common & { done: number; total: number; wide?: boolean }) {
316
+ const percent = percentDone(done, total);
317
+ return (
318
+ <View
319
+ style={[styles.progressTrack, wide === true ? styles.progressTrackWide : null]}
320
+ accessibilityRole="progressbar"
321
+ accessibilityLabel={`${done} of ${total} done`}
322
+ accessibilityValue={{ min: 0, max: 100, now: percent }}
323
+ >
324
+ <View style={[styles.progressFill, { width: `${percent}%`, backgroundColor: theme.colors.statusSuccess }]} />
325
+ </View>
326
+ );
327
+ }
328
+
329
+ export function Empty({ styles, message }: Common & { message: string }) {
330
+ return <Text style={styles.muted}>{message}</Text>;
331
+ }
332
+
333
+ export function RecommendationRow({
334
+ styles,
335
+ theme,
336
+ recommendation,
337
+ selected,
338
+ onSelect,
339
+ }: Common & {
340
+ recommendation: Recommendation;
341
+ selected: boolean;
342
+ onSelect: (issueId: string) => void;
343
+ }) {
344
+ const note = [
345
+ recommendation.blockedBy.length === 0 ? null : `blocked by ${recommendation.blockedBy.join(", ")}`,
346
+ recommendation.unblocks.length === 0 ? null : `unblocks ${recommendation.unblocks.join(", ")}`,
347
+ recommendation.reasons[0] ?? null,
348
+ ]
349
+ .filter((part): part is string => part !== null)
350
+ .join(" · ");
351
+
352
+ return (
353
+ <RailRow
354
+ styles={styles}
355
+ theme={theme}
356
+ tone={priorityTone(recommendation.priority)}
357
+ title={recommendation.title}
358
+ facets={
359
+ <>
360
+ <IdentFacet styles={styles} theme={theme} id={recommendation.id} />
361
+ <StatusFacet styles={styles} theme={theme} status={recommendation.status} />
362
+ <PriorityFacet styles={styles} theme={theme} priority={recommendation.priority} />
363
+ <Facet
364
+ styles={styles}
365
+ theme={theme}
366
+ value={recommendation.assignee === null ? null : `@${recommendation.assignee}`}
367
+ />
368
+ <Facet styles={styles} theme={theme} value={recommendation.type} />
369
+ <Facet
370
+ styles={styles}
371
+ theme={theme}
372
+ value={recommendation.claimable ? "claimable" : "not claimable"}
373
+ />
374
+ </>
375
+ }
376
+ note={note.length === 0 ? null : note}
377
+ selected={selected}
378
+ accessibilityLabel={accessibilityFacts([
379
+ `${recommendation.id}, ${recommendation.title}`,
380
+ `status ${statusLabel(recommendation.status)}`,
381
+ priorityLabel(recommendation.priority) === null
382
+ ? "no priority"
383
+ : `priority ${priorityLabel(recommendation.priority)}`,
384
+ recommendation.assignee === null ? null : `assigned to ${recommendation.assignee}`,
385
+ recommendation.type,
386
+ recommendation.claimable ? "claimable" : "not claimable",
387
+ recommendation.blockedBy.length === 0 ? null : `blocked by ${recommendation.blockedBy.length}`,
388
+ recommendation.unblocks.length === 0 ? null : `unblocks ${recommendation.unblocks.length}`,
389
+ ])}
390
+ onPress={() => onSelect(recommendation.id)}
391
+ />
392
+ );
393
+ }
394
+
395
+ export function TrackBlock({
396
+ styles,
397
+ theme,
398
+ track,
399
+ selectedId,
400
+ onSelect,
401
+ }: Common & { track: Track; selectedId: string | null; onSelect: (issueId: string) => void }) {
402
+ return (
403
+ <View style={styles.trackBlock}>
404
+ <Text style={styles.sectionMeta}>
405
+ {track.id} · {track.totalItems} item{track.totalItems === 1 ? "" : "s"}
406
+ {track.totalItems > track.items.length ? ` (${track.items.length} shown)` : ""}
407
+ {track.reason === null ? "" : ` · ${track.reason}`}
408
+ </Text>
409
+ {track.items.map((item) => (
410
+ <RailRow
411
+ key={`${track.id}:${item.id}`}
412
+ styles={styles}
413
+ theme={theme}
414
+ tone={priorityTone(item.priority)}
415
+ title={item.title}
416
+ facets={
417
+ <>
418
+ <IdentFacet styles={styles} theme={theme} id={item.id} />
419
+ <StatusFacet styles={styles} theme={theme} status={item.status} />
420
+ <PriorityFacet styles={styles} theme={theme} priority={item.priority} />
421
+ <Facet
422
+ styles={styles}
423
+ theme={theme}
424
+ value={item.unblocks.length === 0 ? null : `unblocks ${item.unblocks.length}`}
425
+ />
426
+ </>
427
+ }
428
+ selected={selectedId === item.id}
429
+ accessibilityLabel={accessibilityFacts([
430
+ `${item.id}, ${item.title}`,
431
+ `in ${track.id}`,
432
+ `status ${statusLabel(item.status)}`,
433
+ priorityLabel(item.priority) === null ? "no priority" : `priority ${priorityLabel(item.priority)}`,
434
+ item.unblocks.length === 0 ? null : `unblocks ${item.unblocks.length}`,
435
+ ])}
436
+ onPress={() => onSelect(item.id)}
437
+ />
438
+ ))}
439
+ </View>
440
+ );
441
+ }
442
+
443
+ export function BlockerRow({
444
+ styles,
445
+ theme,
446
+ blocker,
447
+ selectedId,
448
+ onSelect,
449
+ }: Common & { blocker: Blocker; selectedId: string | null; onSelect: (issueId: string) => void }) {
450
+ return (
451
+ <RailRow
452
+ styles={styles}
453
+ theme={theme}
454
+ // A Blocker carries no priority and no status, so risk keeps the colour here.
455
+ tone={blocker.actionable ? "warning" : "danger"}
456
+ title={blocker.title}
457
+ facets={
458
+ <>
459
+ <IdentFacet styles={styles} theme={theme} id={blocker.id} />
460
+ <Facet styles={styles} theme={theme} value={`unblocks ${blocker.unblocksCount}`} strong />
461
+ <Facet
462
+ styles={styles}
463
+ theme={theme}
464
+ value={blocker.actionable ? "actionable" : "not actionable"}
465
+ />
466
+ </>
467
+ }
468
+ note={blocker.unblocks.length === 0 ? null : blocker.unblocks.join(", ")}
469
+ selected={selectedId === blocker.id}
470
+ accessibilityLabel={accessibilityFacts([
471
+ `blocker ${blocker.id}, ${blocker.title}`,
472
+ `unblocks ${blocker.unblocksCount}`,
473
+ blocker.actionable ? "actionable" : "not actionable",
474
+ ])}
475
+ onPress={() => onSelect(blocker.id)}
476
+ />
477
+ );
478
+ }
479
+
480
+ export function AlertRow({
481
+ styles,
482
+ theme,
483
+ alert,
484
+ selectedId,
485
+ onSelect,
486
+ }: Common & { alert: Alert; selectedId: string | null; onSelect: (issueId: string) => void }) {
487
+ // Alerts have severity but no priority, so severity keeps the colour channel.
488
+ const tone = severityTone(alert.severity);
489
+ const facets = (
490
+ <>
491
+ {alert.issueId === null ? null : <IdentFacet styles={styles} theme={theme} id={alert.issueId} />}
492
+ <Facet styles={styles} theme={theme} value={alert.severity} strong />
493
+ <Facet styles={styles} theme={theme} value={alert.type} />
494
+ </>
495
+ );
496
+ if (alert.issueId === null) {
497
+ return (
498
+ <RailRow
499
+ styles={styles}
500
+ theme={theme}
501
+ tone={tone}
502
+ title={alert.message}
503
+ facets={facets}
504
+ note={alert.suggestedAction}
505
+ />
506
+ );
507
+ }
508
+ return (
509
+ <RailRow
510
+ styles={styles}
511
+ theme={theme}
512
+ tone={tone}
513
+ title={alertHeadline(alert)}
514
+ facets={facets}
515
+ note={alert.suggestedAction}
516
+ selected={selectedId === alert.issueId}
517
+ accessibilityLabel={accessibilityFacts([
518
+ `${alert.issueId}, ${alert.message}`,
519
+ `${alert.severity} alert`,
520
+ alert.type,
521
+ alert.suggestedAction,
522
+ ])}
523
+ onPress={() => onSelect(alert.issueId ?? "")}
524
+ />
525
+ );
526
+ }
527
+
528
+ export function SearchResultRow({
529
+ styles,
530
+ theme,
531
+ result,
532
+ selectedId,
533
+ onSelect,
534
+ }: Common & { result: SearchResult; selectedId: string | null; onSelect: (issueId: string) => void }) {
535
+ return (
536
+ <RailRow
537
+ styles={styles}
538
+ theme={theme}
539
+ // Search results carry neither priority nor status; accent marks relevance.
540
+ tone="accent"
541
+ title={result.title}
542
+ facets={
543
+ <>
544
+ <IdentFacet styles={styles} theme={theme} id={result.id} />
545
+ <Facet
546
+ styles={styles}
547
+ theme={theme}
548
+ label="score"
549
+ value={result.score === null ? null : result.score.toFixed(3)}
550
+ />
551
+ </>
552
+ }
553
+ selected={selectedId === result.id}
554
+ accessibilityLabel={accessibilityFacts([
555
+ `search result ${result.id}, ${result.title}`,
556
+ result.score === null ? null : `score ${result.score.toFixed(3)}`,
557
+ ])}
558
+ onPress={() => onSelect(result.id)}
559
+ />
560
+ );
561
+ }
562
+
563
+ /** A titled inspector section whose body is rendered as bounded Markdown. */
564
+ function DetailSection({ styles, theme, label, value }: Common & { label: string; value: string | null }) {
565
+ if (value === null || value.trim().length === 0) return null;
566
+ return (
567
+ <View style={styles.detailSection}>
568
+ <Text style={styles.detailSectionLabel}>{label}</Text>
569
+ <MarkdownView styles={styles} theme={theme} source={value} />
570
+ </View>
571
+ );
572
+ }
573
+
574
+ export function IssueDetailView({ styles, theme, issue }: Common & { issue: IssueDetail }) {
575
+ const relations = [
576
+ issue.parent === null ? null : `parent ${issue.parent}`,
577
+ issue.labels.length === 0 ? null : `labels ${issue.labels.join(", ")}`,
578
+ issue.dependencies.length === 0
579
+ ? null
580
+ : `depends on ${issue.dependencies.map(refLabel).join(", ")}`,
581
+ issue.dependents.length === 0 ? null : `blocks ${issue.dependents.map(refLabel).join(", ")}`,
582
+ ].filter((part): part is string => part !== null);
583
+
584
+ const timestamps = [
585
+ issue.createdAt === null ? null : `created ${issue.createdAt}`,
586
+ issue.updatedAt === null ? null : `updated ${issue.updatedAt}`,
587
+ issue.closedAt === null ? null : `closed ${issue.closedAt}`,
588
+ issue.closeReason === null ? null : `reason ${issue.closeReason}`,
589
+ ]
590
+ .filter((part): part is string => part !== null)
591
+ .join(" · ");
592
+
593
+ return (
594
+ <View style={styles.detailStack}>
595
+ <View style={styles.detailHeadBlock}>
596
+ <Text
597
+ style={styles.detailTitle}
598
+ accessibilityRole="header"
599
+ accessibilityLabel={accessibilityFacts([
600
+ issue.title,
601
+ issue.id,
602
+ `status ${statusLabel(issue.status)}`,
603
+ priorityLabel(issue.priority) === null ? "no priority" : `priority ${priorityLabel(issue.priority)}`,
604
+ issue.type,
605
+ issue.assignee === null ? null : `assigned to ${issue.assignee}`,
606
+ ])}
607
+ >
608
+ {issue.title}
609
+ </Text>
610
+ <View style={styles.facetRow}>
611
+ <IdentFacet styles={styles} theme={theme} id={issue.id} />
612
+ <StatusFacet styles={styles} theme={theme} status={issue.status} />
613
+ <PriorityFacet styles={styles} theme={theme} priority={issue.priority} />
614
+ <Facet styles={styles} theme={theme} value={issue.type} />
615
+ <Facet
616
+ styles={styles}
617
+ theme={theme}
618
+ value={issue.assignee === null ? null : `@${issue.assignee}`}
619
+ />
620
+ </View>
621
+ {relations.length === 0 ? null : (
622
+ <View style={styles.metaRow}>
623
+ {relations.map((relation) => (
624
+ <Text key={relation} style={styles.tagText}>
625
+ {relation}
626
+ </Text>
627
+ ))}
628
+ </View>
629
+ )}
630
+ </View>
631
+ <DetailSection styles={styles} theme={theme} label="Description" value={issue.description} />
632
+ <DetailSection styles={styles} theme={theme} label="Design" value={issue.design} />
633
+ <DetailSection
634
+ styles={styles}
635
+ theme={theme}
636
+ label="Acceptance criteria"
637
+ value={issue.acceptanceCriteria}
638
+ />
639
+ <DetailSection styles={styles} theme={theme} label="Notes" value={issue.notes} />
640
+ {issue.comments.length === 0 ? null : (
641
+ <View style={styles.detailSection}>
642
+ <Text style={styles.detailSectionLabel}>Comments ({issue.comments.length})</Text>
643
+ {issue.comments.slice(0, 5).map((comment) => (
644
+ <View key={comment.id} style={styles.commentBlock}>
645
+ <Text style={styles.commentByline}>
646
+ {comment.author ?? "unknown"}
647
+ {comment.createdAt === null ? "" : ` · ${comment.createdAt}`}
648
+ </Text>
649
+ <MarkdownView styles={styles} theme={theme} source={comment.text} />
650
+ </View>
651
+ ))}
652
+ </View>
653
+ )}
654
+ {timestamps.length === 0 ? null : <Text style={styles.monoMeta}>{timestamps}</Text>}
655
+ </View>
656
+ );
657
+ }
658
+
659
+ function refLabel(ref: IssueDetail["dependencies"][number]): string {
660
+ return `${ref.id}${ref.status === null ? "" : ` (${ref.status})`}`;
661
+ }
662
+
663
+ /** Joins accessibility facts into one comma-separated label, dropping absent ones. */
664
+ export function accessibilityFacts(parts: readonly (string | null)[]): string {
665
+ return parts.filter((part): part is string => part !== null && part.length > 0).join(", ");
666
+ }