@krapwoo/ds-viewer 0.4.8 → 0.4.9

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.
package/dist/cli/dev.js CHANGED
@@ -106,6 +106,7 @@ function packageManagerLabel(pm) {
106
106
  * (the endpoint, Task 12, refuses `POST /update` itself); this function only ever runs a plan it
107
107
  * has already been told is safe to apply. */
108
108
  export async function performUpdate(config, currentVersion, plan, deps) {
109
+ const startAt = deps.from === 'doctor' ? 3 : deps.from === 'migrate' ? 2 : 1;
109
110
  const migrationCount = plan.files.filter((f) => f.reason.startsWith('migration:')).length;
110
111
  const pm = detectPackageManager(config.projectRoot);
111
112
  // Names the detected package manager and the real migration count (Important finding, Fable
@@ -133,30 +134,34 @@ export async function performUpdate(config, currentVersion, plan, deps) {
133
134
  state: i < completedCount ? 'done' : i === completedCount ? 'now' : 'todo',
134
135
  })),
135
136
  });
136
- report(1);
137
- const { command, args } = installUpgradeCommand(pm, '@krapwoo/ds-viewer', plan.latest);
138
- try {
139
- await deps.execImpl(command, args, { cwd: config.projectRoot, encoding: 'utf8' });
140
- }
141
- catch (error) {
142
- deps.onStatus({ phase: 'failure', log: error.message, failedStep: stepDefs[1].failLabel ?? stepDefs[1].label });
143
- return;
144
- }
145
- report(2);
146
- try {
147
- await deps.execImpl('node', [installedMainJs(config.projectRoot), 'migrate', '--from', currentVersion], { cwd: config.projectRoot, encoding: 'utf8' });
137
+ report(startAt);
138
+ if (startAt <= 1) {
139
+ const { command, args } = installUpgradeCommand(pm, '@krapwoo/ds-viewer', plan.latest);
140
+ try {
141
+ await deps.execImpl(command, args, { cwd: config.projectRoot, encoding: 'utf8' });
142
+ }
143
+ catch (error) {
144
+ deps.onStatus({ phase: 'failure', log: error.message, failedStep: stepDefs[1].failLabel ?? stepDefs[1].label, step: 'install' });
145
+ return;
146
+ }
147
+ report(2);
148
148
  }
149
- catch (error) {
150
- deps.onStatus({ phase: 'failure', log: error.message, failedStep: stepDefs[2].label });
151
- return;
149
+ if (startAt <= 2) {
150
+ try {
151
+ await deps.execImpl('node', [installedMainJs(config.projectRoot), 'migrate', '--from', currentVersion], { cwd: config.projectRoot, encoding: 'utf8' });
152
+ }
153
+ catch (error) {
154
+ deps.onStatus({ phase: 'failure', log: error.message, failedStep: stepDefs[2].label, step: 'migrate' });
155
+ return;
156
+ }
157
+ report(3);
152
158
  }
153
- report(3);
154
159
  let doctorOutput;
155
160
  try {
156
161
  doctorOutput = await deps.execImpl('node', [installedMainJs(config.projectRoot), 'doctor'], { cwd: config.projectRoot, encoding: 'utf8' });
157
162
  }
158
163
  catch (error) {
159
- deps.onStatus({ phase: 'failure', log: error.message, failedStep: stepDefs[3].label });
164
+ deps.onStatus({ phase: 'failure', log: error.message, failedStep: stepDefs[3].label, step: 'doctor' });
160
165
  return;
161
166
  }
162
167
  report(4);
@@ -331,32 +336,56 @@ export async function dev(initialConfig, devOptions = {}) {
331
336
  let child;
332
337
  const secret = randomBytes(24).toString('hex');
333
338
  const allowedOrigin = `http://localhost:${port}`;
339
+ // The plan the last update ran with, so a failed migrate or doctor step can be resumed.
340
+ let lastPlan;
341
+ const runUpdate = (plan, from) => {
342
+ lastPlan = plan;
343
+ watchersPaused = true;
344
+ void performUpdate(config, ownVersion, plan, {
345
+ execImpl: defaultExecImpl,
346
+ from,
347
+ onStatus: (status) => {
348
+ updateStatus = status;
349
+ if (status.phase === 'failure')
350
+ watchersPaused = false; // the update stopped — keep developing on the old version.
351
+ },
352
+ restart: async (result) => {
353
+ writeFileSync(statusFile, JSON.stringify({ phase: 'success', ...result }));
354
+ await performRestart({ projectRoot: config.projectRoot, port, child, watchers, endpointServer, doctorSummary: result.doctorSummary, files: result.files });
355
+ },
356
+ }).catch((error) => {
357
+ // `performUpdate` only reports 'failure' from its own guarded steps; anything thrown outside
358
+ // them (e.g. writing the status file) lands here instead of as an unhandled rejection.
359
+ updateStatus = { phase: 'failure', log: error.message, failedStep: 'Updating', step: 'other' };
360
+ watchersPaused = false;
361
+ });
362
+ };
334
363
  const endpointDeps = {
335
364
  secret,
336
365
  allowedOrigin,
337
366
  buildPlan: () => buildUpdatePlan(config, ownVersion),
338
- startUpdate: (plan) => {
339
- watchersPaused = true;
340
- void performUpdate(config, ownVersion, plan, {
341
- execImpl: defaultExecImpl,
342
- onStatus: (status) => {
343
- updateStatus = status;
344
- if (status.phase === 'failure')
345
- watchersPaused = false; // the update stopped — keep developing on the old version.
346
- },
347
- restart: async (result) => {
348
- writeFileSync(statusFile, JSON.stringify({ phase: 'success', ...result }));
349
- await performRestart({ projectRoot: config.projectRoot, port, child, watchers, endpointServer, doctorSummary: result.doctorSummary, files: result.files });
350
- },
351
- }).catch((error) => {
352
- // Minor finding, Fable correction pass: `performUpdate` only ever reports `'failure'` from
353
- // its own guarded steps — a throw outside them (e.g. `writeFileSync(statusFile)` itself
354
- // failing) previously became an unhandled rejection with nothing watching for it.
355
- updateStatus = { phase: 'failure', log: error.message, failedStep: 'Updating' };
356
- watchersPaused = false;
367
+ // Install, migrate, doctor, restart; `from` resumes after a failed migrate or doctor step.
368
+ startUpdate: (plan) => runUpdate(plan),
369
+ getStatus: () => updateStatus,
370
+ dismissStatus: () => {
371
+ updateStatus = { phase: 'idle' };
372
+ },
373
+ resumeUpdate: (step) => {
374
+ if (lastPlan) {
375
+ runUpdate(lastPlan, step);
376
+ return;
377
+ }
378
+ // No plan in memory (this process didn't run the failed update): rebuild it first.
379
+ updateStatus = { phase: 'updating', steps: [{ label: 'Preparing', state: 'now' }] };
380
+ void buildUpdatePlan(config, ownVersion).then((plan) => {
381
+ if ('error' in plan)
382
+ updateStatus = { phase: 'failure', log: plan.error, failedStep: 'Preparing', step: 'other' };
383
+ else
384
+ runUpdate(plan, step);
385
+ }, (error) => {
386
+ updateStatus = { phase: 'failure', log: error.message, failedStep: 'Preparing', step: 'other' };
357
387
  });
358
388
  },
359
- getStatus: () => updateStatus,
360
389
  getVersionStatus: version.getVersionStatus,
361
390
  checkNow: version.checkNow,
362
391
  setAutoCheck: version.setAutoCheck,
@@ -166,6 +166,39 @@ export function createUpdateEndpoint(deps) {
166
166
  });
167
167
  return;
168
168
  }
169
+ if (req.method === 'POST' && req.url === '/update/dismiss' && deps.dismissStatus) {
170
+ const phase = deps.getStatus().phase;
171
+ if (starting || phase === 'updating' || phase === 'restarting') {
172
+ sendJson(res, 409, { error: 'An update is running.' }, deps.allowedOrigin);
173
+ return;
174
+ }
175
+ deps.dismissStatus();
176
+ sendJson(res, 200, deps.getStatus(), deps.allowedOrigin);
177
+ return;
178
+ }
179
+ if (req.method === 'POST' && req.url === '/update/resume' && deps.resumeUpdate) {
180
+ const status = deps.getStatus();
181
+ const step = status.phase === 'failure' ? status.step : undefined;
182
+ if (starting || (step !== 'migrate' && step !== 'doctor')) {
183
+ sendJson(res, 409, { error: 'There is no failed migrate or doctor step to resume.' }, deps.allowedOrigin);
184
+ return;
185
+ }
186
+ // Same reasoning as POST /update: answer first, then run the work on the next tick.
187
+ starting = true;
188
+ sendJson(res, 202, { started: true }, deps.allowedOrigin);
189
+ setImmediate(() => {
190
+ try {
191
+ deps.resumeUpdate(step);
192
+ }
193
+ catch (error) {
194
+ console.warn('Update could not resume: ' + error.message);
195
+ }
196
+ finally {
197
+ starting = false;
198
+ }
199
+ });
200
+ return;
201
+ }
169
202
  if (req.method === 'GET' && req.url === '/update/status') {
170
203
  sendJson(res, 200, deps.getStatus(), deps.allowedOrigin);
171
204
  return;
@@ -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 },
@@ -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.4.9",
4
4
  "license": "MIT",
5
5
  "type": "module",
6
6
  "description": "A browsable, always-current catalog for an Expo / React Native design system.",