@krapwoo/ds-viewer 0.4.8 → 0.5.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,46 @@
1
+ import React, { forwardRef, useState } from 'react';
2
+ import { Pressable, StyleSheet, Text, type View } from 'react-native';
3
+ import { CATALOG_COLOR, CATALOG_LAYOUT, CATALOG_TYPE } from './tokens';
4
+
5
+ /** The viewer's own button, for catalog chrome and demo triggers (not an app component). Same look
6
+ * as the update page's buttons. */
7
+ export const CatalogButton = forwardRef<View, {
8
+ label: string;
9
+ onPress: () => void;
10
+ variant?: 'primary' | 'secondary';
11
+ accessibilityLabel?: string;
12
+ }>(function CatalogButton({ label, onPress, variant = 'primary', accessibilityLabel }, ref) {
13
+ const [focused, setFocused] = useState(false);
14
+ const primary = variant === 'primary';
15
+ return (
16
+ <Pressable
17
+ ref={ref}
18
+ onPress={onPress}
19
+ onFocus={() => setFocused(true)}
20
+ onBlur={() => setFocused(false)}
21
+ accessibilityRole="button"
22
+ accessibilityLabel={accessibilityLabel ?? label}
23
+ style={({ pressed }) => [
24
+ styles.base,
25
+ primary ? styles.primary : styles.secondary,
26
+ pressed && (primary ? styles.primaryPressed : styles.secondaryPressed),
27
+ focused && styles.focus,
28
+ ]}
29
+ >
30
+ <Text style={[styles.label, primary ? styles.labelPrimary : styles.labelSecondary]}>{label}</Text>
31
+ </Pressable>
32
+ );
33
+ });
34
+
35
+ const styles = StyleSheet.create({
36
+ base: { height: CATALOG_LAYOUT.controlSize, paddingHorizontal: 18, borderRadius: 22, alignItems: 'center', justifyContent: 'center', alignSelf: 'center' },
37
+ primary: { backgroundColor: CATALOG_COLOR.text },
38
+ primaryPressed: { opacity: 0.85 },
39
+ secondary: { borderWidth: 1, borderColor: CATALOG_COLOR.borderStrong, backgroundColor: CATALOG_COLOR.surface },
40
+ secondaryPressed: { backgroundColor: CATALOG_COLOR.surfacePressed },
41
+ // Same ring as the sidebar's: an outline, so focusing never shifts the layout.
42
+ focus: { outlineWidth: CATALOG_LAYOUT.focusRingWidth, outlineStyle: 'solid', outlineColor: CATALOG_COLOR.focusRing } as object,
43
+ label: { fontSize: CATALOG_TYPE.md, fontWeight: '700' },
44
+ labelPrimary: { color: '#ffffff' },
45
+ labelSecondary: { color: CATALOG_COLOR.text },
46
+ });
@@ -0,0 +1,52 @@
1
+ import React, { useEffect, useRef, useState } from 'react';
2
+ import { StyleSheet, View } from 'react-native';
3
+ import { PhoneFrame } from './PhoneFrame';
4
+ import { CatalogButton } from './CatalogButton';
5
+ import { overlayTrigger } from './overlayDemoState';
6
+
7
+ // Not `StyleSheet.absoluteFill`: React Native 0.81's types reject spreading it.
8
+ const FILL = { position: 'absolute' as const, top: 0, right: 0, bottom: 0, left: 0 };
9
+
10
+ /**
11
+ * A phone with a catalog button that opens an overlay: a sheet, a dialog, a toast. The overlay
12
+ * (`children`) fills the device above the centred trigger and gets `{ open, close }`; keep it
13
+ * mounted and drive its own `visible` from `open`, so its open and close animations play.
14
+ */
15
+ export function OverlayDemo({
16
+ triggerLabel,
17
+ initiallyOpen = false,
18
+ keepTrigger = false,
19
+ children,
20
+ }: {
21
+ triggerLabel: string | ((open: boolean) => string);
22
+ initiallyOpen?: boolean;
23
+ /** Keep the trigger visible while open, as a toggle. @default false */
24
+ keepTrigger?: boolean;
25
+ children: (state: { open: boolean; close: () => void }) => React.ReactNode;
26
+ }) {
27
+ const [open, setOpen] = useState(initiallyOpen);
28
+ const trigger = overlayTrigger(open, { triggerLabel, keepTrigger });
29
+ // When the overlay closes, keyboard focus returns to the trigger instead of the page body.
30
+ const triggerRef = useRef<View>(null);
31
+ const wasOpen = useRef(open);
32
+ useEffect(() => {
33
+ if (wasOpen.current && !open) (triggerRef.current as unknown as { focus?: () => void } | null)?.focus?.();
34
+ wasOpen.current = open;
35
+ }, [open]);
36
+ return (
37
+ <PhoneFrame>
38
+ <View style={styles.screen}>
39
+ {trigger.visible && (
40
+ <CatalogButton ref={triggerRef} label={trigger.label} onPress={() => setOpen((value) => (keepTrigger ? !value : true))} />
41
+ )}
42
+ <View style={FILL} pointerEvents="box-none">
43
+ {children({ open, close: () => setOpen(false) })}
44
+ </View>
45
+ </View>
46
+ </PhoneFrame>
47
+ );
48
+ }
49
+
50
+ const styles = StyleSheet.create({
51
+ screen: { flex: 1, alignSelf: 'stretch', alignItems: 'center', justifyContent: 'center' },
52
+ });
@@ -0,0 +1,51 @@
1
+ import React from 'react';
2
+ import { ScrollView, StyleSheet, View, type StyleProp, type ViewStyle } from 'react-native';
3
+
4
+ /**
5
+ * A whole app screen inside a `PhoneFrame`: `header` pinned to the top, `footer` to the bottom,
6
+ * and the children in a body between them that scrolls instead of pushing the footer off the
7
+ * device. `floating` is a layer over the top of the screen for toasts; it adds no inset of its
8
+ * own, so the app positions its content. `bodyWrapper` lets the app put its own surface or
9
+ * context provider around the scrolling body; give its root `flex: 1, minHeight: 0`.
10
+ */
11
+ export function PhoneScreen({
12
+ header,
13
+ footer,
14
+ floating,
15
+ bodyWrapper,
16
+ scroll = true,
17
+ contentContainerStyle,
18
+ children,
19
+ }: {
20
+ header?: React.ReactNode;
21
+ footer?: React.ReactNode;
22
+ floating?: React.ReactNode;
23
+ bodyWrapper?: (body: React.ReactNode) => React.ReactNode;
24
+ /** Scroll the body (default), or lay it out to fit. */
25
+ scroll?: boolean;
26
+ contentContainerStyle?: StyleProp<ViewStyle>;
27
+ children?: React.ReactNode;
28
+ }) {
29
+ const body = scroll ? (
30
+ <ScrollView style={styles.flex} contentContainerStyle={contentContainerStyle}>{children}</ScrollView>
31
+ ) : (
32
+ <View style={[styles.flex, contentContainerStyle]}>{children}</View>
33
+ );
34
+ return (
35
+ <View style={styles.screen}>
36
+ {header}
37
+ <View style={styles.flex}>{bodyWrapper ? bodyWrapper(body) : body}</View>
38
+ {footer}
39
+ {floating ? (
40
+ <View style={styles.floating} pointerEvents="box-none">{floating}</View>
41
+ ) : null}
42
+ </View>
43
+ );
44
+ }
45
+
46
+ const styles = StyleSheet.create({
47
+ // Fills the device; `minHeight: 0` lets a flex child shrink below its content on the web.
48
+ screen: { flex: 1, alignSelf: 'stretch', minHeight: 0 },
49
+ flex: { flex: 1, minHeight: 0 },
50
+ floating: { position: 'absolute', top: 0, left: 0, right: 0, zIndex: 20 },
51
+ });
@@ -3,7 +3,7 @@ import { ActivityIndicator, Platform, Pressable, ScrollView, StyleSheet, Switch,
3
3
  import { CATALOG_COLOR, CATALOG_LAYOUT, CATALOG_RADIUS, CATALOG_SPACE, CATALOG_TYPE } from './tokens';
4
4
  import { DS_VIEWER_SECRET_HEADER } from './catalogNavigation';
5
5
  import {
6
- autoCheckHelp, bumpLabel, checkedAgoLabel, daysAgoLabel, isAlreadyUpToDateError, resolvePanelPhase, versionOverview, type UpdatePlanView, type UpdateStatusView,
6
+ autoCheckHelp, bumpLabel, checkedAgoLabel, resultNote, daysAgoLabel, isAlreadyUpToDateError, resolvePanelPhase, versionOverview, type UpdatePlanView, type UpdateStatusView,
7
7
  } from './updatePanelState';
8
8
  import type { UpdateNotice, VersionStatus } from './types';
9
9
 
@@ -51,20 +51,6 @@ function FilesList({ files }: { files: UpdatePlanView['files'] }) {
51
51
  );
52
52
  }
53
53
 
54
- /** Same shape as `FilesList`, but with no right-hand reason column — the `success` phase's
55
- * `status.files` carries plain paths, never a reason (Task 16 Step 2 table: a dedicated,
56
- * smaller render here is simplest rather than over-generalizing `FilesList` for one caller). */
57
- function PlainFilesList({ files }: { files: string[] }) {
58
- return (
59
- <View style={styles.filesBox}>
60
- {files.map((path, i) => (
61
- <View key={path} style={[styles.fileRow, i > 0 && styles.fileRowBorder]}>
62
- <Text style={styles.filePath}>{path}</Text>
63
- </View>
64
- ))}
65
- </View>
66
- );
67
- }
68
54
 
69
55
  const NOTE_BOX_VARIANT = {
70
56
  muted: { box: { backgroundColor: CATALOG_COLOR.surfaceMuted }, text: { color: CATALOG_COLOR.text } },
@@ -139,59 +125,66 @@ function Code({ children }: { children: string }) {
139
125
  return <Text style={styles.code}>{children}</Text>;
140
126
  }
141
127
 
142
- /** The `success` phase's content, shared between the `update === null` path (Errata 1c — the
143
- * normal case, since `update.json` becomes `null` once latest equals current) and the defensive
144
- * `update` still non-null path. Reads `status.latest`/`status.files`/`status.doctorSummary`,
145
- * never `update.latest`/`plan.files` — `plan` is gone by the time this renders (the page has
146
- * reloaded onto the new version). */
147
- function SuccessContent({ status }: { status: Extract<UpdateStatusView, { phase: 'success' }> }) {
128
+
129
+
130
+ /**
131
+ * The result of the last update, as a note at the top of the update page (approved mockup
132
+ * 2026-10-11): a bold header, a description, one detail line, and a bottom-left button in the
133
+ * note's colour. The rest of the page stays usable below it.
134
+ */
135
+ function ResultNoteBox({
136
+ status,
137
+ current,
138
+ latest,
139
+ busy,
140
+ onAction,
141
+ }: {
142
+ status: Extract<UpdateStatusView, { phase: 'success' | 'failure' }>;
143
+ current: string;
144
+ latest?: string;
145
+ busy: boolean;
146
+ onAction: (kind: 'dismiss' | 'retry' | 'resume') => void;
147
+ }) {
148
+ const [showLog, setShowLog] = useState(false);
149
+ const note = resultNote(status, current, latest);
150
+ const ok = note.tone === 'ok';
151
+ const tone = ok ? CATALOG_COLOR.success : CATALOG_COLOR.danger;
148
152
  return (
149
- <>
150
- <View style={styles.versionsRow}>
151
- <Text style={styles.versionsText}>{status.latest}</Text>
152
- <View style={[styles.pill, styles.pillSuccess]}>
153
- <Text style={[styles.pillText, styles.pillTextSuccess]}>Updated</Text>
154
- </View>
155
- </View>
156
- <NoteBox variant="ok">
157
- <Text style={styles.noteBoxBold}>{`Updated to ${status.latest}.`}</Text>
158
- {' Changes are not committed — review them in your editor.'}
159
- </NoteBox>
160
- <View style={styles.block}>
161
- <Text style={styles.h3}>Changed</Text>
162
- <PlainFilesList files={status.files} />
163
- </View>
164
- <View style={styles.block}>
165
- <Text style={styles.h3}>Doctor</Text>
166
- <Text style={styles.muted}>{status.doctorSummary}</Text>
153
+ <View style={[styles.resultNote, ok ? styles.resultNoteOk : styles.resultNoteErr]} role="status">
154
+ <Text style={[styles.resultTitle, { color: tone }]}>{note.title}</Text>
155
+ <Text style={[styles.resultDescription, { color: tone }]}>{note.description}</Text>
156
+ <Text style={[styles.resultDetail, { color: tone }]}>
157
+ {note.command ? (
158
+ <>
159
+ {'You can also run '}
160
+ <Code>{note.command}</Code>
161
+ {' in your terminal.'}
162
+ </>
163
+ ) : (
164
+ note.detail
165
+ )}
166
+ </Text>
167
+ <View style={styles.resultRow}>
168
+ <Pressable
169
+ onPress={() => onAction(note.action.kind)}
170
+ disabled={busy}
171
+ accessibilityRole="button"
172
+ accessibilityState={{ disabled: busy }}
173
+ style={({ pressed }) => [styles.resultButton, { backgroundColor: tone }, (pressed || busy) && styles.resultButtonPressed]}
174
+ >
175
+ <Text style={styles.resultButtonText}>{note.action.label}</Text>
176
+ </Pressable>
177
+ {note.hasLog && status.phase === 'failure' && (
178
+ <Pressable onPress={() => setShowLog((v) => !v)} accessibilityRole="button" accessibilityState={{ expanded: showLog }}>
179
+ <Text style={[styles.resultLink, { color: tone }]}>{showLog ? 'Hide log' : 'Show log'}</Text>
180
+ </Pressable>
181
+ )}
167
182
  </View>
168
- </>
183
+ {showLog && status.phase === 'failure' && <LogBlock>{status.log}</LogBlock>}
184
+ </View>
169
185
  );
170
186
  }
171
187
 
172
- /** The `failure` phase's headline/body copy, keyed off which step failed — `runUpdate`/
173
- * `performUpdate` each name their own distinct recovery command (Important finding, Fable
174
- * correction pass: one hardcoded headline/body for every failure was wrong for two of the
175
- * three). */
176
- function failureCopy(failedStep: string, current: string): { headline: string; body: React.ReactNode } {
177
- if (/install/i.test(failedStep)) {
178
- return {
179
- headline: 'The update stopped while installing.',
180
- body: <>Your files weren’t migrated. Recover with <Code>npx ds-viewer update</Code> in your terminal.</>,
181
- };
182
- }
183
- if (/migrat/i.test(failedStep)) {
184
- return {
185
- headline: 'The update installed, but migrating failed.',
186
- body: <>Recover with <Code>{`npx ds-viewer migrate --from ${current}`}</Code> in your terminal.</>,
187
- };
188
- }
189
- return {
190
- headline: 'The update installed and migrated, but doctor failed to run.',
191
- body: <>Recover with <Code>npx ds-viewer doctor</Code> in your terminal.</>,
192
- };
193
- }
194
-
195
188
  /**
196
189
  * The update page when no newer version is known (approved mockup
197
190
  * docs/design/2026-10-10-ds-viewer-update-page-always-approved.html, states 3, 4, 6, 7 and 8): the
@@ -202,10 +195,13 @@ function VersionDetails({
202
195
  status,
203
196
  endpoint,
204
197
  onStatus,
198
+ onCheck,
205
199
  }: {
206
200
  status: VersionStatus;
207
201
  endpoint?: Endpoint;
208
202
  onStatus: (next: VersionStatus) => void;
203
+ /** Called as Check now starts (clears an update result note). */
204
+ onCheck?: () => void;
209
205
  }) {
210
206
  const [checking, setChecking] = useState(false);
211
207
  const [justChecked, setJustChecked] = useState(false);
@@ -217,6 +213,7 @@ function VersionDetails({
217
213
 
218
214
  const checkNow = async () => {
219
215
  if (!endpoint || checking) return;
216
+ onCheck?.();
220
217
  setChecking(true);
221
218
  setJustChecked(false);
222
219
  setLocalError(undefined);
@@ -395,7 +392,20 @@ export function UpdatePanel({
395
392
  });
396
393
  }, [fetchStatus, fetchPlan, update]);
397
394
 
398
- const phase = resolvePanelPhase(status, planResult);
395
+ // A finished update (success or failure) shows as a note at the top; the page under it behaves
396
+ // as if idle, so Check now, the switch and a newer update all keep working.
397
+ const result = status.phase === 'success' || status.phase === 'failure' ? status : undefined;
398
+ const phase = resolvePanelPhase(result ? { phase: 'idle' } : status, planResult);
399
+ const [noteBusy, setNoteBusy] = useState(false);
400
+ const dismissResult = useCallback(async () => {
401
+ if (!endpoint || !isWeb()) return;
402
+ try {
403
+ const res = await authedFetch(endpoint, '/update/dismiss', { method: 'POST' });
404
+ if (res.ok) setStatus((await res.json()) as UpdateStatusView);
405
+ } catch {
406
+ // The note stays; the next status poll or reload settles it.
407
+ }
408
+ }, [endpoint]);
399
409
 
400
410
  // Polls `/update/status` at a plain interval while an update is in flight — design §5 never
401
411
  // asks for push/websocket progress, only "the panel shows progress."
@@ -463,14 +473,14 @@ export function UpdatePanel({
463
473
  if (!update) {
464
474
  // Errata 1c: no update known (disabled, offline, or genuinely up to date). `status` still
465
475
  // reflects a just-finished update read from the (surviving) endpoint across the reload.
466
- content =
467
- status.phase === 'success' ? (
468
- <SuccessContent status={status} />
469
- ) : versionStatus ? (
470
- <VersionDetails status={versionStatus} endpoint={endpoint} onStatus={onVersionStatus ?? (() => {})} />
476
+ content = versionStatus ? (
477
+ <VersionDetails status={versionStatus} endpoint={endpoint} onStatus={onVersionStatus ?? (() => {})} onCheck={result ? dismissResult : undefined} />
471
478
  ) : (
472
479
  <NoteBox variant="ok">You’re already up to date.</NoteBox>
473
480
  );
481
+ } else if (result?.phase === 'failure') {
482
+ // The failure note's own button is the recovery; don't offer Update now twice.
483
+ content = <VersionsHeader current={update.current} latest={update.latest} breaking={update.breaking} releasedAt={update.releasedAt} />;
474
484
  } else if (phase === 'ready' && plan) {
475
485
  content = (
476
486
  <>
@@ -587,40 +597,7 @@ export function UpdatePanel({
587
597
  <Text style={styles.muted}>If it doesn’t come back, run <Code>npx ds-viewer dev</Code> in your terminal.</Text>
588
598
  </>
589
599
  );
590
- } else if (phase === 'success' && status.phase === 'success') {
591
- content = <SuccessContent status={status} />;
592
- } else if (phase === 'failure' && status.phase === 'failure') {
593
- const { headline, body } = failureCopy(status.failedStep, update.current);
594
- const failedLabel = `${status.failedStep} failed`;
595
- content = (
596
- <>
597
- <VersionsHeader current={update.current} latest={update.latest} breaking={update.breaking} releasedAt={update.releasedAt} />
598
- <NoteBox variant="err">
599
- <Text style={styles.noteBoxBold}>{headline}</Text>
600
- {' '}
601
- {body}
602
- </NoteBox>
603
- <Steps
604
- steps={[
605
- { label: `Downloaded ${update.latest}`, state: 'done' },
606
- { label: failedLabel, state: 'todo' },
607
- ]}
608
- failedStep={failedLabel}
609
- />
610
- <LogBlock>{status.log}</LogBlock>
611
- <View style={styles.actions}>
612
- <Pressable
613
- onPress={() => {
614
- if (isWeb()) navigator.clipboard?.writeText(status.log);
615
- }}
616
- accessibilityRole="button"
617
- style={styles.secondaryButton}
618
- >
619
- <Text style={styles.secondaryButtonText}>Copy log</Text>
620
- </Pressable>
621
- </View>
622
- </>
623
- );
600
+
624
601
  }
625
602
 
626
603
  return (
@@ -631,6 +608,28 @@ export function UpdatePanel({
631
608
  <View ref={headingRef} tabIndex={-1} role="heading" {...({ 'aria-level': 1 } as Record<string, unknown>)} style={styles.headingTarget}>
632
609
  <Text style={styles.title}>DS Viewer updates</Text>
633
610
  </View>
611
+ {result && (
612
+ <ResultNoteBox
613
+ status={result}
614
+ current={update?.current ?? versionStatus?.current ?? ''}
615
+ latest={update?.latest}
616
+ busy={noteBusy || starting}
617
+ onAction={async (kind) => {
618
+ if (!endpoint) return;
619
+ setNoteBusy(true);
620
+ try {
621
+ if (kind === 'dismiss') await dismissResult();
622
+ else if (kind === 'retry') await startUpdate();
623
+ else {
624
+ await authedFetch(endpoint, '/update/resume', { method: 'POST' });
625
+ await fetchStatus();
626
+ }
627
+ } finally {
628
+ setNoteBusy(false);
629
+ }
630
+ }}
631
+ />
632
+ )}
634
633
  {content}
635
634
  </View>
636
635
  );
@@ -638,6 +637,18 @@ export function UpdatePanel({
638
637
 
639
638
  const styles = StyleSheet.create({
640
639
  wrap: { maxWidth: CATALOG_LAYOUT.updateContentMaxWidth },
640
+ resultNote: { borderRadius: CATALOG_RADIUS.md, padding: 14, marginTop: 12, marginBottom: 18, gap: 4 },
641
+ resultNoteOk: { backgroundColor: CATALOG_COLOR.successSubtle },
642
+ resultNoteErr: { backgroundColor: CATALOG_COLOR.dangerSubtle, borderWidth: 1, borderColor: CATALOG_COLOR.dangerBorder },
643
+ resultTitle: { fontSize: CATALOG_TYPE.lg, fontWeight: '800' },
644
+ resultDescription: { fontSize: CATALOG_TYPE.panelHeading, lineHeight: 19 },
645
+ resultDetail: { fontSize: CATALOG_TYPE.sm, lineHeight: 18 },
646
+ resultRow: { flexDirection: 'row', alignItems: 'center', gap: CATALOG_SPACE.md, marginTop: 8 },
647
+ // The catalog's smallest control size (the major banner's close button), filled in the note's colour.
648
+ resultButton: { height: 32, paddingHorizontal: 14, borderRadius: 16, alignItems: 'center', justifyContent: 'center' },
649
+ resultButtonPressed: { opacity: 0.8 },
650
+ resultButtonText: { color: '#ffffff', fontSize: CATALOG_TYPE.sm, fontWeight: '700' },
651
+ resultLink: { fontSize: CATALOG_TYPE.panelHeading, fontWeight: '700', textDecorationLine: 'underline' },
641
652
  crumb: { fontSize: CATALOG_TYPE.sm, color: CATALOG_COLOR.textMuted },
642
653
  headingTarget: { marginTop: 4, marginBottom: 6 },
643
654
  title: { fontSize: CATALOG_TYPE.pageTitle, fontWeight: '800', color: CATALOG_COLOR.text },
@@ -31,6 +31,9 @@ export { TokenSections, TokenGrid, TokenTile } from './TokenLayouts';
31
31
  export { DividedStack } from './DividedStack';
32
32
  export { Swatch } from './Swatch';
33
33
  export { PhoneFrame } from './PhoneFrame';
34
+ export { PhoneScreen } from './PhoneScreen';
35
+ export { OverlayDemo } from './OverlayDemo';
36
+ export { CatalogButton } from './CatalogButton';
34
37
  export { BoundedOverlayViewport } from './BoundedOverlayViewport';
35
38
  export type { BoundedOverlayViewportProps } from './BoundedOverlayViewport';
36
39
  export { comparisonCellItemKey } from './overlayViewport';
@@ -0,0 +1,8 @@
1
+ /** What `OverlayDemo` shows for a given open state. Kept free of runtime imports for `node --test`. */
2
+ export function overlayTrigger(
3
+ open: boolean,
4
+ options: { triggerLabel: string | ((open: boolean) => string); keepTrigger?: boolean },
5
+ ): { visible: boolean; label: string } {
6
+ const label = typeof options.triggerLabel === 'function' ? options.triggerLabel(open) : options.triggerLabel;
7
+ return { visible: !open || Boolean(options.keepTrigger), label };
8
+ }
@@ -32,7 +32,7 @@ export type UpdateStatusView =
32
32
  | { phase: 'updating'; steps: { label: string; state: 'done' | 'now' | 'todo' }[] }
33
33
  | { phase: 'restarting' }
34
34
  | { phase: 'success'; doctorSummary: string; files: string[]; latest: string }
35
- | { phase: 'failure'; log: string; failedStep: string };
35
+ | { phase: 'failure'; log: string; failedStep: string; step?: 'install' | 'migrate' | 'doctor' | 'other' };
36
36
 
37
37
  export type UpdatePhase = 'checking' | 'ready' | 'dirty' | 'offline' | 'updating' | 'restarting' | 'success' | 'failure';
38
38
 
@@ -144,3 +144,70 @@ export function bumpLabel(current: string, latest: string, breaking: boolean): '
144
144
  if (!a || !b) return 'Minor';
145
145
  return a[0] === b[0] && a[1] === b[1] ? 'Patch' : 'Minor';
146
146
  }
147
+
148
+ export interface ResultNote {
149
+ tone: 'ok' | 'err';
150
+ title: string;
151
+ description: string;
152
+ detail: string;
153
+ /** The terminal fallback, shown as code inside `detail` (failures only). */
154
+ command?: string;
155
+ /** At most two words. `dismiss` clears the note; `retry` runs the whole update again; `resume`
156
+ * continues from the failed migrate or doctor step. */
157
+ action: { label: string; kind: 'dismiss' | 'retry' | 'resume' };
158
+ hasLog: boolean;
159
+ }
160
+
161
+ /** The note shown at the top of the update page after an update finished (approved mockup
162
+ * 2026-10-11): a header, a description, one detail line, and a bottom-left button. */
163
+ export function resultNote(
164
+ status: Extract<UpdateStatusView, { phase: 'success' | 'failure' }>,
165
+ current: string,
166
+ latest?: string,
167
+ ): ResultNote {
168
+ if (status.phase === 'success') {
169
+ return {
170
+ tone: 'ok',
171
+ title: `Updated to ${status.latest}`,
172
+ description: "The changes aren't committed yet, so review them in your editor.",
173
+ // Doctor's own summary line continues after an em dash with component counts; the note keeps
174
+ // only the error and warning counts.
175
+ detail: `Changed: ${status.files.join(', ') || 'nothing'} · Doctor: ${status.doctorSummary.split(' — ')[0]}`,
176
+ action: { label: 'Got it', kind: 'dismiss' },
177
+ hasLog: false,
178
+ };
179
+ }
180
+ const failure = (title: string, description: string, command: string, action: ResultNote['action']): ResultNote => ({
181
+ tone: 'err', title, description, detail: `You can also run ${command} in your terminal.`, command, action, hasLog: true,
182
+ });
183
+ switch (status.step) {
184
+ case 'migrate':
185
+ return failure(
186
+ 'The update installed, but migrating failed',
187
+ `Your package is on ${latest ?? 'the new version'}, but your files still match ${current}.`,
188
+ `npx ds-viewer migrate --from ${current}`,
189
+ { label: 'Finish migration', kind: 'resume' },
190
+ );
191
+ case 'doctor':
192
+ return failure(
193
+ 'The update installed, but doctor failed to run',
194
+ 'The update itself is done. Only the page check is missing.',
195
+ 'npx ds-viewer doctor',
196
+ { label: 'Rerun doctor', kind: 'resume' },
197
+ );
198
+ case 'install':
199
+ return failure(
200
+ 'The update stopped while installing',
201
+ 'Nothing was migrated. Retrying starts again from the download.',
202
+ 'npx ds-viewer update',
203
+ { label: 'Retry update', kind: 'retry' },
204
+ );
205
+ default:
206
+ return failure(
207
+ 'The update stopped',
208
+ 'Something unexpected went wrong. Retrying starts again from the download.',
209
+ 'npx ds-viewer update',
210
+ { label: 'Retry update', kind: 'retry' },
211
+ );
212
+ }
213
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@krapwoo/ds-viewer",
3
- "version": "0.4.8",
3
+ "version": "0.5.0",
4
4
  "license": "MIT",
5
5
  "type": "module",
6
6
  "description": "A browsable, always-current catalog for an Expo / React Native design system.",
@@ -1,6 +1,6 @@
1
- import React, { useState } from 'react';
1
+ import React from 'react';
2
2
  import { View, Text, StyleSheet } from 'react-native';
3
- import { defineCatalogPage, PhoneFrame } from '@krapwoo/ds-viewer';
3
+ import { defineCatalogPage, OverlayDemo } from '@krapwoo/ds-viewer';
4
4
  import { Button } from '../Button';
5
5
  import { Dock } from '../Dock';
6
6
  import { TopNav } from '../TopNav';
@@ -17,53 +17,43 @@ const styles = StyleSheet.create({
17
17
  });
18
18
 
19
19
  function BottomSheetDemo() {
20
- const [visible, setVisible] = useState(false);
21
20
  return (
22
- <PhoneFrame>
23
- {!visible && <Button label="Open sheet" onPress={() => setVisible(true)} />}
24
- <BottomSheet
25
- visible={visible}
26
- onDismiss={() => setVisible(false)}
27
- header={
28
- <TopNav
29
- title="Trip details"
30
- trailing={
31
- <Button
32
- variant="secondary"
33
- size="small"
34
- showIcon
35
- showLabel={false}
36
- iconName="clear"
37
- accessibilityLabel="Close"
38
- onPress={() => setVisible(false)}
39
- />
40
- }
41
- />
42
- }
43
- footer={
44
- <Dock>
45
- <Button label="Confirm" onPress={() => setVisible(false)} />
46
- </Dock>
47
- }
48
- >
49
- <View style={styles.sheetContent}>
50
- <Text style={styles.cardTitle}>Uptown & The Bronx</Text>
51
- <Text style={styles.cardBody}>Next train in 4 min · every 6–8 min.</Text>
52
- {/* Long enough to overflow the phone frame's fixed height — demonstrates the Dock footer's
53
- `elevated` shadow turning on automatically once this content area actually scrolls. */}
54
- <Text style={styles.cardBody}>Board at the front car for a faster transfer at Union Sq.</Text>
55
- <Text style={styles.cardBody}>
56
- This line runs express between 96 St and 168 St during rush hours, skipping local stops in
57
- between. Weekend service runs local along the full route, with some stations closed for
58
- planned maintenance.
59
- </Text>
60
- <Text style={styles.cardBody}>
61
- Elevators are available at 168 St, 137 St, and 125 St — check the map for accessible
62
- entrances before you travel.
63
- </Text>
64
- </View>
65
- </BottomSheet>
66
- </PhoneFrame>
21
+ <OverlayDemo triggerLabel="Open sheet">
22
+ {({ open, close }) => (
23
+ <BottomSheet
24
+ visible={open}
25
+ onDismiss={close}
26
+ header={
27
+ <TopNav
28
+ title="Trip details"
29
+ trailing={<Button variant="secondary" size="small" showIcon showLabel={false} iconName="clear" accessibilityLabel="Close" onPress={close} />}
30
+ />
31
+ }
32
+ footer={
33
+ <Dock>
34
+ <Button label="Confirm" onPress={close} />
35
+ </Dock>
36
+ }
37
+ >
38
+ <View style={styles.sheetContent}>
39
+ <Text style={styles.cardTitle}>Uptown & The Bronx</Text>
40
+ <Text style={styles.cardBody}>Next train in 4 min · every 6–8 min.</Text>
41
+ {/* Long enough to overflow the phone frame's fixed height — demonstrates the Dock footer's
42
+ `elevated` shadow turning on automatically once this content area actually scrolls. */}
43
+ <Text style={styles.cardBody}>Board at the front car for a faster transfer at Union Sq.</Text>
44
+ <Text style={styles.cardBody}>
45
+ This line runs express between 96 St and 168 St during rush hours, skipping local stops in
46
+ between. Weekend service runs local along the full route, with some stations closed for
47
+ planned maintenance.
48
+ </Text>
49
+ <Text style={styles.cardBody}>
50
+ Elevators are available at 168 St, 137 St, and 125 St — check the map for accessible
51
+ entrances before you travel.
52
+ </Text>
53
+ </View>
54
+ </BottomSheet>
55
+ )}
56
+ </OverlayDemo>
67
57
  );
68
58
  }
69
59