@krapwoo/ds-viewer 0.4.7 → 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.
Files changed (62) hide show
  1. package/README.md +21 -6
  2. package/dist/cli/dev.js +66 -37
  3. package/dist/cli/doctor.js +4 -2
  4. package/dist/cli/endpoint.js +33 -0
  5. package/dist/cli/init.js +5 -8
  6. package/dist/native/catalog/comparison.js +17 -8
  7. package/native/catalog/CatalogShell.tsx +5 -1
  8. package/native/catalog/ComparisonGrid.tsx +5 -1
  9. package/native/catalog/ComparisonGroups.tsx +7 -1
  10. package/native/catalog/ComparisonList.tsx +7 -1
  11. package/native/catalog/PhoneFrame.tsx +108 -24
  12. package/native/catalog/ReferenceDetails.tsx +8 -0
  13. package/native/catalog/SectionBlock.tsx +10 -4
  14. package/native/catalog/SpecimenAddress.tsx +15 -0
  15. package/native/catalog/SpecimenSurface.tsx +66 -4
  16. package/native/catalog/UpdatePanel.tsx +114 -103
  17. package/native/catalog/comparison.ts +21 -8
  18. package/native/catalog/deviceFrame.ts +14 -0
  19. package/native/catalog/overlayViewport.ts +4 -2
  20. package/native/catalog/specimenSurfaceStyle.ts +54 -0
  21. package/native/catalog/updatePanelState.ts +68 -1
  22. package/package.json +5 -4
  23. package/starter-kit/components/AnimatedChevron/AnimatedChevron.catalog.tsx +1 -1
  24. package/starter-kit/components/Avatar/Avatar.catalog.tsx +1 -1
  25. package/starter-kit/components/Badge/Badge.catalog.tsx +1 -1
  26. package/starter-kit/components/Banner/Banner.catalog.tsx +2 -1
  27. package/starter-kit/components/BottomSheet/BottomSheet.catalog.tsx +5 -1
  28. package/starter-kit/components/Button/Button.catalog.tsx +1 -1
  29. package/starter-kit/components/ButtonGroup/ButtonGroup.catalog.tsx +8 -1
  30. package/starter-kit/components/Card/Card.catalog.tsx +2 -1
  31. package/starter-kit/components/Checkbox/Checkbox.catalog.tsx +2 -2
  32. package/starter-kit/components/Dialog/Dialog.catalog.tsx +1 -1
  33. package/starter-kit/components/Divider/Divider.catalog.tsx +2 -1
  34. package/starter-kit/components/Dock/Dock.catalog.tsx +4 -1
  35. package/starter-kit/components/Dropdown/Dropdown.catalog.tsx +1 -1
  36. package/starter-kit/components/EmptyState/EmptyState.catalog.tsx +2 -1
  37. package/starter-kit/components/FieldContainer/FieldContainer.catalog.tsx +5 -1
  38. package/starter-kit/components/InputClearButton/InputClearButton.catalog.tsx +1 -1
  39. package/starter-kit/components/InputField/InputField.catalog.tsx +3 -1
  40. package/starter-kit/components/List/List.catalog.tsx +2 -1
  41. package/starter-kit/components/ListItem/ListItem.catalog.tsx +7 -1
  42. package/starter-kit/components/Loading/Loading.catalog.tsx +2 -1
  43. package/starter-kit/components/Pill/Pill.catalog.tsx +1 -1
  44. package/starter-kit/components/PillRow/PillRow.catalog.tsx +1 -1
  45. package/starter-kit/components/ProgressDots/ProgressDots.catalog.tsx +1 -1
  46. package/starter-kit/components/Radio/Radio.catalog.tsx +2 -2
  47. package/starter-kit/components/SearchField/SearchField.catalog.tsx +2 -1
  48. package/starter-kit/components/SectionHeader/SectionHeader.catalog.tsx +2 -1
  49. package/starter-kit/components/SegmentedToggle/SegmentedToggle.catalog.tsx +1 -3
  50. package/starter-kit/components/Shimmer/Breathing.catalog.tsx +2 -1
  51. package/starter-kit/components/Shimmer/Shimmer.catalog.tsx +2 -1
  52. package/starter-kit/components/Shimmer/SkeletonGroup.catalog.tsx +5 -1
  53. package/starter-kit/components/Surface/Surface.catalog.tsx +2 -1
  54. package/starter-kit/components/Switch/Switch.catalog.tsx +2 -2
  55. package/starter-kit/components/TextArea/TextArea.catalog.tsx +2 -1
  56. package/starter-kit/components/Toast/Toast.catalog.tsx +2 -1
  57. package/starter-kit/components/Tooltip/Tooltip.catalog.tsx +2 -1
  58. package/starter-kit/components/TopNav/TopNav.catalog.tsx +4 -1
  59. package/starter-kit/components/UnderlineTabs/UnderlineTabs.catalog.tsx +1 -3
  60. package/starter-kit/pages/ReportIssue.catalog.tsx +18 -1
  61. package/starter-kit/pages/SavedTrips.catalog.tsx +27 -6
  62. package/starter-kit/pages/TripPlannerForm.catalog.tsx +7 -1
@@ -6,6 +6,7 @@ import { ComparisonGroups } from './ComparisonGroups';
6
6
  import { ComparisonList } from './ComparisonList';
7
7
  import { ReferenceDetails } from './ReferenceDetails';
8
8
  import { SpecimenSurface } from './SpecimenSurface';
9
+ import { SpecimenAddressProvider } from './SpecimenAddress';
9
10
  import { DEFAULT_SPECIMEN_SURFACE } from './specimenSurfaceStyle';
10
11
  import { TokenSections } from './TokenLayouts';
11
12
  import { MATRIX_LAYOUT, choosePlacement, listGeometry, presentationBlocks, type PresentationBlock } from './comparison';
@@ -169,15 +170,19 @@ function BlockContent<TId extends string>({ block, def, surface }: { block: Pres
169
170
  case 'grid':
170
171
  return <ComparisonGrid def={block.comparison} size={block.size} sectionId={def.id} surface={surface} />;
171
172
  case 'grouped':
172
- return <ComparisonGroups groups={block.groups} size={block.size} label={def.id} surface={surface} />;
173
+ return <ComparisonGroups groups={block.groups} size={block.size} label={def.id} surface={surface} pageId={def.id} />;
173
174
  case 'list':
174
- return <ComparisonList items={block.items} size={block.size} label={`${def.id}: ${block.title}`} maxColumns={block.maxColumns} surface={surface} />;
175
+ return <ComparisonList items={block.items} size={block.size} label={`${def.id}: ${block.title}`} maxColumns={block.maxColumns} surface={surface} pageId={def.id} />;
175
176
  case 'preview':
176
177
  // A token gallery's `preview` block shows full-width raw token data, not a component
177
178
  // specimen — it keeps its current, unstaged presentation.
178
179
  // `Preview`'s own `widths === 'full'` check already excludes a full-width or token-gallery
179
180
  // preview from ever reaching the table layout, regardless of `def.previewLayout`.
180
- return def.render ? <Preview render={def.render} widths={block.widths} surface={def.tokenGallery ? undefined : surface} layout={def.previewLayout === 'table' ? 'table' : undefined} /> : null;
181
+ return def.render ? (
182
+ <SpecimenAddressProvider address={{ pageId: def.id, slot: 'preview', itemKey: 'preview' }}>
183
+ <Preview render={def.render} widths={block.widths} surface={def.tokenGallery ? undefined : surface} layout={def.previewLayout === 'table' ? 'table' : undefined} />
184
+ </SpecimenAddressProvider>
185
+ ) : null;
181
186
  case 'tokenSections':
182
187
  return <TokenSections sections={block.sections} columns={block.columns} />;
183
188
  default:
@@ -369,7 +374,8 @@ const styles = StyleSheet.create({
369
374
  blocks: { gap: CATALOG_LAYOUT.blockGap, marginBottom: CATALOG_LAYOUT.blockGap },
370
375
  blocksSide: { flexDirection: 'row', alignItems: 'flex-start' },
371
376
  blockFill: { flex: 1, minWidth: 0 },
372
- frames: { flexDirection: 'row', flexWrap: 'wrap', gap: CATALOG_SPACE.xl, alignItems: 'flex-start' },
377
+ // Centred in the card, like every other example (a single phone-width frame used to sit left).
378
+ frames: { flexDirection: 'row', flexWrap: 'wrap', gap: CATALOG_SPACE.xl, alignItems: 'flex-start', justifyContent: 'center' },
373
379
  frame: { maxWidth: '100%', gap: CATALOG_SPACE.sm },
374
380
  frameLabel: {
375
381
  fontSize: CATALOG_TYPE.tableHeader,
@@ -0,0 +1,15 @@
1
+ import React, { createContext, useContext } from 'react';
2
+ import type { OverlayAddress } from './overlayViewport';
3
+
4
+ const SpecimenAddressContext = createContext<OverlayAddress | undefined>(undefined);
5
+
6
+ /** Tells the example rendered inside it which catalog example it is (page, slot, item key), so a
7
+ * `PhoneFrame` in it can open that one example in its own device-sized document. Set by the
8
+ * catalog's own list, grid and preview renderers, and by the child document itself. */
9
+ export function SpecimenAddressProvider({ address, children }: { address: OverlayAddress | undefined; children: React.ReactNode }) {
10
+ return <SpecimenAddressContext.Provider value={address}>{children}</SpecimenAddressContext.Provider>;
11
+ }
12
+
13
+ export function useSpecimenAddress(): OverlayAddress | undefined {
14
+ return useContext(SpecimenAddressContext);
15
+ }
@@ -1,7 +1,11 @@
1
1
  import React, { useCallback, useEffect, useRef, useState } from 'react';
2
2
  import { View, StyleSheet, type StyleProp, type ViewStyle } from 'react-native';
3
3
  import { CATALOG_COLOR } from './tokens';
4
- import { DEFAULT_SPECIMEN_SURFACE, SPECIMEN_SURFACE_WIDTH_STYLE, specimenContentAlignItems, specimenContentBoundedStyle } from './specimenSurfaceStyle';
4
+ import {
5
+ DEFAULT_SPECIMEN_SURFACE, SPECIMEN_SURFACE_WIDTH_STYLE, overflowsCell, paintedExtent, shouldCenterFilledContent, specimenContentAlignItems,
6
+ specimenContentBoundedStyle, visibleBoxes, type LayoutBox,
7
+ } from './specimenSurfaceStyle';
8
+ import { useSpecimenAddress } from './SpecimenAddress';
5
9
  import { blendsIntoCell, measurePaintedBoxes } from './specimenFill';
6
10
  import type { SpecimenAlign, SpecimenSurfaceKind } from './types';
7
11
 
@@ -75,12 +79,70 @@ export function SpecimenSurface({
75
79
  * (e.g. Badge's `alignSelf: 'flex-start'`) free to override that positioning outright, since
76
80
  * `alignItems` only sets a child's *default* `alignSelf`. `fill` keeps the existing column +
77
81
  * `alignItems` stretch behavior, unchanged. */
82
+ type MeasurableNode = {
83
+ getBoundingClientRect: () => { left: number; right: number; width: number };
84
+ children: ArrayLike<MeasurableNode>;
85
+ childElementCount: number;
86
+ tagName: string;
87
+ };
88
+
89
+ /** The example's element tree as `LayoutBox`es (up to 400 elements), so `visibleBoxes` can skip
90
+ * what scrolling or clipping containers hide. */
91
+ function readTree(root: MeasurableNode, browser: Browser): LayoutBox {
92
+ let budget = 400;
93
+ const read = (el: MeasurableNode): LayoutBox => {
94
+ const rect = el.getBoundingClientRect();
95
+ const style = browser.getComputedStyle!(el) as unknown as { backgroundColor: string; borderTopWidth: string; visibility: string; overflowX: string };
96
+ const kids: LayoutBox[] = [];
97
+ const children = el.children;
98
+ for (let i = 0; i < children.length && budget > 0; i++) {
99
+ budget -= 1;
100
+ const child = children[i];
101
+ if (child.getBoundingClientRect().width < 1) continue;
102
+ kids.push(read(child));
103
+ }
104
+ return {
105
+ left: rect.left,
106
+ right: rect.right,
107
+ painted:
108
+ style.visibility !== 'hidden' &&
109
+ (el.childElementCount === 0 || el.tagName === 'svg' || el.tagName === 'IFRAME' ||
110
+ style.backgroundColor !== 'rgba(0, 0, 0, 0)' || parseFloat(style.borderTopWidth) > 0),
111
+ clips: style.overflowX === 'hidden' || style.overflowX === 'auto' || style.overflowX === 'scroll' || style.overflowX === 'clip',
112
+ children: kids,
113
+ };
114
+ };
115
+ return read(root);
116
+ }
117
+
78
118
  export function SpecimenContent({ fill, align, children }: { fill?: boolean; align?: SpecimenAlign; children: React.ReactNode }) {
119
+ // After layout (browser only): a `fill` example whose visible content is narrower than the cell
120
+ // (a fixed-width skeleton, a tooltip around a small trigger) is centred instead of sitting
121
+ // against the leading edge; an explicit `align` always wins. And in development, an example
122
+ // wider than its cell logs a warning naming a wider `specimenSize`.
123
+ const address = useSpecimenAddress();
124
+ const ref = useRef<View>(null);
125
+ const [centerNarrow, setCenterNarrow] = useState(false);
126
+ const warned = useRef(false);
127
+ const measure = useCallback(() => {
128
+ const browser = globalThis as unknown as Browser;
129
+ const node = ref.current as unknown as MeasurableNode | null;
130
+ if (!browser.Element || !browser.getComputedStyle || !node?.getBoundingClientRect) return;
131
+ const wrapperWidth = node.getBoundingClientRect().width;
132
+ const visible = paintedExtent(visibleBoxes(readTree(node, browser)));
133
+ if (fill && align === undefined && !centerNarrow && shouldCenterFilledContent(visible, wrapperWidth)) setCenterNarrow(true);
134
+ if ((globalThis as { __DEV__?: boolean }).__DEV__ && !warned.current && overflowsCell(visible, wrapperWidth)) {
135
+ warned.current = true;
136
+ console.warn(
137
+ `[Catalog] ${address ? `${address.pageId} (${address.itemKey})` : 'An example'} is ${Math.round(visible)}px wide in a ${Math.round(wrapperWidth)}px cell, so it's cut off. Give the page a wider specimenSize ('regular' or 'wide').`,
138
+ );
139
+ }
140
+ }, [fill, align, centerNarrow, address]);
79
141
  if (fill) {
80
- const alignItems = specimenContentAlignItems(fill, align);
81
- return <View style={[SPECIMEN_SURFACE_WIDTH_STYLE, { alignItems }]}>{children}</View>;
142
+ const alignItems = centerNarrow ? 'center' : specimenContentAlignItems(fill, align);
143
+ return <View ref={ref} onLayout={measure} style={[SPECIMEN_SURFACE_WIDTH_STYLE, { alignItems }]}>{children}</View>;
82
144
  }
83
- return <View style={specimenContentBoundedStyle(align)}>{children}</View>;
145
+ return <View ref={ref} onLayout={measure} style={specimenContentBoundedStyle(align)}>{children}</View>;
84
146
  }
85
147
 
86
148
  // 'dark' reuses CATALOG_COLOR.text (the catalog's own darkest token); there is no dark-surface token.
@@ -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 },
@@ -229,6 +229,9 @@ export function remainingStates(states: VariantSlot | undefined, comparison: Com
229
229
 
230
230
  export interface ListItem {
231
231
  key: string;
232
+ /** Which authored slot this item came from, so a device frame inside it can address it
233
+ * (`overlayViewport.ts`). */
234
+ slot?: 'variants' | 'states';
232
235
  label: string;
233
236
  node: unknown;
234
237
  fill?: boolean;
@@ -265,10 +268,20 @@ export function slotSize<TId extends string>(def: SectionDef<TId>, slot: Variant
265
268
  return def.specimenSize ?? (slot.itemsFill ? 'wide' : 'regular');
266
269
  }
267
270
 
268
- function slotItems(slot: VariantSlot): ListItem[] {
271
+ /** Full-width (filled) examples are block components (cards, banners, rows): narrower than about a
272
+ * third of a laptop's content width they stop reading as themselves, so they wrap at most 3
273
+ * across unless the page sets its own `maxColumns`. Small centred examples wrap freely. */
274
+ export const FILLED_SLOT_MAX_COLUMNS = 3;
275
+
276
+ function slotColumnCap(slot: VariantSlot): { maxColumns?: 1 | 2 | 3 | 4 | 5 } {
277
+ if (slot.maxColumns !== undefined) return { maxColumns: slot.maxColumns };
278
+ return slot.itemsFill ? { maxColumns: FILLED_SLOT_MAX_COLUMNS } : {};
279
+ }
280
+
281
+ function slotItems(slot: VariantSlot, from: 'variants' | 'states'): ListItem[] {
269
282
  // Nullish, not `||`: an item's own explicit `fill: false` must override an inherited
270
283
  // `itemsFill: true`, which `false || slot.itemsFill` would silently discard.
271
- return slot.items.map((item) => ({ key: item.key, label: item.name, node: item.node, fill: item.fill ?? slot.itemsFill, surface: item.surface, align: item.align }));
284
+ return slot.items.map((item) => ({ key: item.key, slot: from, label: item.name, node: item.node, fill: item.fill ?? slot.itemsFill, surface: item.surface, align: item.align }));
272
285
  }
273
286
 
274
287
  /** Preview widths, each capped at phone width. 'full' is kept for catalog chrome and token pages. */
@@ -289,8 +302,8 @@ function groupStates(variants: VariantSlot, states: VariantSlot): { groups: List
289
302
  const groups = variants.items.map((variant) => {
290
303
  const own = states.items.filter((s) => s.group === variant.key);
291
304
  const items = own.length > 0
292
- ? own.map((s) => ({ key: s.key, label: s.name, node: s.node, fill: fill(s, states), surface: s.surface, align: s.align }))
293
- : [{ key: variant.key, label: variant.name, node: variant.node, fill: fill(variant, variants), surface: variant.surface, align: variant.align }];
305
+ ? own.map((s) => ({ key: s.key, slot: 'states' as const, label: s.name, node: s.node, fill: fill(s, states), surface: s.surface, align: s.align }))
306
+ : [{ key: variant.key, slot: 'variants' as const, label: variant.name, node: variant.node, fill: fill(variant, variants), surface: variant.surface, align: variant.align }];
294
307
  return { key: variant.key, label: variant.name, items };
295
308
  });
296
309
  const rest = states.items.filter((s) => s.group === undefined || !variantKeys.has(s.group));
@@ -337,8 +350,8 @@ export function presentationBlocks<TId extends string>(def: SectionDef<TId>, opt
337
350
  kind: 'list',
338
351
  title: 'Variants',
339
352
  size: slotSize(def, def.variants),
340
- items: slotItems(def.variants),
341
- ...(def.variants.maxColumns !== undefined ? { maxColumns: def.variants.maxColumns } : {}),
353
+ items: slotItems(def.variants, 'variants'),
354
+ ...slotColumnCap(def.variants),
342
355
  });
343
356
  } else if (def.render) {
344
357
  blocks.push({ kind: 'preview', title: 'Preview', widths: previewWidths(def.previewWidths, options.defaultPreviewWidths) });
@@ -351,8 +364,8 @@ export function presentationBlocks<TId extends string>(def: SectionDef<TId>, opt
351
364
  kind: 'list',
352
365
  title: secondary ? 'Other configurations' : 'States / configurations',
353
366
  size: slotSize(def, states),
354
- items: slotItems(states),
355
- ...(states.maxColumns !== undefined ? { maxColumns: states.maxColumns } : {}),
367
+ items: slotItems(states, 'states'),
368
+ ...slotColumnCap(states),
356
369
  });
357
370
  }
358
371
 
@@ -0,0 +1,14 @@
1
+ /**
2
+ * The device every `PhoneFrame` shows: a real phone's viewport, in points, at the small end of
3
+ * current phones (iPhone SE, 3rd generation). Examples lay out at exactly these dimensions; a cell
4
+ * narrower than the device scales the whole viewport down instead of squeezing its layout. Kept
5
+ * free of runtime imports so `node --test` can check it.
6
+ */
7
+ export const DEVICE_FRAME = { name: 'iPhone SE', width: 375, height: 667 } as const;
8
+
9
+ /** How much to scale the device so it fits `availableWidth` (never enlarged). An unmeasured cell
10
+ * (0) renders at full size rather than collapsing. */
11
+ export function deviceFrameScale(availableWidth: number): number {
12
+ if (!(availableWidth > 0)) return 1;
13
+ return Math.min(1, availableWidth / DEVICE_FRAME.width);
14
+ }
@@ -9,7 +9,7 @@ import type React from 'react';
9
9
  import type { SectionDef } from './types.ts';
10
10
 
11
11
  /** Which block on a page an addressed specimen's `node` lives in — see `findOverlayNode`. */
12
- export type OverlaySlot = 'variants' | 'states' | 'comparison';
12
+ export type OverlaySlot = 'variants' | 'states' | 'comparison' | 'preview';
13
13
 
14
14
  /** Identifies exactly one authored specimen: a page id (`SectionDef.id`), which block it lives
15
15
  * in, and that item's own key — `VariantExample.key` for `'variants'`/`'states'`, or this
@@ -23,7 +23,7 @@ export interface OverlayAddress {
23
23
  itemKey: string;
24
24
  }
25
25
 
26
- const OVERLAY_SLOTS: readonly OverlaySlot[] = ['variants', 'states', 'comparison'];
26
+ const OVERLAY_SLOTS: readonly OverlaySlot[] = ['variants', 'states', 'comparison', 'preview'];
27
27
 
28
28
  /** `encodeURIComponent` leaves `.` untouched (it's an unreserved character), which would let a
29
29
  * literal `.` inside a segment collide with the `.`-joined encoding below; escape it too so
@@ -117,6 +117,8 @@ export function findOverlayNode(sections: readonly SectionDef<string>[], address
117
117
  if (!def) return undefined;
118
118
  if (address.slot === 'variants') return def.variants?.items.find((item) => item.key === address.itemKey)?.node;
119
119
  if (address.slot === 'states') return def.states?.items.find((item) => item.key === address.itemKey)?.node;
120
+ // A `render()` page's preview (one per page; `itemKey` is always 'preview').
121
+ if (address.slot === 'preview') return def.render?.();
120
122
  // Matched by recomputing each cell's own canonical key, never by splitting `address.itemKey` —
121
123
  // a row/column key may itself contain any delimiter this module could pick.
122
124
  return def.comparison?.cells.find((cell) => comparisonCellItemKey(cell.rowKey, cell.columnKey) === address.itemKey)?.node;
@@ -88,3 +88,57 @@ export function specimenContentBoundedStyle(align: SpecimenAlign | undefined) {
88
88
  /** Each example's cell turns gray only when its example would vanish against white (see
89
89
  * `specimenFill.ts`). A page can force a fill with `specimenSurface`. */
90
90
  export const DEFAULT_SPECIMEN_SURFACE: SpecimenSurfaceKind = 'auto';
91
+
92
+ /** A `fill` example stretches to its cell, but one with its own fixed width (a 160-point skeleton
93
+ * bar) stays that width; it should then sit in the middle of the cell, not against its leading
94
+ * edge. True when the measured example is narrower than its wrapper (by more than rounding). */
95
+ export function shouldCenterFilledContent(contentWidth: number, wrapperWidth: number): boolean {
96
+ if (!(contentWidth > 0) || !(wrapperWidth > 0)) return false;
97
+ return contentWidth < wrapperWidth - 1;
98
+ }
99
+
100
+ /** The width of what's actually visible in an example: from the leftmost to the rightmost painted
101
+ * box (text, an icon, anything with a fill), ignoring transparent layout wrappers that may span
102
+ * the whole cell. */
103
+ export function paintedExtent(boxes: readonly { left: number; right: number; painted: boolean }[]): number {
104
+ let left = Infinity;
105
+ let right = -Infinity;
106
+ for (const box of boxes) {
107
+ if (!box.painted || box.right <= box.left) continue;
108
+ left = Math.min(left, box.left);
109
+ right = Math.max(right, box.right);
110
+ }
111
+ return Number.isFinite(left) ? right - left : 0;
112
+ }
113
+
114
+ /** True when an example is wider than its cell (beyond rounding): its edges are cut off or touch
115
+ * the neighbouring cell, so the page should use a wider `specimenSize`. */
116
+ export function overflowsCell(contentWidth: number, cellWidth: number): boolean {
117
+ return contentWidth > 0 && cellWidth > 0 && contentWidth > cellWidth + 1;
118
+ }
119
+
120
+ /** One element of an example as measured in the browser. `clips` is true for a container that
121
+ * hides or scrolls its overflow (a scrolling row, a device frame). */
122
+ export interface LayoutBox {
123
+ left: number;
124
+ right: number;
125
+ painted: boolean;
126
+ clips: boolean;
127
+ children: LayoutBox[];
128
+ }
129
+
130
+ /** The boxes a reader can actually see: a clipping container counts as its own box, and what it
131
+ * hides or scrolls inside it is skipped. */
132
+ export function visibleBoxes(root: LayoutBox): { left: number; right: number; painted: boolean }[] {
133
+ const out: { left: number; right: number; painted: boolean }[] = [];
134
+ const walk = (box: LayoutBox, isRoot: boolean) => {
135
+ if (!isRoot && box.clips) {
136
+ out.push({ left: box.left, right: box.right, painted: true });
137
+ return;
138
+ }
139
+ if (!isRoot) out.push({ left: box.left, right: box.right, painted: box.painted });
140
+ for (const child of box.children) walk(child, false);
141
+ };
142
+ walk(root, true);
143
+ return out;
144
+ }