@krapwoo/ds-viewer 0.3.0 → 0.4.1

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,556 @@
1
+ import React, { useCallback, useEffect, useRef, useState } from 'react';
2
+ import { Platform, Pressable, ScrollView, StyleSheet, Text, View } from 'react-native';
3
+ import { CATALOG_COLOR, CATALOG_LAYOUT, CATALOG_RADIUS, CATALOG_SPACE, CATALOG_TYPE } from './tokens';
4
+ import { DS_VIEWER_SECRET_HEADER } from './catalogNavigation';
5
+ import { daysAgoLabel, isAlreadyUpToDateError, resolvePanelPhase, type UpdatePlanView, type UpdateStatusView } from './updatePanelState';
6
+ import type { UpdateNotice } from './types';
7
+
8
+ function isWeb(): boolean {
9
+ return Platform.OS === 'web' && typeof window !== 'undefined';
10
+ }
11
+
12
+ type Endpoint = { baseUrl: string; secret: string };
13
+
14
+ async function authedFetch(endpoint: Endpoint, path: string, init: RequestInit = {}): Promise<Response> {
15
+ return fetch(`${endpoint.baseUrl}${path}`, { ...init, headers: { ...init.headers, [DS_VIEWER_SECRET_HEADER]: endpoint.secret } });
16
+ }
17
+
18
+ /** Everything visually shared across phases: the versions row, the file list, a colored note box,
19
+ * a step list, and a dark log block — one definition each, reused by every `case` below. */
20
+ function VersionsHeader({ current, latest, breaking, releasedAt }: { current: string; latest: string; breaking: boolean; releasedAt?: string }) {
21
+ return (
22
+ <View>
23
+ <View style={styles.versionsRow}>
24
+ <Text style={styles.versionsText}>{current}</Text>
25
+ <Text style={styles.arrow}>→</Text>
26
+ <Text style={styles.versionsText}>{latest}</Text>
27
+ <View style={[styles.pill, breaking && styles.pillMajor]}>
28
+ <Text style={[styles.pillText, breaking && styles.pillTextMajor]}>{breaking ? 'Major' : 'Minor'}</Text>
29
+ </View>
30
+ </View>
31
+ <Text style={styles.muted}>{`You’re on ${current}.${releasedAt ? ` ${daysAgoLabel(releasedAt, Date.now())}` : ''}`}</Text>
32
+ </View>
33
+ );
34
+ }
35
+
36
+ function FilesList({ files }: { files: UpdatePlanView['files'] }) {
37
+ return (
38
+ <View style={styles.block}>
39
+ <Text style={styles.h3}>Files that would change</Text>
40
+ <View style={styles.filesBox}>
41
+ {files.map((file, i) => (
42
+ <View key={file.path} style={[styles.fileRow, i > 0 && styles.fileRowBorder]}>
43
+ <Text style={styles.filePath}>{file.path}</Text>
44
+ <Text style={[styles.fileReason, file.dirty && styles.fileReasonDirty]}>{file.dirty ? 'uncommitted' : file.reason}</Text>
45
+ </View>
46
+ ))}
47
+ </View>
48
+ </View>
49
+ );
50
+ }
51
+
52
+ /** Same shape as `FilesList`, but with no right-hand reason column — the `success` phase's
53
+ * `status.files` carries plain paths, never a reason (Task 16 Step 2 table: a dedicated,
54
+ * smaller render here is simplest rather than over-generalizing `FilesList` for one caller). */
55
+ function PlainFilesList({ files }: { files: string[] }) {
56
+ return (
57
+ <View style={styles.filesBox}>
58
+ {files.map((path, i) => (
59
+ <View key={path} style={[styles.fileRow, i > 0 && styles.fileRowBorder]}>
60
+ <Text style={styles.filePath}>{path}</Text>
61
+ </View>
62
+ ))}
63
+ </View>
64
+ );
65
+ }
66
+
67
+ const NOTE_BOX_VARIANT = {
68
+ muted: { box: { backgroundColor: CATALOG_COLOR.surfaceMuted }, text: { color: CATALOG_COLOR.text } },
69
+ warn: { box: { backgroundColor: CATALOG_COLOR.warningSubtle, borderWidth: 1, borderColor: CATALOG_COLOR.warningBorder }, text: { color: CATALOG_COLOR.warning } },
70
+ err: { box: { backgroundColor: CATALOG_COLOR.dangerSubtle, borderWidth: 1, borderColor: CATALOG_COLOR.dangerBorder }, text: { color: CATALOG_COLOR.danger } },
71
+ ok: { box: { backgroundColor: CATALOG_COLOR.successSubtle }, text: { color: CATALOG_COLOR.success } },
72
+ } as const;
73
+
74
+ function NoteBox({ variant, children }: { variant: keyof typeof NOTE_BOX_VARIANT; children: React.ReactNode }) {
75
+ const v = NOTE_BOX_VARIANT[variant];
76
+ return (
77
+ <View style={[styles.noteBox, v.box]}>
78
+ <Text style={[styles.noteBoxText, v.text]}>{children}</Text>
79
+ </View>
80
+ );
81
+ }
82
+
83
+ /** `state` is a three-way `'done' | 'now' | 'todo'` (Important finding, Fable correction pass —
84
+ * the approved mockup's own `updating` state shows a step mid-flight as a distinct spinner-style
85
+ * icon, `ic now`, never collapsed into the same boolean `done`/not-`done` the `failure` phase's
86
+ * own two-item list still uses below). `failedStep` marks one step `fail` regardless of its own
87
+ * `state` — the failure phase's steps list is built fresh, inline, never reusing `status.steps`
88
+ * from a prior `updating` snapshot. */
89
+ function Steps({ steps, failedStep }: { steps: { label: string; state: 'done' | 'now' | 'todo' }[]; failedStep?: string }) {
90
+ return (
91
+ <View style={styles.steps}>
92
+ {steps.map((step) => {
93
+ const failed = failedStep !== undefined && step.label === failedStep;
94
+ const icon = failed ? 'fail' : step.state;
95
+ return (
96
+ <View key={step.label} style={styles.stepRow}>
97
+ <View
98
+ style={[
99
+ styles.stepIcon,
100
+ icon === 'done' && styles.stepIconDone,
101
+ icon === 'now' && styles.stepIconNow,
102
+ icon === 'todo' && styles.stepIconTodo,
103
+ icon === 'fail' && styles.stepIconFail,
104
+ ]}
105
+ >
106
+ <Text style={styles.stepIconGlyph}>{icon === 'done' ? '✓' : icon === 'fail' ? '!' : ''}</Text>
107
+ </View>
108
+ <Text style={failed ? styles.stepLabelFail : icon === 'now' ? styles.stepLabelNow : styles.stepLabel}>
109
+ {/* Errata 10: the in-flight step reads like the mockup's "Applying 1 migration…". */}
110
+ {icon === 'now' && !step.label.endsWith('…') ? `${step.label}…` : step.label}
111
+ </Text>
112
+ </View>
113
+ );
114
+ })}
115
+ </View>
116
+ );
117
+ }
118
+
119
+ function LogBlock({ children }: { children: string }) {
120
+ // Real npm logs run to dozens of lines; cap the height so "Copy log" stays in view. The region
121
+ // is keyboard-focusable on web so the overflow can be scrolled without a pointer.
122
+ return (
123
+ <ScrollView
124
+ style={styles.logBox}
125
+ accessibilityLabel="Update log"
126
+ {...(Platform.OS === 'web' ? ({ tabIndex: 0 } as Record<string, unknown>) : {})}
127
+ >
128
+ <Text style={styles.logText}>{children}</Text>
129
+ </ScrollView>
130
+ );
131
+ }
132
+
133
+ /** An inline, code-styled chip for a terminal command (the approved mockup's own `code.cmd`) —
134
+ * used wherever this panel's copy names a recovery command (Minor finding, Fable correction
135
+ * pass: the plan previously rendered every such command as plain muted text). */
136
+ function Code({ children }: { children: string }) {
137
+ return <Text style={styles.code}>{children}</Text>;
138
+ }
139
+
140
+ /** The `success` phase's content, shared between the `update === null` path (Errata 1c — the
141
+ * normal case, since `update.json` becomes `null` once latest equals current) and the defensive
142
+ * `update` still non-null path. Reads `status.latest`/`status.files`/`status.doctorSummary`,
143
+ * never `update.latest`/`plan.files` — `plan` is gone by the time this renders (the page has
144
+ * reloaded onto the new version). */
145
+ function SuccessContent({ status }: { status: Extract<UpdateStatusView, { phase: 'success' }> }) {
146
+ return (
147
+ <>
148
+ <View style={styles.versionsRow}>
149
+ <Text style={styles.versionsText}>{status.latest}</Text>
150
+ <View style={[styles.pill, styles.pillSuccess]}>
151
+ <Text style={[styles.pillText, styles.pillTextSuccess]}>Updated</Text>
152
+ </View>
153
+ </View>
154
+ <NoteBox variant="ok">
155
+ <Text style={styles.noteBoxBold}>{`Updated to ${status.latest}.`}</Text>
156
+ {' Changes are not committed — review them in your editor.'}
157
+ </NoteBox>
158
+ <View style={styles.block}>
159
+ <Text style={styles.h3}>Changed</Text>
160
+ <PlainFilesList files={status.files} />
161
+ </View>
162
+ <View style={styles.block}>
163
+ <Text style={styles.h3}>Doctor</Text>
164
+ <Text style={styles.muted}>{status.doctorSummary}</Text>
165
+ </View>
166
+ </>
167
+ );
168
+ }
169
+
170
+ /** The `failure` phase's headline/body copy, keyed off which step failed — `runUpdate`/
171
+ * `performUpdate` each name their own distinct recovery command (Important finding, Fable
172
+ * correction pass: one hardcoded headline/body for every failure was wrong for two of the
173
+ * three). */
174
+ function failureCopy(failedStep: string, current: string): { headline: string; body: React.ReactNode } {
175
+ if (/install/i.test(failedStep)) {
176
+ return {
177
+ headline: 'The update stopped while installing.',
178
+ body: <>Your files weren’t migrated. Recover with <Code>npx ds-viewer update</Code> in your terminal.</>,
179
+ };
180
+ }
181
+ if (/migrat/i.test(failedStep)) {
182
+ return {
183
+ headline: 'The update installed, but migrating failed.',
184
+ body: <>Recover with <Code>{`npx ds-viewer migrate --from ${current}`}</Code> in your terminal.</>,
185
+ };
186
+ }
187
+ return {
188
+ headline: 'The update installed and migrated, but doctor failed to run.',
189
+ body: <>Recover with <Code>npx ds-viewer doctor</Code> in your terminal.</>,
190
+ };
191
+ }
192
+
193
+ /**
194
+ * Design §5's update page (`#ds-viewer-update`), approved mockup direction "C · Update page".
195
+ * Fetches `/update/status` once on mount, then `/plan` — `resolvePanelPhase` (Task 15) picks which
196
+ * of the mockup's 10 states to render from those two pieces of data, polled (never pushed) at a
197
+ * plain interval while an update is in flight.
198
+ *
199
+ * Errata 1c: `update` is `UpdateNotice | null` — `update.json` becomes `null` once latest equals
200
+ * current, which is exactly what happens the moment a successful update's reload lands on the new
201
+ * version. When `update` is `null`, the plan is never fetched; the panel shows the success state
202
+ * read from `status` alone when one is in flight/just finished, else a plain up-to-date note.
203
+ */
204
+ export function UpdatePanel({
205
+ update,
206
+ endpoint,
207
+ appName,
208
+ headingRef,
209
+ }: {
210
+ update: UpdateNotice | null;
211
+ endpoint?: Endpoint;
212
+ appName: string;
213
+ headingRef?: React.Ref<View>;
214
+ }) {
215
+ const [status, setStatus] = useState<UpdateStatusView>({ phase: 'idle' });
216
+ const [planResult, setPlanResult] = useState<UpdatePlanView | { error: string } | undefined>(undefined);
217
+ // Critical finding, Fable correction pass: while this is true, the endpoint's own `/update` POST
218
+ // is in flight (re-planning before it can even start) — **Update now** must disable itself for
219
+ // that whole window, not just once the server reports `'updating'`, or a second click fires a
220
+ // second `POST /update` the endpoint's own `starting` flag would otherwise have to refuse.
221
+ const [starting, setStarting] = useState(false);
222
+ const pollRef = useRef<ReturnType<typeof setInterval> | undefined>(undefined);
223
+
224
+ const fetchStatus = useCallback(async () => {
225
+ if (!endpoint || !isWeb()) return;
226
+ try {
227
+ const res = await authedFetch(endpoint, '/update/status');
228
+ setStatus((await res.json()) as UpdateStatusView);
229
+ } catch {
230
+ // Errata 2a: the endpoint's own process exits mid-restart — a failed poll while an update
231
+ // was in flight or already restarting means "still restarting," not "idle again."
232
+ setStatus((prev) => (prev.phase === 'updating' || prev.phase === 'restarting' ? { phase: 'restarting' } : prev));
233
+ }
234
+ }, [endpoint]);
235
+
236
+ const fetchPlan = useCallback(async () => {
237
+ if (!endpoint || !isWeb()) return;
238
+ // Important finding, Fable correction pass: an unhandled rejection here (the endpoint
239
+ // unreachable, CORS-blocked, or mid-restart) previously left `planResult` `undefined` forever
240
+ // — the panel sat on "Checking…" with no way out. The same error shape `buildUpdatePlan`
241
+ // itself returns on failure drives the existing `offline` phase.
242
+ try {
243
+ const res = await authedFetch(endpoint, '/plan', { method: 'POST' });
244
+ setPlanResult((await res.json()) as UpdatePlanView | { error: string });
245
+ } catch {
246
+ setPlanResult({ error: "Couldn't prepare the update. You may be offline, or npm didn't respond. Nothing was changed." });
247
+ }
248
+ }, [endpoint]);
249
+
250
+ useEffect(() => {
251
+ // Errata 1c: never call `fetchPlan` when there is no update to plan for.
252
+ fetchStatus().then(() => {
253
+ if (update) fetchPlan();
254
+ });
255
+ }, [fetchStatus, fetchPlan, update]);
256
+
257
+ const phase = resolvePanelPhase(status, planResult);
258
+
259
+ // Polls `/update/status` at a plain interval while an update is in flight — design §5 never
260
+ // asks for push/websocket progress, only "the panel shows progress."
261
+ useEffect(() => {
262
+ clearInterval(pollRef.current);
263
+ if (phase === 'updating' || phase === 'restarting') pollRef.current = setInterval(fetchStatus, 700);
264
+ return () => clearInterval(pollRef.current);
265
+ }, [phase, fetchStatus]);
266
+
267
+ // Restart handoff step 4 (design §5): once the server reports "restarting", Metro itself (not
268
+ // this endpoint — the endpoint's own process is about to exit) is polled on the *page's own
269
+ // origin* until it answers again, then the page reloads onto the new version. Errata 2b: reload
270
+ // only once at least one probe has already failed, so the old Metro answering briefly (before
271
+ // it actually goes down) can't cause a reload loop back onto the old version.
272
+ useEffect(() => {
273
+ if (phase !== 'restarting' || !isWeb()) return;
274
+ let hadFailure = false;
275
+ const interval = setInterval(() => {
276
+ fetch(window.location.origin, { method: 'HEAD' })
277
+ .then((res) => {
278
+ if (res.ok && hadFailure) {
279
+ clearInterval(interval);
280
+ window.location.reload();
281
+ }
282
+ })
283
+ .catch(() => {
284
+ hadFailure = true; // still restarting — keep polling.
285
+ });
286
+ }, 500);
287
+ return () => clearInterval(interval);
288
+ }, [phase]);
289
+
290
+ const startUpdate = useCallback(async () => {
291
+ if (!endpoint) return;
292
+ setStarting(true);
293
+ try {
294
+ const res = await authedFetch(endpoint, '/update', { method: 'POST' });
295
+ // A non-202 (409 dirty/already-running, or 502 "couldn't prepare") means a file was dirtied
296
+ // — or the plan went stale — after this panel's own `ready` plan was fetched; re-fetching the
297
+ // plan surfaces the `dirty`/`offline` phase instead of silently staying on `ready` forever
298
+ // (Minor finding, Fable correction pass).
299
+ if (res.status !== 202) {
300
+ await fetchPlan();
301
+ return;
302
+ }
303
+ await fetchStatus();
304
+ } catch {
305
+ // Important finding, Fable correction pass: same reasoning as `fetchPlan` above — a failed
306
+ // `POST /update` must not leave the panel stuck mid-click with no way forward.
307
+ setPlanResult({ error: "Couldn't prepare the update. You may be offline, or npm didn't respond. Nothing was changed." });
308
+ } finally {
309
+ setStarting(false);
310
+ }
311
+ }, [endpoint, fetchPlan, fetchStatus]);
312
+
313
+ const plan = planResult && !('error' in planResult) ? planResult : undefined;
314
+
315
+ // The breadcrumb and heading render for every phase (Important finding, Fable correction pass:
316
+ // the approved mockup's own `frame()` always renders `<crumb>{appName} / DS Viewer</crumb>` and
317
+ // the title "Update DS Viewer" above the 512px column — this plan previously rendered neither,
318
+ // which also left `check:catalog`'s own "every page has a heading" check with nothing to find
319
+ // once the footer link existed). `content` is computed per phase below, then wrapped once.
320
+ let content: React.ReactNode = null;
321
+
322
+ if (!update) {
323
+ // Errata 1c: no update known (disabled, offline, or genuinely up to date). `status` still
324
+ // reflects a just-finished update read from the (surviving) endpoint across the reload.
325
+ content =
326
+ status.phase === 'success' ? (
327
+ <SuccessContent status={status} />
328
+ ) : (
329
+ <NoteBox variant="ok">You’re already up to date.</NoteBox>
330
+ );
331
+ } else if (phase === 'ready' && plan) {
332
+ content = (
333
+ <>
334
+ <VersionsHeader current={update.current} latest={update.latest} breaking={update.breaking} releasedAt={update.releasedAt} />
335
+ {update.summary.length > 0 && (
336
+ <View style={styles.block}>
337
+ <Text style={styles.h3}>What’s new</Text>
338
+ {update.summary.map((line) => (
339
+ <Text key={line} style={styles.bullet}>{`• ${line}`}</Text>
340
+ ))}
341
+ </View>
342
+ )}
343
+ <FilesList files={plan.files} />
344
+ {plan.kitFilesDiffering > 0 && (
345
+ <Text style={styles.muted}>
346
+ {`${plan.kitFilesDiffering} starter-kit file${plan.kitFilesDiffering === 1 ? '' : 's'} differ from ${update.latest}’s kit. ` +
347
+ 'They’re yours and are never changed. Compare with npx ds-viewer kit diff <Component>.'}
348
+ </Text>
349
+ )}
350
+ {plan.outsideGitRepo && (
351
+ <Text style={styles.muted}>Not a git repository, so uncommitted changes couldn’t be checked.</Text>
352
+ )}
353
+ <View style={styles.actions}>
354
+ <Pressable
355
+ onPress={startUpdate}
356
+ disabled={starting}
357
+ accessibilityRole="button"
358
+ accessibilityState={{ disabled: starting }}
359
+ style={[styles.primaryButton, starting && styles.disabledButton]}
360
+ >
361
+ <Text style={[styles.primaryButtonText, starting && styles.disabledButtonText]}>Update now</Text>
362
+ </Pressable>
363
+ {starting ? <Text style={styles.muted}>Starting…</Text> : <Text style={styles.muted}>or run <Code>npx ds-viewer update</Code></Text>}
364
+ </View>
365
+ </>
366
+ );
367
+ } else if (phase === 'checking') {
368
+ content = (
369
+ <>
370
+ <VersionsHeader current={update.current} latest={update.latest} breaking={update.breaking} releasedAt={update.releasedAt} />
371
+ <NoteBox variant="muted">Checking what this update would change…</NoteBox>
372
+ {update.summary.length > 0 && (
373
+ <View style={styles.block}>
374
+ <Text style={styles.h3}>What’s new</Text>
375
+ {update.summary.map((line) => (
376
+ <Text key={line} style={styles.bullet}>{`• ${line}`}</Text>
377
+ ))}
378
+ </View>
379
+ )}
380
+ </>
381
+ );
382
+ } else if (phase === 'dirty' && plan) {
383
+ content = (
384
+ <>
385
+ <VersionsHeader current={update.current} latest={update.latest} breaking={update.breaking} releasedAt={update.releasedAt} />
386
+ <NoteBox variant="warn">
387
+ <Text style={styles.noteBoxBold}>{`${plan.dirtyFiles.length} file${plan.dirtyFiles.length === 1 ? '' : 's'} ${plan.dirtyFiles.length === 1 ? 'has' : 'have'} uncommitted changes.`}</Text>
388
+ {' Commit or stash them, then check again.'}
389
+ </NoteBox>
390
+ <FilesList files={plan.files} />
391
+ <View style={styles.actions}>
392
+ <Pressable
393
+ disabled
394
+ accessibilityRole="button"
395
+ accessibilityState={{ disabled: true }}
396
+ style={[styles.primaryButton, styles.disabledButton]}
397
+ >
398
+ <Text style={[styles.primaryButtonText, styles.disabledButtonText]}>Update now</Text>
399
+ </Pressable>
400
+ <Pressable onPress={fetchPlan} accessibilityRole="button" style={styles.secondaryButton}>
401
+ <Text style={styles.secondaryButtonText}>Check again</Text>
402
+ </Pressable>
403
+ </View>
404
+ </>
405
+ );
406
+ } else if (phase === 'offline') {
407
+ content = isAlreadyUpToDateError(planResult!) ? (
408
+ <>
409
+ <VersionsHeader current={update.current} latest={update.latest} breaking={update.breaking} releasedAt={update.releasedAt} />
410
+ <NoteBox variant="ok">You’re already up to date.</NoteBox>
411
+ </>
412
+ ) : (
413
+ <>
414
+ <VersionsHeader current={update.current} latest={update.latest} breaking={update.breaking} releasedAt={update.releasedAt} />
415
+ <NoteBox variant="err">
416
+ <Text style={styles.noteBoxBold}>Couldn’t prepare the update.</Text>
417
+ {' You may be offline, or npm didn’t respond. Nothing was changed.'}
418
+ </NoteBox>
419
+ <View style={styles.actions}>
420
+ <Pressable onPress={fetchPlan} accessibilityRole="button" style={styles.secondaryButton}>
421
+ <Text style={styles.secondaryButtonText}>Try again</Text>
422
+ </Pressable>
423
+ <Text style={styles.muted}>or run <Code>npx ds-viewer update</Code></Text>
424
+ </View>
425
+ </>
426
+ );
427
+ } else if (phase === 'updating' && status.phase === 'updating') {
428
+ content = (
429
+ <>
430
+ <VersionsHeader current={update.current} latest={update.latest} breaking={update.breaking} releasedAt={update.releasedAt} />
431
+ <Text style={[styles.h3, styles.block]}>Updating</Text>
432
+ <Steps steps={status.steps} />
433
+ <Text style={styles.muted}>Keep this tab open. The viewer restarts on its own when the update finishes.</Text>
434
+ </>
435
+ );
436
+ } else if (phase === 'restarting') {
437
+ content = (
438
+ <>
439
+ <VersionsHeader current={update.current} latest={update.latest} breaking={update.breaking} releasedAt={update.releasedAt} />
440
+ <NoteBox variant="muted">
441
+ <Text style={styles.noteBoxBold}>{`Restarting the viewer on ${update.latest}…`}</Text>
442
+ {' This page reloads by itself.'}
443
+ </NoteBox>
444
+ <Text style={styles.muted}>If it doesn’t come back, run <Code>npx ds-viewer dev</Code> in your terminal.</Text>
445
+ </>
446
+ );
447
+ } else if (phase === 'success' && status.phase === 'success') {
448
+ content = <SuccessContent status={status} />;
449
+ } else if (phase === 'failure' && status.phase === 'failure') {
450
+ const { headline, body } = failureCopy(status.failedStep, update.current);
451
+ const failedLabel = `${status.failedStep} failed`;
452
+ content = (
453
+ <>
454
+ <VersionsHeader current={update.current} latest={update.latest} breaking={update.breaking} releasedAt={update.releasedAt} />
455
+ <NoteBox variant="err">
456
+ <Text style={styles.noteBoxBold}>{headline}</Text>
457
+ {' '}
458
+ {body}
459
+ </NoteBox>
460
+ <Steps
461
+ steps={[
462
+ { label: `Downloaded ${update.latest}`, state: 'done' },
463
+ { label: failedLabel, state: 'todo' },
464
+ ]}
465
+ failedStep={failedLabel}
466
+ />
467
+ <LogBlock>{status.log}</LogBlock>
468
+ <View style={styles.actions}>
469
+ <Pressable
470
+ onPress={() => {
471
+ if (isWeb()) navigator.clipboard?.writeText(status.log);
472
+ }}
473
+ accessibilityRole="button"
474
+ style={styles.secondaryButton}
475
+ >
476
+ <Text style={styles.secondaryButtonText}>Copy log</Text>
477
+ </Pressable>
478
+ </View>
479
+ </>
480
+ );
481
+ }
482
+
483
+ return (
484
+ <View style={styles.wrap}>
485
+ <Text style={styles.crumb}>{`${appName} / DS Viewer`}</Text>
486
+ {/* react-native-web reads `aria-level`; React Native's own prop types do not declare it —
487
+ * the same cast `SectionBlock.tsx`'s own heading target already uses. */}
488
+ <View ref={headingRef} tabIndex={-1} role="heading" {...({ 'aria-level': 1 } as Record<string, unknown>)} style={styles.headingTarget}>
489
+ <Text style={styles.title}>Update DS Viewer</Text>
490
+ </View>
491
+ {content}
492
+ </View>
493
+ );
494
+ }
495
+
496
+ const styles = StyleSheet.create({
497
+ wrap: { maxWidth: CATALOG_LAYOUT.updateContentMaxWidth },
498
+ crumb: { fontSize: CATALOG_TYPE.sm, color: CATALOG_COLOR.textMuted },
499
+ headingTarget: { marginTop: 4, marginBottom: 6 },
500
+ title: { fontSize: CATALOG_TYPE.pageTitle, fontWeight: '800', color: CATALOG_COLOR.text },
501
+ code: {
502
+ fontFamily: 'Menlo, monospace',
503
+ fontSize: CATALOG_TYPE.sm,
504
+ backgroundColor: CATALOG_COLOR.surfaceMuted,
505
+ borderWidth: 1,
506
+ borderColor: CATALOG_COLOR.border,
507
+ borderRadius: 6,
508
+ paddingHorizontal: 6,
509
+ paddingVertical: 2,
510
+ },
511
+ versionsRow: { flexDirection: 'row', alignItems: 'center', gap: 10 },
512
+ versionsText: { fontSize: 22, fontWeight: '800', color: CATALOG_COLOR.text },
513
+ arrow: { fontSize: 22, color: CATALOG_COLOR.textMuted },
514
+ pill: { paddingHorizontal: 8, paddingVertical: 2, borderRadius: 10, backgroundColor: CATALOG_COLOR.accentSubtle },
515
+ pillMajor: { backgroundColor: CATALOG_COLOR.warningSubtle },
516
+ pillSuccess: { backgroundColor: CATALOG_COLOR.successSubtle },
517
+ pillText: { fontSize: CATALOG_TYPE.sm, fontWeight: '700', color: CATALOG_COLOR.accent },
518
+ pillTextMajor: { color: CATALOG_COLOR.warning },
519
+ pillTextSuccess: { color: CATALOG_COLOR.success },
520
+ muted: { fontSize: CATALOG_TYPE.sm, color: CATALOG_COLOR.textMuted, marginTop: 4 },
521
+ block: { marginTop: 18 },
522
+ h3: { fontSize: CATALOG_TYPE.panelHeading, textTransform: 'uppercase', letterSpacing: 0.5, color: CATALOG_COLOR.text, marginBottom: 8 },
523
+ bullet: { fontSize: CATALOG_TYPE.md, color: CATALOG_COLOR.text, marginVertical: 2 },
524
+ filesBox: { borderWidth: 1, borderColor: CATALOG_COLOR.border, borderRadius: CATALOG_RADIUS.md },
525
+ fileRow: { flexDirection: 'row', justifyContent: 'space-between', paddingVertical: 8, paddingHorizontal: 12 },
526
+ fileRowBorder: { borderTopWidth: 1, borderTopColor: CATALOG_COLOR.border },
527
+ filePath: { fontFamily: 'Menlo, monospace', fontSize: CATALOG_TYPE.sm, color: CATALOG_COLOR.text },
528
+ fileReason: { fontSize: CATALOG_TYPE.sm, color: CATALOG_COLOR.textMuted },
529
+ fileReasonDirty: { color: CATALOG_COLOR.warning, fontWeight: '700' },
530
+ actions: { flexDirection: 'row', alignItems: 'center', gap: CATALOG_SPACE.md, marginTop: 20 },
531
+ primaryButton: { height: CATALOG_LAYOUT.controlSize, paddingHorizontal: 18, borderRadius: 22, backgroundColor: CATALOG_COLOR.text, alignItems: 'center', justifyContent: 'center' },
532
+ primaryButtonText: { color: '#fff', fontWeight: '700', fontSize: CATALOG_TYPE.md },
533
+ secondaryButton: { height: CATALOG_LAYOUT.controlSize, paddingHorizontal: 18, borderRadius: 22, borderWidth: 1, borderColor: CATALOG_COLOR.borderStrong, alignItems: 'center', justifyContent: 'center' },
534
+ secondaryButtonText: { fontWeight: '700', fontSize: CATALOG_TYPE.md, color: CATALOG_COLOR.text },
535
+ disabledButton: { backgroundColor: '#e6e6e6', borderColor: '#e6e6e6' },
536
+ disabledButtonText: { color: '#8a8a8a' },
537
+ noteBox: { borderRadius: CATALOG_RADIUS.md, padding: 12, marginTop: 18 },
538
+ noteBoxText: { fontSize: CATALOG_TYPE.md },
539
+ noteBoxBold: { fontWeight: '700' },
540
+ steps: { marginTop: 12 },
541
+ stepRow: { flexDirection: 'row', alignItems: 'center', gap: 10, paddingVertical: 6 },
542
+ stepIcon: { width: 20, height: 20, borderRadius: 10, alignItems: 'center', justifyContent: 'center' },
543
+ stepIconDone: { backgroundColor: CATALOG_COLOR.successSubtle },
544
+ // The mockup's own `.ic.now` is a CSS-animated half-ring spinner (`border-right-color:
545
+ // transparent`); a static accent-colored ring reads clearly enough here without introducing
546
+ // this file's first animation for a step that is, in practice, on screen for a few seconds.
547
+ stepIconNow: { borderWidth: 2, borderColor: CATALOG_COLOR.accent },
548
+ stepIconTodo: { borderWidth: 2, borderColor: CATALOG_COLOR.borderStrong },
549
+ stepIconFail: { backgroundColor: CATALOG_COLOR.dangerSubtle },
550
+ stepIconGlyph: { fontSize: 12, fontWeight: '800', color: CATALOG_COLOR.text },
551
+ stepLabel: { fontSize: CATALOG_TYPE.md, color: CATALOG_COLOR.textMuted },
552
+ stepLabelNow: { fontSize: CATALOG_TYPE.md, color: CATALOG_COLOR.text, fontWeight: '700' },
553
+ stepLabelFail: { fontSize: CATALOG_TYPE.md, color: CATALOG_COLOR.text, fontWeight: '700' },
554
+ logBox: { backgroundColor: '#1d1d1f', borderRadius: CATALOG_RADIUS.md, padding: 12, marginTop: 8, maxHeight: 240 },
555
+ logText: { color: '#e8e8e8', fontSize: 12, fontFamily: 'Menlo, monospace' },
556
+ });
@@ -3,7 +3,7 @@
3
3
  * runner. CatalogShell, CatalogSidebar, and SectionBlock all derive page order from here, so the
4
4
  * sidebar order, previous/next order, and fragment fallback can never drift apart.
5
5
  */
6
- import type { NavGroup } from './types.ts';
6
+ import type { NavGroup, UpdateNotice } from './types.ts';
7
7
 
8
8
  /** THE canonical within-group ordering of section ids. */
9
9
  export function sortIds<TId extends string>(ids: readonly TId[]): TId[] {
@@ -75,3 +75,72 @@ export function idFromHash<TId extends string>(hash: string, order: readonly TId
75
75
  const match = order.find((id) => id === decoded);
76
76
  return match ?? order[0];
77
77
  }
78
+
79
+ /** Design §5: the update panel is its own catalog page at this fragment, "not listed in the
80
+ * sidebar groups" — it is therefore never a member of `order`, so `idFromHash` alone would treat
81
+ * it as an unknown fragment and fall back to the first real page. */
82
+ export const UPDATE_PAGE_ID = 'ds-viewer-update';
83
+
84
+ /** The request header both the local endpoint (Task 12, which checks it) and the panel's own
85
+ * fetch calls (Task 16, which send it) use for the per-`dev`-run secret (design §5 "Local
86
+ * endpoint safeguards"). Defined once, here, rather than as a literal string repeated in both the
87
+ * `cli/` and `native/catalog/` halves of the package, which may never import from each other in
88
+ * the other direction (`cli/` imports `native/catalog/`; `native/catalog/` imports nothing from
89
+ * `cli/`). */
90
+ export const DS_VIEWER_SECRET_HEADER = 'x-ds-viewer-secret';
91
+
92
+ /** A minimal `major.minor.patch` comparison, duplicated from `cli/semver.ts`'s own
93
+ * `compareVersions` rather than imported — `native/catalog/` is the published package root and
94
+ * never imports from `cli/` (the same reasoning `native/catalog/updatePanelState.ts`'s own
95
+ * `UpdatePlanView`/`UpdateStatusView`, Task 15, already established for duplicated types). Used
96
+ * only as a defense-in-depth guard (Critical finding, Fable correction pass) alongside
97
+ * `checkForUpdate`'s own newer-than-current check (Task 4) — not a general-purpose semver
98
+ * comparator, the same scope note `cli/semver.ts`'s own `Version` interface already carries.
99
+ * Errata 5: parses with a regex rather than `split('.').map(Number)` so a prerelease suffix on
100
+ * either string (e.g. `0.4.1-verify`) doesn't turn the patch component into `NaN`; a string that
101
+ * doesn't match at all is treated as not newer. */
102
+ function isNewerVersion(a: string, b: string): boolean {
103
+ const ma = a.match(/^(\d+)\.(\d+)\.(\d+)/);
104
+ const mb = b.match(/^(\d+)\.(\d+)\.(\d+)/);
105
+ if (!ma || !mb) return false;
106
+ for (let i = 1; i <= 3; i += 1) {
107
+ const na = Number(ma[i]);
108
+ const nb = Number(mb[i]);
109
+ if (na !== nb) return na > nb;
110
+ }
111
+ return false;
112
+ }
113
+
114
+ export function resolveActiveFromHash<TId extends string>(
115
+ hash: string,
116
+ order: readonly TId[],
117
+ ): TId | typeof UPDATE_PAGE_ID | undefined {
118
+ const raw = hash.startsWith('#') ? hash.slice(1) : hash;
119
+ let decoded = '';
120
+ try {
121
+ decoded = decodeURIComponent(raw);
122
+ } catch {
123
+ decoded = '';
124
+ }
125
+ if (decoded === UPDATE_PAGE_ID) return UPDATE_PAGE_ID;
126
+ return idFromHash(hash, order);
127
+ }
128
+
129
+ /** Design §5: "Major: ... dismissible per version; the footer line remains." `dismissedVersion`
130
+ * is whatever a previous dismissal recorded (`CatalogShell`'s own `localStorage` read, Task 14) —
131
+ * any value other than the *current* `update.latest` (including `null`, or an older dismissed
132
+ * major) still shows the banner, so dismissing 1.0.0 does not also suppress a later 2.0.0. */
133
+ export function shouldShowMajorBanner(update: UpdateNotice | null | undefined, dismissedVersion: string | null): boolean {
134
+ if (!update || !update.breaking) return false;
135
+ if (!isNewerVersion(update.latest, update.current)) return false;
136
+ return update.latest !== dismissedVersion;
137
+ }
138
+
139
+ /** The quiet footer line's exact copy (design §5: `"Update available · 0.5.0"`), or `undefined`
140
+ * when there is nothing to show. Independent of `shouldShowMajorBanner` — design: the footer line
141
+ * shows for *every* available update, minor or major, and stays even once the major banner is
142
+ * dismissed. */
143
+ export function footerLabel(update: UpdateNotice | null | undefined): string | undefined {
144
+ if (!update || !isNewerVersion(update.latest, update.current)) return undefined;
145
+ return `Update available · ${update.latest}`;
146
+ }
@@ -34,6 +34,9 @@ export type { ComponentManifestEntry, ManifestExample } from './manifest';
34
34
  export { defineCatalogPage, buildCatalogSections } from './pageApi';
35
35
  export type { CatalogPage, CatalogPageInput, GeneratedComponent, GeneratedPropRecord } from './pageApi';
36
36
  export type { PropDef, SectionDef, NavGroup, SpecimenSize, ComparisonDef, ComparisonCell, ComparisonAxisItem, GridAxis, PreviewWidths } from './types';
37
+ export { UpdatePanel } from './UpdatePanel';
38
+ export { UPDATE_PAGE_ID } from './catalogNavigation';
39
+ export type { UpdateNotice } from './types';
37
40
  export type { ListGroup, ListItem } from './comparison';
38
41
  export {
39
42
  CATALOG_TYPE,