@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.
- package/README.md +43 -0
- package/dist/cli/dev.js +66 -37
- package/dist/cli/doctor.js +50 -2
- package/dist/cli/endpoint.js +33 -0
- package/dist/cli/main.js +11 -2
- package/dist/cli/render.js +247 -0
- package/dist/cli/staticPage.js +10 -4
- package/native/catalog/CatalogButton.tsx +46 -0
- package/native/catalog/OverlayDemo.tsx +52 -0
- package/native/catalog/PhoneScreen.tsx +51 -0
- package/native/catalog/UpdatePanel.tsx +114 -103
- package/native/catalog/index.ts +3 -0
- package/native/catalog/overlayDemoState.ts +8 -0
- package/native/catalog/updatePanelState.ts +68 -1
- package/package.json +1 -1
- package/starter-kit/components/BottomSheet/BottomSheet.catalog.tsx +38 -48
- package/starter-kit/components/Dialog/Dialog.catalog.tsx +16 -16
- package/starter-kit/components/Toast/Toast.catalog.tsx +9 -10
- package/starter-kit/pages/ReportIssue.catalog.tsx +17 -13
- package/starter-kit/pages/SavedTrips.catalog.tsx +114 -119
|
@@ -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
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
*
|
|
146
|
-
*
|
|
147
|
-
|
|
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
|
-
<
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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 },
|
package/native/catalog/index.ts
CHANGED
|
@@ -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
|
-
import React
|
|
1
|
+
import React from 'react';
|
|
2
2
|
import { View, Text, StyleSheet } from 'react-native';
|
|
3
|
-
import { defineCatalogPage,
|
|
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
|
-
<
|
|
23
|
-
{
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
}
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
<
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
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
|
|