@krapwoo/ds-viewer 0.6.0 → 0.6.2

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.
@@ -58,6 +58,9 @@ function toSectionDef(page) {
58
58
  fullWidthLabel: page.fullWidthLabel,
59
59
  specimenSize: page.specimenSize,
60
60
  previewWidths: page.previewWidths,
61
+ // Carried because `blockMinWidth` reads it: the two preview cards have different chrome, so
62
+ // without it `explain` prices a 'table' preview as a frames card.
63
+ previewLayout: page.previewLayout,
61
64
  variants: page.variantsItems ? { itemsFill: page.variantsItemsFill, maxColumns: page.variantsMaxColumns, items: page.variantsItems.map(placeholderItem) } : undefined,
62
65
  states: page.statesItems ? { itemsFill: page.statesItemsFill, maxColumns: page.statesMaxColumns, items: page.statesItems.map(placeholderItem) } : undefined,
63
66
  comparison,
@@ -120,6 +120,19 @@ function readStaticPage(file) {
120
120
  // Anything else (a function call, a non-literal expression) is simply left unset — `previewWidths`
121
121
  // isn't in design §4's literal-checkable list, so it's never a reason to flip `checkable`.
122
122
  }
123
+ const previewLayoutProp = findProp(pageObject, 'previewLayout');
124
+ if (previewLayoutProp) {
125
+ // Unlike `previewWidths`, an unread value here is not harmless: the two card shapes have
126
+ // different chrome, so a missed 'table' would make `explain` price the preview 16px wider
127
+ // than the viewer does.
128
+ const resolved = resolve(previewLayoutProp.initializer, consts, new Set());
129
+ if (ts.isStringLiteral(resolved) && (resolved.text === 'frames' || resolved.text === 'table')) {
130
+ page.previewLayout = resolved.text;
131
+ }
132
+ else {
133
+ page.checkable = false;
134
+ }
135
+ }
123
136
  const fullWidthLabelProp = findProp(pageObject, 'fullWidthLabel');
124
137
  if (fullWidthLabelProp)
125
138
  page.fullWidthLabel = stringLiteral(resolve(fullWidthLabelProp.initializer, consts, new Set()));
@@ -113,6 +113,10 @@ export const CATALOG_COLOR = {
113
113
  surfaceMuted: '#fafafa',
114
114
  /** Hover and pressed feedback on nav rows and buttons. */
115
115
  surfacePressed: '#f1f3f8',
116
+ /** A disabled control's surface, and its label. Approved with the update page's buttons
117
+ * (docs/design/2026-10-10-ds-viewer-update-page-always-approved.html). */
118
+ controlDisabled: '#e6e6e6',
119
+ controlDisabledText: '#8a8a8a',
116
120
  /** An example's cell when the example itself is white (`specimenSurface`), darker than surfaceMuted so a white card's edges show. */
117
121
  specimenStage: '#f2f2f2',
118
122
  pageBackground: '#f6f6f4',
@@ -1,47 +1,85 @@
1
1
  import React, { forwardRef, useState } from 'react';
2
- import { Pressable, StyleSheet, Text, type View } from 'react-native';
3
- import { CATALOG_COLOR, CATALOG_LAYOUT, CATALOG_RADIUS, CATALOG_TYPE } from './tokens';
2
+ import { ActivityIndicator, Pressable, StyleSheet, Text, type View } from 'react-native';
3
+ import { CATALOG_COLOR, CATALOG_LAYOUT, CATALOG_RADIUS, CATALOG_SPACE, CATALOG_TYPE } from './tokens';
4
4
 
5
5
  /** The viewer's own button, for catalog chrome and demo triggers (not an app component): a rounded
6
- * rectangle, primary in the accent blue, secondary outlined. */
6
+ * rectangle, primary in the accent blue, secondary outlined. `disabled` greys it and stops the
7
+ * press; `busy` keeps its own colours while the action it started is still running, with a
8
+ * spinner before the label. */
7
9
  export const CatalogButton = forwardRef<View, {
8
10
  label: string;
9
- onPress: () => void;
11
+ /** Optional, because a permanently unavailable button still renders (see `disabled`). */
12
+ onPress?: () => void;
10
13
  variant?: 'primary' | 'secondary';
14
+ /** Not pressable, and visibly unavailable. */
15
+ disabled?: boolean;
16
+ /** Pressed already, still working: a spinner and a muted label. Also stops a second press. */
17
+ busy?: boolean;
11
18
  accessibilityLabel?: string;
12
- }>(function CatalogButton({ label, onPress, variant = 'primary', accessibilityLabel }, ref) {
19
+ }>(function CatalogButton({ label, onPress, variant = 'primary', disabled = false, busy = false, accessibilityLabel }, ref) {
13
20
  const [focused, setFocused] = useState(false);
14
21
  const primary = variant === 'primary';
22
+ // Busy stops the press like disabled does — the action is already running — but keeps the
23
+ // button's own colours, because it is working rather than unavailable.
24
+ const off = disabled || busy;
15
25
  return (
16
26
  <Pressable
17
27
  ref={ref}
18
- onPress={onPress}
28
+ onPress={off ? undefined : onPress}
29
+ disabled={off}
19
30
  onFocus={() => setFocused(true)}
20
31
  onBlur={() => setFocused(false)}
21
32
  accessibilityRole="button"
22
33
  accessibilityLabel={accessibilityLabel ?? label}
34
+ accessibilityState={{ disabled: off, busy }}
23
35
  style={({ pressed }) => [
24
36
  styles.base,
25
37
  primary ? styles.primary : styles.secondary,
26
- pressed && (primary ? styles.primaryPressed : styles.secondaryPressed),
38
+ pressed && !off && (primary ? styles.primaryPressed : styles.secondaryPressed),
39
+ disabled && styles.disabled,
27
40
  focused && styles.focus,
28
41
  ]}
29
42
  >
30
- <Text style={[styles.label, primary ? styles.labelPrimary : styles.labelSecondary]}>{label}</Text>
43
+ {busy ? <ActivityIndicator size="small" color={CATALOG_COLOR.textMuted} /> : null}
44
+ <Text
45
+ style={[
46
+ styles.label,
47
+ primary ? styles.labelPrimary : styles.labelSecondary,
48
+ busy && styles.labelBusy,
49
+ disabled && styles.labelDisabled,
50
+ ]}
51
+ >
52
+ {label}
53
+ </Text>
31
54
  </Pressable>
32
55
  );
33
56
  });
34
57
 
35
58
  const styles = StyleSheet.create({
36
59
  // A rounded rectangle (owner decision 2026-10-11); primary in the catalog's accent blue.
37
- base: { height: CATALOG_LAYOUT.controlSize, paddingHorizontal: 18, borderRadius: CATALOG_RADIUS.control, alignItems: 'center', justifyContent: 'center', alignSelf: 'center' },
60
+ // A row, so `busy` can put a spinner before the label without the label moving.
61
+ base: {
62
+ height: CATALOG_LAYOUT.controlSize,
63
+ paddingHorizontal: 18,
64
+ borderRadius: CATALOG_RADIUS.control,
65
+ flexDirection: 'row',
66
+ gap: CATALOG_SPACE.sm,
67
+ alignItems: 'center',
68
+ justifyContent: 'center',
69
+ alignSelf: 'center',
70
+ },
38
71
  primary: { backgroundColor: CATALOG_COLOR.accent },
39
72
  primaryPressed: { opacity: 0.85 },
40
73
  secondary: { borderWidth: 1, borderColor: CATALOG_COLOR.borderStrong, backgroundColor: CATALOG_COLOR.surface },
41
74
  secondaryPressed: { backgroundColor: CATALOG_COLOR.surfacePressed },
75
+ // One flat grey for both variants, so a disabled primary and a disabled secondary read the same:
76
+ // what stops the press is the state, not which button it is.
77
+ disabled: { backgroundColor: CATALOG_COLOR.controlDisabled, borderColor: CATALOG_COLOR.controlDisabled },
42
78
  // Same ring as the sidebar's: an outline, so focusing never shifts the layout.
43
79
  focus: { outlineWidth: CATALOG_LAYOUT.focusRingWidth, outlineStyle: 'solid', outlineColor: CATALOG_COLOR.focusRing } as object,
44
80
  label: { fontSize: CATALOG_TYPE.md, fontWeight: '700' },
45
81
  labelPrimary: { color: '#ffffff' },
46
82
  labelSecondary: { color: CATALOG_COLOR.text },
83
+ labelBusy: { color: CATALOG_COLOR.textMuted },
84
+ labelDisabled: { color: CATALOG_COLOR.controlDisabledText },
47
85
  });
@@ -130,14 +130,18 @@ export function SpecimenContent({ fill, align, capped = true, children }: { fill
130
130
  if (!browser.Element || !browser.getComputedStyle || !node?.getBoundingClientRect) return;
131
131
  const wrapperWidth = node.getBoundingClientRect().width;
132
132
  const visible = paintedExtent(visibleBoxes(readTree(node, browser)));
133
- if (fill && align === undefined && !centerNarrow && shouldCenterFilledContent(visible, wrapperWidth)) setCenterNarrow(true);
133
+ // Only for a phone-width cell. Centring exists for a small control adrift in a wide cell (a
134
+ // tooltip, a fixed-width skeleton); a 'page' cell holds a layout, whose own content is
135
+ // supposed to sit at the left of its own measure, and centring it collapses it to the width of
136
+ // whatever it happens to have painted.
137
+ if (capped && fill && align === undefined && !centerNarrow && shouldCenterFilledContent(visible, wrapperWidth)) setCenterNarrow(true);
134
138
  if ((globalThis as { __DEV__?: boolean }).__DEV__ && !warned.current && overflowsCell(visible, wrapperWidth)) {
135
139
  warned.current = true;
136
140
  console.warn(
137
141
  `[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', 'wide', or 'page' for components wider than a phone).`,
138
142
  );
139
143
  }
140
- }, [fill, align, centerNarrow, address]);
144
+ }, [capped, fill, align, centerNarrow, address]);
141
145
  if (fill) {
142
146
  const alignItems = centerNarrow ? 'center' : specimenContentAlignItems(fill, align);
143
147
  return <View ref={ref} onLayout={measure} style={[SPECIMEN_SURFACE_WIDTH_STYLE, filledContentStyle(capped), { alignItems }]}>{children}</View>;
@@ -1,9 +1,10 @@
1
1
  import React, { useCallback, useEffect, useRef, useState } from 'react';
2
- import { ActivityIndicator, Platform, Pressable, ScrollView, StyleSheet, Switch, Text, View } from 'react-native';
2
+ import { 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
+ import { CatalogButton } from './CatalogButton';
4
5
  import { DS_VIEWER_SECRET_HEADER } from './catalogNavigation';
5
6
  import {
6
- autoCheckHelp, bumpLabel, checkedAgoLabel, resultNote, daysAgoLabel, isAlreadyUpToDateError, resolvePanelPhase, versionOverview, type UpdatePlanView, type UpdateStatusView,
7
+ autoCheckHelp, bumpLabel, checkedAgoLabel, resultNote, daysAgoLabel, isAlreadyUpToDateError, isRemoteOrigin, resolvePanelPhase, versionOverview, type UpdatePlanView, type UpdateStatusView,
7
8
  } from './updatePanelState';
8
9
  import type { UpdateNotice, VersionStatus } from './types';
9
10
 
@@ -11,6 +12,13 @@ function isWeb(): boolean {
11
12
  return Platform.OS === 'web' && typeof window !== 'undefined';
12
13
  }
13
14
 
15
+ /** The page is open from somewhere other than the machine running the viewer, so every action will
16
+ * be refused however healthy the server is — see `isRemoteOrigin`. */
17
+ function remoteOrigin(endpoint: Endpoint | undefined): boolean {
18
+ if (!endpoint || !isWeb()) return false;
19
+ return isRemoteOrigin(window.location.protocol, window.location.hostname);
20
+ }
21
+
14
22
  type Endpoint = { baseUrl: string; secret: string };
15
23
 
16
24
  async function authedFetch(endpoint: Endpoint, path: string, init: RequestInit = {}): Promise<Response> {
@@ -225,7 +233,12 @@ function VersionDetails({
225
233
  setJustChecked(next.lastOutcome === 'ok' && !next.update);
226
234
  onStatus(next);
227
235
  } catch {
228
- setLocalError('Couldn’t reach the viewer’s local server. If this keeps happening, restart npx ds-viewer dev.');
236
+ setLocalError(remoteOrigin(endpoint)
237
+ // The endpoint only answers its own origin, and it can install packages and restart the
238
+ // server — so saying "restart the viewer" here would send the reader after a fault that
239
+ // isn't there. The page itself is fine over a tunnel; only its actions are local.
240
+ ? 'This page is open from another address, and the viewer only takes actions from the machine it runs on. Open the catalog on that machine, or run npx ds-viewer update there.'
241
+ : 'Couldn’t reach the viewer’s local server. If this keeps happening, restart npx ds-viewer dev.');
229
242
  } finally {
230
243
  setChecking(false);
231
244
  }
@@ -304,18 +317,12 @@ function VersionDetails({
304
317
  {localError && <NoteBox variant="err">{localError}</NoteBox>}
305
318
  {live && (
306
319
  <View style={styles.actions}>
307
- <Pressable
320
+ <CatalogButton
321
+ variant="secondary"
322
+ busy={checking}
308
323
  onPress={checkNow}
309
- disabled={checking}
310
- accessibilityRole="button"
311
- accessibilityState={{ disabled: checking, busy: checking }}
312
- style={[styles.secondaryButton, styles.buttonRow, checking && styles.busyButton]}
313
- >
314
- {checking && <ActivityIndicator size="small" color={CATALOG_COLOR.textMuted} />}
315
- <Text style={[styles.secondaryButtonText, checking && styles.busyButtonText]}>
316
- {checking ? 'Checking…' : overview.action === 'retry' ? 'Try again' : 'Check now'}
317
- </Text>
318
- </Pressable>
324
+ label={checking ? 'Checking…' : overview.action === 'retry' ? 'Try again' : 'Check now'}
325
+ />
319
326
  {unreachable && !checking && <Text style={styles.muted}>or run <Code>npm view @krapwoo/ds-viewer version</Code></Text>}
320
327
  </View>
321
328
  )}
@@ -504,15 +511,7 @@ export function UpdatePanel({
504
511
  <Text style={styles.muted}>Not a git repository, so uncommitted changes couldn’t be checked.</Text>
505
512
  )}
506
513
  <View style={styles.actions}>
507
- <Pressable
508
- onPress={startUpdate}
509
- disabled={starting}
510
- accessibilityRole="button"
511
- accessibilityState={{ disabled: starting }}
512
- style={[styles.primaryButton, starting && styles.disabledButton]}
513
- >
514
- <Text style={[styles.primaryButtonText, starting && styles.disabledButtonText]}>Update now</Text>
515
- </Pressable>
514
+ <CatalogButton label="Update now" onPress={startUpdate} disabled={starting} />
516
515
  {starting ? <Text style={styles.muted}>Starting…</Text> : <Text style={styles.muted}>or run <Code>npx ds-viewer update</Code></Text>}
517
516
  </View>
518
517
  </>
@@ -542,17 +541,8 @@ export function UpdatePanel({
542
541
  </NoteBox>
543
542
  <FilesList files={plan.files} />
544
543
  <View style={styles.actions}>
545
- <Pressable
546
- disabled
547
- accessibilityRole="button"
548
- accessibilityState={{ disabled: true }}
549
- style={[styles.primaryButton, styles.disabledButton]}
550
- >
551
- <Text style={[styles.primaryButtonText, styles.disabledButtonText]}>Update now</Text>
552
- </Pressable>
553
- <Pressable onPress={fetchPlan} accessibilityRole="button" style={styles.secondaryButton}>
554
- <Text style={styles.secondaryButtonText}>Check again</Text>
555
- </Pressable>
544
+ <CatalogButton label="Update now" disabled />
545
+ <CatalogButton label="Check again" variant="secondary" onPress={fetchPlan} />
556
546
  </View>
557
547
  </>
558
548
  );
@@ -570,9 +560,7 @@ export function UpdatePanel({
570
560
  {' You may be offline, or npm didn’t respond. Nothing was changed.'}
571
561
  </NoteBox>
572
562
  <View style={styles.actions}>
573
- <Pressable onPress={fetchPlan} accessibilityRole="button" style={styles.secondaryButton}>
574
- <Text style={styles.secondaryButtonText}>Try again</Text>
575
- </Pressable>
563
+ <CatalogButton label="Try again" variant="secondary" onPress={fetchPlan} />
576
564
  <Text style={styles.muted}>or run <Code>npx ds-viewer update</Code></Text>
577
565
  </View>
578
566
  </>
@@ -681,9 +669,6 @@ const styles = StyleSheet.create({
681
669
  switchLabel: { fontSize: CATALOG_TYPE.md, fontWeight: '700', color: CATALOG_COLOR.text },
682
670
  switch: { width: 44, height: 26 },
683
671
  switchHelp: { fontSize: CATALOG_TYPE.sm, color: CATALOG_COLOR.textMuted },
684
- buttonRow: { flexDirection: 'row', gap: CATALOG_SPACE.sm },
685
- busyButton: { borderColor: CATALOG_COLOR.border },
686
- busyButtonText: { color: CATALOG_COLOR.textMuted },
687
672
  muted: { fontSize: CATALOG_TYPE.sm, color: CATALOG_COLOR.textMuted, marginTop: 4 },
688
673
  block: { marginTop: 18 },
689
674
  h3: { fontSize: CATALOG_TYPE.panelHeading, textTransform: 'uppercase', letterSpacing: 0.5, color: CATALOG_COLOR.text, marginBottom: 8 },
@@ -695,12 +680,6 @@ const styles = StyleSheet.create({
695
680
  fileReason: { fontSize: CATALOG_TYPE.sm, color: CATALOG_COLOR.textMuted },
696
681
  fileReasonDirty: { color: CATALOG_COLOR.warning, fontWeight: '700' },
697
682
  actions: { flexDirection: 'row', alignItems: 'center', gap: CATALOG_SPACE.md, marginTop: 20 },
698
- primaryButton: { height: CATALOG_LAYOUT.controlSize, paddingHorizontal: 18, borderRadius: 22, backgroundColor: CATALOG_COLOR.text, alignItems: 'center', justifyContent: 'center' },
699
- primaryButtonText: { color: '#fff', fontWeight: '700', fontSize: CATALOG_TYPE.md },
700
- secondaryButton: { height: CATALOG_LAYOUT.controlSize, paddingHorizontal: 18, borderRadius: 22, borderWidth: 1, borderColor: CATALOG_COLOR.borderStrong, alignItems: 'center', justifyContent: 'center' },
701
- secondaryButtonText: { fontWeight: '700', fontSize: CATALOG_TYPE.md, color: CATALOG_COLOR.text },
702
- disabledButton: { backgroundColor: '#e6e6e6', borderColor: '#e6e6e6' },
703
- disabledButtonText: { color: '#8a8a8a' },
704
683
  noteBox: { borderRadius: CATALOG_RADIUS.md, padding: 12, marginTop: 18 },
705
684
  noteBoxText: { fontSize: CATALOG_TYPE.md },
706
685
  noteBoxBold: { fontWeight: '700' },
@@ -121,6 +121,10 @@ export const CATALOG_COLOR = {
121
121
  surfaceMuted: '#fafafa',
122
122
  /** Hover and pressed feedback on nav rows and buttons. */
123
123
  surfacePressed: '#f1f3f8',
124
+ /** A disabled control's surface, and its label. Approved with the update page's buttons
125
+ * (docs/design/2026-10-10-ds-viewer-update-page-always-approved.html). */
126
+ controlDisabled: '#e6e6e6',
127
+ controlDisabledText: '#8a8a8a',
124
128
  /** An example's cell when the example itself is white (`specimenSurface`), darker than surfaceMuted so a white card's edges show. */
125
129
  specimenStage: '#f2f2f2',
126
130
  pageBackground: '#f6f6f4',
@@ -211,3 +211,11 @@ export function resultNote(
211
211
  );
212
212
  }
213
213
  }
214
+
215
+ /** Whether a page at this protocol and hostname can use the viewer's local endpoint. The endpoint
216
+ * allows exactly `http://localhost:<dev port>` — it installs packages and restarts the server —
217
+ * so a tunnel, a LAN address or a reverse proxy is refused before the secret is read. Comparing
218
+ * the page's origin with the endpoint's would be wrong: the endpoint is always on its own port. */
219
+ export function isRemoteOrigin(protocol: string, hostname: string): boolean {
220
+ return !(protocol === 'http:' && hostname === 'localhost');
221
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@krapwoo/ds-viewer",
3
- "version": "0.6.0",
3
+ "version": "0.6.2",
4
4
  "license": "MIT",
5
5
  "type": "module",
6
6
  "description": "A browsable, always-current catalog for an Expo / React Native design system.",