@krapwoo/ds-viewer 0.4.4 → 0.4.6

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.
@@ -43,7 +43,7 @@ function NavItem<TId extends string>({ id, label, active, onPress }: { id: TId;
43
43
  );
44
44
  }
45
45
 
46
- function FooterLink({ label, active, onPress }: { label: string; active: boolean; onPress: () => void }) {
46
+ function FooterLink({ label, kind, active, onPress }: { label: string; kind: 'update' | 'version'; active: boolean; onPress: () => void }) {
47
47
  const [hovered, setHovered] = useState(false);
48
48
  const [focused, setFocused] = useState(false);
49
49
  return (
@@ -63,7 +63,8 @@ function FooterLink({ label, active, onPress }: { label: string; active: boolean
63
63
  focused && styles.focusRing,
64
64
  ]}
65
65
  >
66
- <View style={styles.footerDot} />
66
+ {/* The dot marks an available update; the everyday version line is quiet. */}
67
+ {kind === 'update' && <View style={styles.footerDot} />}
67
68
  <Text style={[styles.footerLabel, active && styles.footerLabelActive]}>{label}</Text>
68
69
  </Pressable>
69
70
  );
@@ -96,10 +97,10 @@ export function CatalogSidebar<TId extends string>({
96
97
  labels?: ReadonlyMap<string, string>;
97
98
  active: string | undefined;
98
99
  onPress: (id: TId) => void;
99
- /** The quiet update-notice link below the nav list (design §5's footer line), or `undefined`
100
- * when there is no update to show. `active` is true while the update page itself is open — it
101
- * gets the same active styling a selected nav item would. */
102
- footer?: { label: string; active: boolean; onPress: () => void };
100
+ /** The link below the nav list that opens the update page: the installed version (`'version'`),
101
+ * or the update notice with a dot (`'update'`, design §5's footer line). `active` is true while
102
+ * the update page itself is open — it gets the same active styling a selected nav item would. */
103
+ footer?: { label: string; kind?: 'update' | 'version'; active: boolean; onPress: () => void };
103
104
  }) {
104
105
  const [query, setQuery] = useState('');
105
106
  // A bundled local image (the only kind `cli/workspace.ts` ever produces — see its doc comment
@@ -201,7 +202,7 @@ export function CatalogSidebar<TId extends string>({
201
202
  </ScrollView>
202
203
  {footer && (
203
204
  <View style={styles.footerContainer}>
204
- <FooterLink label={footer.label} active={footer.active} onPress={footer.onPress} />
205
+ <FooterLink label={footer.label} kind={footer.kind ?? 'update'} active={footer.active} onPress={footer.onPress} />
205
206
  </View>
206
207
  )}
207
208
  </View>
@@ -2,7 +2,8 @@ import React from 'react';
2
2
  import { View, Text, ScrollView, StyleSheet } from 'react-native';
3
3
  import { CATALOG_COLOR, CATALOG_RADIUS, CATALOG_TYPE } from './tokens';
4
4
  import { COLUMN_MIN_WIDTH, MATRIX_LAYOUT, axisItems, cellKey, gridWidthBounds, indexCells, validateComparison } from './comparison';
5
- import { SpecimenSurface } from './SpecimenSurface';
5
+ import { SpecimenContent, SpecimenSurface } from './SpecimenSurface';
6
+ import { DEFAULT_SPECIMEN_SURFACE } from './specimenSurfaceStyle';
6
7
  import type { ComparisonDef, SpecimenSize, SpecimenSurfaceKind } from './types';
7
8
 
8
9
  /**
@@ -15,7 +16,7 @@ export function ComparisonGrid({
15
16
  def,
16
17
  size,
17
18
  sectionId,
18
- surface = 'neutral',
19
+ surface = DEFAULT_SPECIMEN_SURFACE,
19
20
  }: {
20
21
  def: ComparisonDef;
21
22
  size: SpecimenSize;
@@ -63,17 +64,17 @@ export function ComparisonGrid({
63
64
  {columns.map((c, ci) => {
64
65
  const cell = cells.get(cellKey(row.key, c.key));
65
66
  return (
66
- <View key={c.key} role="cell" style={[styles.cell, column, styles.bodyRow, styles.specimenCell, ci === lastColumn && styles.lastColumn]}>
67
- {cell?.node !== undefined ? (
68
- <SpecimenSurface surface={surface} style={cell.fill && styles.fill}>
69
- {cell.node}
70
- </SpecimenSurface>
71
- ) : (
67
+ cell?.node !== undefined ? (
68
+ <SpecimenSurface key={c.key} role="cell" surface={surface} style={[styles.cell, column, styles.bodyRow, styles.specimenCell, ci === lastColumn && styles.lastColumn]}>
69
+ <SpecimenContent fill={cell.fill}>{cell.node}</SpecimenContent>
70
+ </SpecimenSurface>
71
+ ) : (
72
+ <View key={c.key} role="cell" style={[styles.cell, column, styles.bodyRow, styles.specimenCell, ci === lastColumn && styles.lastColumn]}>
72
73
  <Text style={styles.unavailable}>
73
74
  {cell?.unavailableReason ? `Not supported — ${cell.unavailableReason}` : 'Missing example'}
74
75
  </Text>
75
- )}
76
- </View>
76
+ </View>
77
+ )
77
78
  );
78
79
  })}
79
80
  </View>
@@ -102,7 +103,6 @@ const styles = StyleSheet.create({
102
103
  headerCell: { backgroundColor: CATALOG_COLOR.surfaceMuted, justifyContent: 'center' },
103
104
  bodyRow: { minHeight: MATRIX_LAYOUT.rowMinHeight },
104
105
  specimenCell: { alignItems: 'center', justifyContent: 'center' },
105
- fill: { alignSelf: 'stretch' },
106
106
  headerText: { fontSize: CATALOG_TYPE.tableHeader, fontWeight: '800', letterSpacing: 0.44, color: CATALOG_COLOR.text },
107
107
  rowHeaderText: { fontSize: CATALOG_TYPE.md, fontWeight: '700', color: CATALOG_COLOR.text },
108
108
  unavailable: { fontSize: CATALOG_TYPE.sm, fontStyle: 'italic', textAlign: 'center', color: CATALOG_COLOR.textMuted },
@@ -2,7 +2,8 @@ import React from 'react';
2
2
  import { View, Text, ScrollView, StyleSheet } from 'react-native';
3
3
  import { CATALOG_COLOR, CATALOG_RADIUS, CATALOG_TYPE } from './tokens';
4
4
  import { COLUMN_MIN_WIDTH, MATRIX_LAYOUT, type ListGroup } from './comparison';
5
- import { SpecimenSurface } from './SpecimenSurface';
5
+ import { SpecimenContent, SpecimenSurface } from './SpecimenSurface';
6
+ import { DEFAULT_SPECIMEN_SURFACE } from './specimenSurfaceStyle';
6
7
  import type { SpecimenSize, SpecimenSurfaceKind } from './types';
7
8
 
8
9
  /**
@@ -16,7 +17,7 @@ export function ComparisonGroups({
16
17
  groups,
17
18
  size,
18
19
  label,
19
- surface = 'neutral',
20
+ surface = DEFAULT_SPECIMEN_SURFACE,
20
21
  }: {
21
22
  groups: ListGroup[];
22
23
  size: SpecimenSize;
@@ -46,11 +47,9 @@ export function ComparisonGroups({
46
47
  <View style={styles.caption}>
47
48
  <Text style={styles.captionText}>{item.label}</Text>
48
49
  </View>
49
- <View style={styles.specimen}>
50
- <SpecimenSurface surface={surface} style={item.fill && styles.fill}>
51
- {item.node as React.ReactNode}
52
- </SpecimenSurface>
53
- </View>
50
+ <SpecimenSurface surface={surface} style={styles.specimen}>
51
+ <SpecimenContent fill={item.fill}>{item.node as React.ReactNode}</SpecimenContent>
52
+ </SpecimenSurface>
54
53
  </View>
55
54
  );
56
55
  })}
@@ -102,5 +101,4 @@ const styles = StyleSheet.create({
102
101
  alignItems: 'center',
103
102
  justifyContent: 'center',
104
103
  },
105
- fill: { alignSelf: 'stretch' },
106
104
  });
@@ -2,7 +2,8 @@ import React, { useState } from 'react';
2
2
  import { View, Text, StyleSheet, type LayoutChangeEvent } from 'react-native';
3
3
  import { CATALOG_COLOR, CATALOG_RADIUS, CATALOG_TYPE } from './tokens';
4
4
  import { MATRIX_LAYOUT, listGeometry, type ListItem } from './comparison';
5
- import { SpecimenSurface } from './SpecimenSurface';
5
+ import { SpecimenContent, SpecimenSurface } from './SpecimenSurface';
6
+ import { DEFAULT_SPECIMEN_SURFACE } from './specimenSurfaceStyle';
6
7
  import type { SpecimenSize, SpecimenSurfaceKind } from './types';
7
8
 
8
9
  /**
@@ -17,7 +18,7 @@ export function ComparisonList({
17
18
  size,
18
19
  label,
19
20
  maxColumns,
20
- surface = 'neutral',
21
+ surface = DEFAULT_SPECIMEN_SURFACE,
21
22
  }: {
22
23
  items: ListItem[];
23
24
  size: SpecimenSize;
@@ -60,11 +61,9 @@ export function ComparisonList({
60
61
  <View style={styles.caption}>
61
62
  <Text style={styles.captionText}>{item.label}</Text>
62
63
  </View>
63
- <View style={styles.specimen}>
64
- <SpecimenSurface surface={surface} style={item.fill && styles.fill}>
65
- {item.node as React.ReactNode}
66
- </SpecimenSurface>
67
- </View>
64
+ <SpecimenSurface surface={surface} style={styles.specimen}>
65
+ <SpecimenContent fill={item.fill}>{item.node as React.ReactNode}</SpecimenContent>
66
+ </SpecimenSurface>
68
67
  </View>
69
68
  );
70
69
  })}
@@ -101,5 +100,4 @@ const styles = StyleSheet.create({
101
100
  alignItems: 'center',
102
101
  justifyContent: 'center',
103
102
  },
104
- fill: { alignSelf: 'stretch' },
105
103
  });
@@ -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 { DEFAULT_SPECIMEN_SURFACE } from './specimenSurfaceStyle';
9
10
  import { TokenSections } from './TokenLayouts';
10
11
  import { choosePlacement, listGeometry, presentationBlocks, type PresentationBlock } from './comparison';
11
12
  import type { PreviewWidths, SectionDef, SpecimenSurfaceKind } from './types';
@@ -104,21 +105,23 @@ function frameLabel(width: number): string {
104
105
  * chrome and token galleries. Every frame is its own live instance with its own state. `surface`
105
106
  * is omitted for token galleries, which keep their current, unstaged presentation. */
106
107
  function Preview({ render, widths, surface }: { render: () => React.ReactNode; widths: PreviewWidths; surface?: SpecimenSurfaceKind }) {
107
- const stage = (node: React.ReactNode) => (surface ? <SpecimenSurface surface={surface}>{node}</SpecimenSurface> : node);
108
- if (widths === 'full') return <View style={styles.previewCard}>{stage(render())}</View>;
108
+ // Token galleries pass no surface: their card stays white.
109
+ const kind = surface ?? 'transparent';
110
+ if (widths === 'full') return <SpecimenSurface surface={kind} style={styles.previewCard}>{render()}</SpecimenSurface>;
109
111
  return (
110
112
  <View style={styles.previewCard}>
111
113
  <View style={styles.frames}>
112
114
  {widths.map((width, i) => (
113
- <View
115
+ <SpecimenSurface
114
116
  key={`${width}-${i}`}
117
+ surface={kind}
115
118
  // Two live instances of the same component: name each so assistive tech can tell them apart.
116
119
  {...(widths.length > 1 ? ({ role: 'group', 'aria-label': frameLabel(width) } as Record<string, unknown>) : null)}
117
120
  style={[styles.frame, { width }]}
118
121
  >
119
122
  {widths.length > 1 && <Text style={styles.frameLabel}>{frameLabel(width)}</Text>}
120
- {stage(render())}
121
- </View>
123
+ {render()}
124
+ </SpecimenSurface>
122
125
  ))}
123
126
  </View>
124
127
  </View>
@@ -246,7 +249,7 @@ export function SectionBlock<TId extends string>({
246
249
  checkCompleteness(def);
247
250
  // Memoized so Blocks' placement effect runs on real changes, not on every render.
248
251
  const blocks = useMemo(() => presentationBlocks(def, { defaultPreviewWidths }), [def, defaultPreviewWidths]);
249
- const surface = def.specimenSurface ?? 'neutral';
252
+ const surface = def.specimenSurface ?? DEFAULT_SPECIMEN_SURFACE;
250
253
  // react-native-web reads `aria-level`; React Native's prop types do not declare it.
251
254
  const headingLevelProps = { 'aria-level': headingLevel } as Record<string, unknown>;
252
255
 
@@ -1,46 +1,84 @@
1
- import React from 'react';
1
+ import React, { useCallback, useEffect, useRef, useState } from 'react';
2
2
  import { View, StyleSheet, type StyleProp, type ViewStyle } from 'react-native';
3
- import { CATALOG_COLOR, CATALOG_RADIUS, CATALOG_SPACE } from './tokens';
4
- import { SPECIMEN_SURFACE_WIDTH_STYLE } from './specimenSurfaceStyle';
3
+ import { CATALOG_COLOR } from './tokens';
4
+ import { DEFAULT_SPECIMEN_SURFACE, SPECIMEN_SURFACE_WIDTH_STYLE } from './specimenSurfaceStyle';
5
+ import { blendsIntoCell, measurePaintedBoxes } from './specimenFill';
5
6
  import type { SpecimenSurfaceKind } from './types';
6
7
 
8
+ type Browser = {
9
+ getComputedStyle?: (element: unknown) => never;
10
+ Element?: new () => unknown;
11
+ requestAnimationFrame?: (callback: () => void) => number;
12
+ };
13
+
7
14
  /**
8
- * The catalog-owned background directly behind a live component specimen — shared by
9
- * `ComparisonList`, `ComparisonGroups`, `ComparisonGrid`, and `SectionBlock`'s component preview,
10
- * so a page's `specimenSurface` renders identically everywhere a specimen appears. Purely a fixed
11
- * backdrop: it never reads the wrapped component's own colors, never adds a border, and is never
12
- * used for token galleries/sections (those keep their existing full-width presentation). Always
13
- * stretches to its parent's width (see `specimenSurfaceStyle.ts`) rather than shrink-wrapping to
14
- * its content, centering that content itself — so a percentage-width specimen resolves against
15
- * the real comparison-cell width instead of collapsing to 0px.
15
+ * An example's whole cell area — shared by `ComparisonList`, `ComparisonGroups`,
16
+ * `ComparisonGrid`, and `SectionBlock`'s preview frames, so a page's `specimenSurface` fills every
17
+ * example cell the same way. With `'auto'` (the default) the cell measures its example after it
18
+ * draws and turns gray only when the example would vanish against white (a white card, sheet or
19
+ * neutral banner; see `specimenFill.ts`). Explicit kinds fill the cell without measuring. Adds no
20
+ * padding or border of its own: pass the cell's own layout in `style`. Measuring needs the
21
+ * browser's DOM; elsewhere `'auto'` leaves the cell white. Extra props (e.g. `role`) go to the View.
16
22
  */
17
23
  export function SpecimenSurface({
18
- surface = 'neutral',
24
+ surface = DEFAULT_SPECIMEN_SURFACE,
19
25
  style,
20
26
  children,
27
+ ...viewProps
21
28
  }: {
22
29
  surface?: SpecimenSurfaceKind;
23
30
  style?: StyleProp<ViewStyle>;
24
31
  children: React.ReactNode;
32
+ [prop: string]: unknown;
25
33
  }) {
26
- return <View style={[styles.base, SURFACE_FILL[surface], style]}>{children}</View>;
34
+ const ref = useRef<View>(null);
35
+ const [blends, setBlends] = useState(false);
36
+
37
+ const measure = useCallback(() => {
38
+ if (surface !== 'auto') return;
39
+ const browser = globalThis as unknown as Browser;
40
+ const node = ref.current as unknown;
41
+ if (!browser.getComputedStyle || !browser.Element || !(node instanceof browser.Element)) return;
42
+ const { boxes, cellArea } = measurePaintedBoxes(node as Parameters<typeof measurePaintedBoxes>[0], (element) => browser.getComputedStyle!(element));
43
+ setBlends(blendsIntoCell(boxes, cellArea));
44
+ }, [surface]);
45
+
46
+ useEffect(() => {
47
+ const raf = (globalThis as unknown as Browser).requestAnimationFrame;
48
+ if (surface !== 'auto' || !raf) return;
49
+ // Two frames, so the example's own styles and fonts have applied before measuring.
50
+ let cancelled = false;
51
+ raf(() => raf(() => {
52
+ if (!cancelled) measure();
53
+ }));
54
+ return () => {
55
+ cancelled = true;
56
+ };
57
+ }, [surface, measure]);
58
+
59
+ const fill = surface === 'auto' ? (blends ? FILL.neutral : undefined) : FILL[surface];
60
+ return (
61
+ <View ref={ref} onLayout={surface === 'auto' ? measure : undefined} style={[style, fill]} {...viewProps}>
62
+ {children}
63
+ </View>
64
+ );
27
65
  }
28
66
 
29
- // 'dark' reuses CATALOG_COLOR.text (the catalog's own darkest token) rather than adding a new
30
- // background-specific color — the catalog chrome has no existing dark-surface token to reach for.
31
- const SURFACE_FILL: Record<SpecimenSurfaceKind, ViewStyle> = {
32
- neutral: { backgroundColor: CATALOG_COLOR.surfaceMuted },
67
+ /** Wraps one example inside its cell: full cell width (so a percentage-width example resolves
68
+ * against the cell), centered unless the example fills the cell. */
69
+ export function SpecimenContent({ fill, children }: { fill?: boolean; children: React.ReactNode }) {
70
+ return <View style={fill ? styles.fill : styles.center}>{children}</View>;
71
+ }
72
+
73
+ // 'dark' reuses CATALOG_COLOR.text (the catalog's own darkest token); there is no dark-surface token.
74
+ const FILL = StyleSheet.create({
75
+ neutral: { backgroundColor: CATALOG_COLOR.specimenStage },
33
76
  white: { backgroundColor: CATALOG_COLOR.surface },
34
77
  dark: { backgroundColor: CATALOG_COLOR.text },
35
- transparent: { backgroundColor: 'transparent' },
36
- };
78
+ transparent: {},
79
+ });
37
80
 
38
81
  const styles = StyleSheet.create({
39
- base: {
40
- ...SPECIMEN_SURFACE_WIDTH_STYLE,
41
- alignItems: 'center',
42
- justifyContent: 'center',
43
- borderRadius: CATALOG_RADIUS.sm,
44
- padding: CATALOG_SPACE.md,
45
- },
82
+ center: { ...SPECIMEN_SURFACE_WIDTH_STYLE, alignItems: 'center' },
83
+ fill: { ...SPECIMEN_SURFACE_WIDTH_STYLE },
46
84
  });
@@ -1,9 +1,11 @@
1
1
  import React, { useCallback, useEffect, useRef, useState } from 'react';
2
- import { Platform, Pressable, ScrollView, StyleSheet, Text, View } from 'react-native';
2
+ import { ActivityIndicator, Platform, Pressable, ScrollView, StyleSheet, Switch, Text, View } from 'react-native';
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
- import { daysAgoLabel, isAlreadyUpToDateError, resolvePanelPhase, type UpdatePlanView, type UpdateStatusView } from './updatePanelState';
6
- import type { UpdateNotice } from './types';
5
+ import {
6
+ autoCheckHelp, bumpLabel, checkedAgoLabel, daysAgoLabel, isAlreadyUpToDateError, resolvePanelPhase, versionOverview, type UpdatePlanView, type UpdateStatusView,
7
+ } from './updatePanelState';
8
+ import type { UpdateNotice, VersionStatus } from './types';
7
9
 
8
10
  function isWeb(): boolean {
9
11
  return Platform.OS === 'web' && typeof window !== 'undefined';
@@ -25,7 +27,7 @@ function VersionsHeader({ current, latest, breaking, releasedAt }: { current: st
25
27
  <Text style={styles.arrow}>→</Text>
26
28
  <Text style={styles.versionsText}>{latest}</Text>
27
29
  <View style={[styles.pill, breaking && styles.pillMajor]}>
28
- <Text style={[styles.pillText, breaking && styles.pillTextMajor]}>{breaking ? 'Major' : 'Minor'}</Text>
30
+ <Text style={[styles.pillText, breaking && styles.pillTextMajor]}>{bumpLabel(current, latest, breaking)}</Text>
29
31
  </View>
30
32
  </View>
31
33
  <Text style={styles.muted}>{`You’re on ${current}.${releasedAt ? ` ${daysAgoLabel(releasedAt, Date.now())}` : ''}`}</Text>
@@ -190,6 +192,140 @@ function failureCopy(failedStep: string, current: string): { headline: string; b
190
192
  };
191
193
  }
192
194
 
195
+ /**
196
+ * The update page when no newer version is known (approved mockup
197
+ * docs/design/2026-10-10-ds-viewer-update-page-always-approved.html, states 3, 4, 6, 7 and 8): the
198
+ * installed version and which situation applies, when it last checked, the automatic-check
199
+ * switch, and **Check now**. Without an endpoint (a static build) it shows the status only.
200
+ */
201
+ function VersionDetails({
202
+ status,
203
+ endpoint,
204
+ onStatus,
205
+ }: {
206
+ status: VersionStatus;
207
+ endpoint?: Endpoint;
208
+ onStatus: (next: VersionStatus) => void;
209
+ }) {
210
+ const [checking, setChecking] = useState(false);
211
+ const [justChecked, setJustChecked] = useState(false);
212
+ const [saving, setSaving] = useState(false);
213
+ const [localError, setLocalError] = useState<string | undefined>(undefined);
214
+ const live = Boolean(endpoint) && isWeb();
215
+ const overview = versionOverview(status, justChecked, Date.now());
216
+ const help = autoCheckHelp(status.autoCheck);
217
+
218
+ const checkNow = async () => {
219
+ if (!endpoint || checking) return;
220
+ setChecking(true);
221
+ setJustChecked(false);
222
+ setLocalError(undefined);
223
+ try {
224
+ const res = await authedFetch(endpoint, '/version/check', { method: 'POST' });
225
+ if (!res.ok) throw new Error(String(res.status));
226
+ const next = (await res.json()) as VersionStatus;
227
+ // A newer version moves the whole page into the update flow (the parent re-renders with it).
228
+ setJustChecked(next.lastOutcome === 'ok' && !next.update);
229
+ onStatus(next);
230
+ } catch {
231
+ setLocalError('Couldn’t reach the viewer’s local server. If this keeps happening, restart npx ds-viewer dev.');
232
+ } finally {
233
+ setChecking(false);
234
+ }
235
+ };
236
+
237
+ const setAutoCheck = async (enabled: boolean) => {
238
+ if (!endpoint || saving) return;
239
+ setSaving(true);
240
+ setLocalError(undefined);
241
+ try {
242
+ const res = await authedFetch(endpoint, '/version/auto-check', {
243
+ method: 'POST',
244
+ headers: { 'Content-Type': 'application/json' },
245
+ body: JSON.stringify({ enabled }),
246
+ });
247
+ if (!res.ok) throw new Error(String(res.status));
248
+ onStatus((await res.json()) as VersionStatus);
249
+ } catch {
250
+ setLocalError('Couldn’t save the setting. Try again.');
251
+ } finally {
252
+ setSaving(false);
253
+ }
254
+ };
255
+
256
+ const unreachable = status.lastOutcome === 'unreachable';
257
+ return (
258
+ <>
259
+ <View style={styles.versionsRow}>
260
+ <Text style={styles.versionsText}>{status.current}</Text>
261
+ {overview.pill === 'latest' && (
262
+ <View style={[styles.pill, styles.pillSuccess]}>
263
+ <Text style={[styles.pillText, styles.pillTextSuccess]}>Latest</Text>
264
+ </View>
265
+ )}
266
+ {overview.pill === 'off' && (
267
+ <View style={[styles.pill, styles.pillOff]}>
268
+ <Text style={[styles.pillText, styles.pillTextOff]}>Checks off</Text>
269
+ </View>
270
+ )}
271
+ </View>
272
+ <Text style={styles.muted}>{overview.line}</Text>
273
+ {overview.note && (
274
+ <NoteBox variant={overview.note.variant}>
275
+ {overview.note.bold ? <Text style={styles.noteBoxBold}>{overview.note.bold}</Text> : null}
276
+ {overview.note.text}
277
+ </NoteBox>
278
+ )}
279
+ {!unreachable && (
280
+ <View style={styles.facts}>
281
+ <View style={styles.factRow}>
282
+ <Text style={styles.factLabel}>Last checked</Text>
283
+ <Text style={styles.factValue}>{checkedAgoLabel(status.lastCheckedAt, Date.now())}</Text>
284
+ </View>
285
+ <View style={styles.factRow}>
286
+ <View style={styles.switchText}>
287
+ <Text style={styles.switchLabel}>Check for updates automatically</Text>
288
+ <Text style={styles.switchHelp}>{help.text}</Text>
289
+ </View>
290
+ <Switch
291
+ value={status.autoCheck.enabled}
292
+ onValueChange={setAutoCheck}
293
+ disabled={!live || help.locked || saving || checking}
294
+ // React Native web passes only the label to the real input (no description), so the
295
+ // helper text rides in the accessible name: a screen reader hears why it's on or off.
296
+ accessibilityLabel={`Check for updates automatically. ${help.text}`}
297
+ trackColor={{ false: '#c9c9c9', true: CATALOG_COLOR.accent }}
298
+ thumbColor="#ffffff"
299
+ // The approved mockup's 44 × 26 switch; React Native web's default is 40 × 20, below
300
+ // the 24px minimum hit target.
301
+ style={styles.switch}
302
+ {...({ activeThumbColor: '#ffffff' } as Record<string, unknown>)}
303
+ />
304
+ </View>
305
+ </View>
306
+ )}
307
+ {localError && <NoteBox variant="err">{localError}</NoteBox>}
308
+ {live && (
309
+ <View style={styles.actions}>
310
+ <Pressable
311
+ onPress={checkNow}
312
+ disabled={checking}
313
+ accessibilityRole="button"
314
+ accessibilityState={{ disabled: checking, busy: checking }}
315
+ style={[styles.secondaryButton, styles.buttonRow, checking && styles.busyButton]}
316
+ >
317
+ {checking && <ActivityIndicator size="small" color={CATALOG_COLOR.textMuted} />}
318
+ <Text style={[styles.secondaryButtonText, checking && styles.busyButtonText]}>
319
+ {checking ? 'Checking…' : overview.action === 'retry' ? 'Try again' : 'Check now'}
320
+ </Text>
321
+ </Pressable>
322
+ {unreachable && !checking && <Text style={styles.muted}>or run <Code>npm view @krapwoo/ds-viewer version</Code></Text>}
323
+ </View>
324
+ )}
325
+ </>
326
+ );
327
+ }
328
+
193
329
  /**
194
330
  * Design §5's update page (`#ds-viewer-update`), approved mockup direction "C · Update page".
195
331
  * Fetches `/update/status` once on mount, then `/plan` — `resolvePanelPhase` (Task 15) picks which
@@ -203,11 +339,16 @@ function failureCopy(failedStep: string, current: string): { headline: string; b
203
339
  */
204
340
  export function UpdatePanel({
205
341
  update,
342
+ versionStatus,
343
+ onVersionStatus,
206
344
  endpoint,
207
345
  appName,
208
346
  headingRef,
209
347
  }: {
210
348
  update: UpdateNotice | null;
349
+ /** When present, the no-update view shows the version, the switch and **Check now**. */
350
+ versionStatus?: VersionStatus | null;
351
+ onVersionStatus?: (next: VersionStatus) => void;
211
352
  endpoint?: Endpoint;
212
353
  appName: string;
213
354
  headingRef?: React.Ref<View>;
@@ -325,6 +466,8 @@ export function UpdatePanel({
325
466
  content =
326
467
  status.phase === 'success' ? (
327
468
  <SuccessContent status={status} />
469
+ ) : versionStatus ? (
470
+ <VersionDetails status={versionStatus} endpoint={endpoint} onStatus={onVersionStatus ?? (() => {})} />
328
471
  ) : (
329
472
  <NoteBox variant="ok">You’re already up to date.</NoteBox>
330
473
  );
@@ -486,7 +629,7 @@ export function UpdatePanel({
486
629
  {/* react-native-web reads `aria-level`; React Native's own prop types do not declare it —
487
630
  * the same cast `SectionBlock.tsx`'s own heading target already uses. */}
488
631
  <View ref={headingRef} tabIndex={-1} role="heading" {...({ 'aria-level': 1 } as Record<string, unknown>)} style={styles.headingTarget}>
489
- <Text style={styles.title}>Update DS Viewer</Text>
632
+ <Text style={styles.title}>DS Viewer updates</Text>
490
633
  </View>
491
634
  {content}
492
635
  </View>
@@ -517,6 +660,19 @@ const styles = StyleSheet.create({
517
660
  pillText: { fontSize: CATALOG_TYPE.sm, fontWeight: '700', color: CATALOG_COLOR.accent },
518
661
  pillTextMajor: { color: CATALOG_COLOR.warning },
519
662
  pillTextSuccess: { color: CATALOG_COLOR.success },
663
+ pillOff: { backgroundColor: CATALOG_COLOR.chip },
664
+ pillTextOff: { color: CATALOG_COLOR.textMuted },
665
+ facts: { marginTop: 18, borderTopWidth: 1, borderTopColor: CATALOG_COLOR.border },
666
+ factRow: { flexDirection: 'row', alignItems: 'center', justifyContent: 'space-between', gap: CATALOG_SPACE.md, paddingVertical: 10, borderBottomWidth: 1, borderBottomColor: CATALOG_COLOR.border },
667
+ factLabel: { fontSize: CATALOG_TYPE.md, color: CATALOG_COLOR.textMuted },
668
+ factValue: { fontSize: CATALOG_TYPE.md, color: CATALOG_COLOR.text },
669
+ switchText: { flex: 1, gap: 2 },
670
+ switchLabel: { fontSize: CATALOG_TYPE.md, fontWeight: '700', color: CATALOG_COLOR.text },
671
+ switch: { width: 44, height: 26 },
672
+ switchHelp: { fontSize: CATALOG_TYPE.sm, color: CATALOG_COLOR.textMuted },
673
+ buttonRow: { flexDirection: 'row', gap: CATALOG_SPACE.sm },
674
+ busyButton: { borderColor: CATALOG_COLOR.border },
675
+ busyButtonText: { color: CATALOG_COLOR.textMuted },
520
676
  muted: { fontSize: CATALOG_TYPE.sm, color: CATALOG_COLOR.textMuted, marginTop: 4 },
521
677
  block: { marginTop: 18 },
522
678
  h3: { fontSize: CATALOG_TYPE.panelHeading, textTransform: 'uppercase', letterSpacing: 0.5, color: CATALOG_COLOR.text, marginBottom: 8 },
@@ -3,7 +3,7 @@
3
3
  * runner. CatalogShell, CatalogSidebar, and SectionBlock all derive page order from here, so the
4
4
  * sidebar order, previous/next order, and fragment fallback can never drift apart.
5
5
  */
6
- import type { NavGroup, UpdateNotice } from './types.ts';
6
+ import type { NavGroup, UpdateNotice, VersionStatus } from './types.ts';
7
7
 
8
8
  /** THE canonical within-group ordering of section ids. */
9
9
  export function sortIds<TId extends string>(ids: readonly TId[]): TId[] {
@@ -149,3 +149,16 @@ export function footerLabel(update: UpdateNotice | null | undefined): string | u
149
149
  if (!update || !isNewerVersion(update.latest, update.current)) return undefined;
150
150
  return `Update available · ${update.latest}`;
151
151
  }
152
+
153
+ /** The sidebar's footer line: the blue "Update available" notice when a newer version is known
154
+ * (from startup or **Check now**), else the installed version, quietly. `undefined` only for a
155
+ * workspace generated before version status existed, which keeps the old notice-only footer. */
156
+ export function footerLink(
157
+ update: UpdateNotice | null | undefined,
158
+ status: VersionStatus | null | undefined,
159
+ ): { label: string; kind: 'update' | 'version' } | undefined {
160
+ const notice = footerLabel(update) ?? footerLabel(status?.update);
161
+ if (notice) return { label: notice, kind: 'update' };
162
+ if (status) return { label: `DS Viewer ${status.current}`, kind: 'version' };
163
+ return undefined;
164
+ }