@krapwoo/ds-viewer 0.5.0 → 0.6.1

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.
@@ -52,7 +52,7 @@ function resetMainScroll(scrollView: ScrollView | null): void {
52
52
  * with only `onPress` (as `CatalogSidebar.tsx`'s own finding already documents) never activates on
53
53
  * Enter under react-native-web unless the element is a genuine anchor. `role="status"` (not
54
54
  * `"alert"`, the mockup's own choice) — a non-urgent notice should not interrupt assistive tech. */
55
- function MajorBanner({ update, onReviewUpdate, onDismiss }: { update: UpdateNotice; onReviewUpdate: () => void; onDismiss: () => void }) {
55
+ function MajorBanner({ update, onReviewUpdate, onDismiss, keepDocumentUrl = false }: { update: UpdateNotice; onReviewUpdate: () => void; onDismiss: () => void; keepDocumentUrl?: boolean }) {
56
56
  const releaseUrl = `https://github.com/krapwoo/design-system-viewer/releases/tag/v${update.latest}`;
57
57
  return (
58
58
  <View style={bannerStyles.banner} role="status">
@@ -68,7 +68,16 @@ function MajorBanner({ update, onReviewUpdate, onDismiss }: { update: UpdateNoti
68
68
  Migration guide
69
69
  </Text>
70
70
  {' · '}
71
- <Text role="link" style={bannerStyles.link} onPress={onReviewUpdate} {...({ href: hashForId(UPDATE_PAGE_ID) } as Record<string, unknown>)}>
71
+ <Text
72
+ role="link"
73
+ style={bannerStyles.link}
74
+ onPress={(event?: { preventDefault?: () => void }) => {
75
+ // An embedded shell opens its own update page; the host page's address stays put.
76
+ if (keepDocumentUrl) event?.preventDefault?.();
77
+ onReviewUpdate();
78
+ }}
79
+ {...({ href: hashForId(UPDATE_PAGE_ID) } as Record<string, unknown>)}
80
+ >
72
81
  Review update
73
82
  </Text>
74
83
  </Text>
@@ -96,7 +105,7 @@ const bannerStyles = StyleSheet.create({
96
105
  bold: { fontWeight: '700' },
97
106
  link: { color: CATALOG_COLOR.warning, fontWeight: '700', textDecorationLine: 'underline' },
98
107
  close: { width: 32, height: 32, borderRadius: CATALOG_RADIUS.sm, alignItems: 'center', justifyContent: 'center' },
99
- closeGlyph: { fontSize: 18, color: CATALOG_COLOR.warning },
108
+ closeGlyph: { fontSize: CATALOG_TYPE.xl, color: CATALOG_COLOR.warning },
100
109
  });
101
110
 
102
111
  /**
@@ -115,9 +124,16 @@ export function CatalogShell<TId extends string>({
115
124
  update,
116
125
  versionStatus,
117
126
  updateEndpoint,
127
+ embedded = false,
128
+ initialPage,
118
129
  }: {
119
130
  /** Short product/app name — the sidebar logo and the breadcrumb root. */
120
131
  appName: string;
132
+ /** A shell shown inside another page (a catalog demo): it keeps its selected page in its own
133
+ * state, never reads or writes the URL, and doesn't treat the page's address as its own. */
134
+ embedded?: boolean;
135
+ /** The page an embedded shell opens on (defaults to the first page). */
136
+ initialPage?: TId;
121
137
  /** Optional logo image, validated and bundled by `cli/workspace.ts` from the project's `logo`
122
138
  * config field. See `CatalogSidebar`'s own doc comment for the approved layout rule. */
123
139
  logoImageSource?: ImageSourcePropType;
@@ -150,14 +166,16 @@ export function CatalogShell<TId extends string>({
150
166
  // recognizing itself as the selected address) would otherwise render alongside it for nothing.
151
167
  // Read once per mount, like `active`'s own initial read below: this mode is only ever entered by
152
168
  // a fresh document load, never a same-document transition.
153
- const overlayAddress = useMemo(() => (isWeb() ? overlayAddressFromSearch(window.location.search) : undefined), []);
169
+ // An embedded shell never owns the URL (hash routing, the overlay child-document address).
170
+ const routesByUrl = isWeb() && !embedded;
171
+ const overlayAddress = useMemo(() => (routesByUrl ? overlayAddressFromSearch(window.location.search) : undefined), [routesByUrl]);
154
172
  const overlayNode = useMemo(() => (overlayAddress ? findOverlayNode(sections, overlayAddress) : undefined), [overlayAddress, sections]);
155
173
 
156
174
  const sectionsById = useMemo(() => new Map(sections.map((def) => [def.id, def])), [sections]);
157
175
  const labels = useMemo(() => new Map(sections.filter((def) => def.title).map((def) => [def.id as string, def.title as string])), [sections]);
158
176
  const order = useMemo(() => orderedIds(groups, new Set(sectionsById.keys())), [groups, sectionsById]);
159
177
  const [active, setActive] = useState<ShellPageId<TId> | undefined>(() =>
160
- isWeb() ? resolveActiveFromHash(window.location.hash, order) : order[0],
178
+ embedded ? initialPage ?? order[0] : isWeb() ? resolveActiveFromHash(window.location.hash, order) : order[0],
161
179
  );
162
180
 
163
181
  const scrollRef = useRef<ScrollView>(null);
@@ -179,7 +197,7 @@ export function CatalogShell<TId extends string>({
179
197
 
180
198
  // Back/forward and manual fragment edits.
181
199
  useEffect(() => {
182
- if (!isWeb()) return;
200
+ if (!routesByUrl) return;
183
201
  const onHashChange = () => {
184
202
  const id = resolveActiveFromHash(window.location.hash, order);
185
203
  if (id === undefined) return;
@@ -193,7 +211,7 @@ export function CatalogShell<TId extends string>({
193
211
  };
194
212
  window.addEventListener('hashchange', onHashChange);
195
213
  return () => window.removeEventListener('hashchange', onHashChange);
196
- }, [order]);
214
+ }, [order, routesByUrl]);
197
215
 
198
216
  // After every page change: sync the fragment, return to the top, and move focus when the user
199
217
  // navigated (never on first load).
@@ -203,7 +221,7 @@ export function CatalogShell<TId extends string>({
203
221
  const focus = focusHeadingNext.current;
204
222
  pushHistoryNext.current = false;
205
223
  focusHeadingNext.current = false;
206
- if (isWeb()) {
224
+ if (routesByUrl) {
207
225
  const expected = hashForId(active);
208
226
  if (window.location.hash !== expected) {
209
227
  if (push) window.location.hash = expected;
@@ -212,7 +230,7 @@ export function CatalogShell<TId extends string>({
212
230
  }
213
231
  resetMainScroll(scrollRef.current);
214
232
  if (focus) focusElement(headingRef.current);
215
- }, [active]);
233
+ }, [active, routesByUrl]);
216
234
 
217
235
  const activeDef = active !== undefined && active !== UPDATE_PAGE_ID ? sectionsById.get(active) : undefined;
218
236
 
@@ -266,7 +284,7 @@ export function CatalogShell<TId extends string>({
266
284
 
267
285
  return (
268
286
  <View style={styles.root}>
269
- <CatalogSidebar logo={appName} logoImageSource={logoImageSource} caption={title} groups={groups} labels={labels} active={active} onPress={select} footer={footer} />
287
+ <CatalogSidebar logo={appName} logoImageSource={logoImageSource} caption={title} groups={groups} labels={labels} active={active} onPress={select} footer={footer} keepDocumentUrl={embedded} />
270
288
  <ScrollView ref={scrollRef} style={styles.main} contentContainerStyle={styles.mainContent}>
271
289
  {active === UPDATE_PAGE_ID ? (
272
290
  <UpdatePanel
@@ -286,7 +304,7 @@ export function CatalogShell<TId extends string>({
286
304
  // The approved mockup's own `PAGE_BG` order: crumb, then banner, then title — the
287
305
  // banner used to render above the breadcrumb instead (Important finding, Fable
288
306
  // correction pass).
289
- banner={showBanner && effectiveUpdate ? <MajorBanner update={effectiveUpdate} onReviewUpdate={() => select(UPDATE_PAGE_ID)} onDismiss={dismissMajorBanner} /> : undefined}
307
+ banner={showBanner && effectiveUpdate ? <MajorBanner update={effectiveUpdate} onReviewUpdate={() => select(UPDATE_PAGE_ID)} onDismiss={dismissMajorBanner} keepDocumentUrl={embedded} /> : undefined}
290
308
  pager={{
291
309
  previousId: neighbors(order, activeDef.id).previous,
292
310
  nextId: neighbors(order, activeDef.id).next,
@@ -13,12 +13,21 @@ function webLinkProps(id: string, active: boolean) {
13
13
 
14
14
  /** One nav link. Hover via onHoverIn/onHoverOut (react-native-web fires them; native never does);
15
15
  * keyboard focus draws the catalog focus ring because react-native-web removes the browser's. */
16
- function NavItem<TId extends string>({ id, label, active, onPress }: { id: TId; label: string; active: boolean; onPress: () => void }) {
16
+ /** In an embedded shell, a link's own navigation would change the host page's address; the shell
17
+ * handles the press itself, so the browser's default is cancelled. */
18
+ function pressHandler(onPress: () => void, keepDocumentUrl: boolean) {
19
+ return (event?: { preventDefault?: () => void }) => {
20
+ if (keepDocumentUrl) event?.preventDefault?.();
21
+ onPress();
22
+ };
23
+ }
24
+
25
+ function NavItem<TId extends string>({ id, label, active, onPress, keepDocumentUrl = false }: { id: TId; label: string; active: boolean; onPress: () => void; keepDocumentUrl?: boolean }) {
17
26
  const [hovered, setHovered] = useState(false);
18
27
  const [focused, setFocused] = useState(false);
19
28
  return (
20
29
  <Pressable
21
- onPress={onPress}
30
+ onPress={pressHandler(onPress, keepDocumentUrl)}
22
31
  onHoverIn={() => setHovered(true)}
23
32
  onHoverOut={() => setHovered(false)}
24
33
  onFocus={() => setFocused(true)}
@@ -43,12 +52,12 @@ function NavItem<TId extends string>({ id, label, active, onPress }: { id: TId;
43
52
  );
44
53
  }
45
54
 
46
- function FooterLink({ label, kind, active, onPress }: { label: string; kind: 'update' | 'version'; active: boolean; onPress: () => void }) {
55
+ function FooterLink({ label, kind, active, onPress, keepDocumentUrl = false }: { label: string; kind: 'update' | 'version'; active: boolean; onPress: () => void; keepDocumentUrl?: boolean }) {
47
56
  const [hovered, setHovered] = useState(false);
48
57
  const [focused, setFocused] = useState(false);
49
58
  return (
50
59
  <Pressable
51
- onPress={onPress}
60
+ onPress={pressHandler(onPress, keepDocumentUrl)}
52
61
  onHoverIn={() => setHovered(true)}
53
62
  onHoverOut={() => setHovered(false)}
54
63
  onFocus={() => setFocused(true)}
@@ -84,8 +93,11 @@ export function CatalogSidebar<TId extends string>({
84
93
  active,
85
94
  onPress,
86
95
  footer,
96
+ keepDocumentUrl = false,
87
97
  }: {
88
98
  logo: string;
99
+ /** For a shell embedded in another page: links never change the document's address. */
100
+ keepDocumentUrl?: boolean;
89
101
  /** Optional logo image, validated and bundled by `cli/workspace.ts` from the project's `logo`
90
102
  * config field. Visual spec: docs/design/2026-10-08-ds-viewer-sidebar-logo-approved.html
91
103
  * ("C · Adaptive", approved 2026-10-08) — a mark (aspect ratio ≤ 2) sits beside the name; a
@@ -195,14 +207,14 @@ export function CatalogSidebar<TId extends string>({
195
207
  <View key={group.label}>
196
208
  <Text style={styles.groupLabel}>{group.label}</Text>
197
209
  {group.ids.map((id) => (
198
- <NavItem key={id} id={id} label={labels?.get(id) ?? id} active={active === id} onPress={() => onPress(id)} />
210
+ <NavItem key={id} id={id} label={labels?.get(id) ?? id} active={active === id} onPress={() => onPress(id)} keepDocumentUrl={keepDocumentUrl} />
199
211
  ))}
200
212
  </View>
201
213
  ))}
202
214
  </ScrollView>
203
215
  {footer && (
204
216
  <View style={styles.footerContainer}>
205
- <FooterLink label={footer.label} kind={footer.kind ?? 'update'} active={footer.active} onPress={footer.onPress} />
217
+ <FooterLink label={footer.label} kind={footer.kind ?? 'update'} active={footer.active} onPress={footer.onPress} keepDocumentUrl={keepDocumentUrl} />
206
218
  </View>
207
219
  )}
208
220
  </View>
@@ -1,7 +1,7 @@
1
1
  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
- import { COLUMN_MIN_WIDTH, MATRIX_LAYOUT, axisItems, cellKey, gridWidthBounds, indexCells, validateComparison } from './comparison';
4
+ import { COLUMN_MIN_WIDTH, MATRIX_LAYOUT, axisItems, cellKey, gridMinWidth, indexCells, validateComparison } from './comparison';
5
5
  import { SpecimenContent, SpecimenSurface } from './SpecimenSurface';
6
6
  import { SpecimenAddressProvider } from './SpecimenAddress';
7
7
  import { comparisonCellItemKey } from './overlayViewport';
@@ -10,9 +10,11 @@ import type { ComparisonDef, SpecimenSize, SpecimenSurfaceKind } from './types';
10
10
 
11
11
  /**
12
12
  * A grid of two props that combine freely, rendered as an accessible table inside one card.
13
- * Columns grow with the window up to 402px and never shrink below the specimen size's minimum;
14
- * a grid wider than its container scrolls horizontally inside the card, never the page. `surface`
15
- * is the page's `specimenSurface` behind each cell's example.
13
+ * The card fills its container and its columns share that width equally, never shrinking below the
14
+ * specimen size's minimum; a grid narrower than that scrolls horizontally inside the card, never
15
+ * the page. Phone width (402px) caps the specimen inside a cell, not the column, so a two-column
16
+ * grid is as wide as a five-column one. `surface` is the page's `specimenSurface` behind each
17
+ * cell's example.
16
18
  */
17
19
  export function ComparisonGrid({
18
20
  def,
@@ -31,13 +33,12 @@ export function ComparisonGrid({
31
33
  const cells = indexCells(def);
32
34
  const rows = axisItems(def.rows);
33
35
  const columns = axisItems(def.columns);
34
- const bounds = gridWidthBounds(columns.length, size);
36
+ const minWidth = gridMinWidth(columns.length, size);
35
37
  const column = {
36
38
  flexGrow: 1,
37
39
  flexShrink: 1,
38
40
  flexBasis: COLUMN_MIN_WIDTH[size],
39
41
  minWidth: COLUMN_MIN_WIDTH[size],
40
- maxWidth: MATRIX_LAYOUT.columnMaxWidth,
41
42
  };
42
43
  const lastColumn = columns.length - 1;
43
44
 
@@ -46,7 +47,7 @@ export function ComparisonGrid({
46
47
  <View
47
48
  role="table"
48
49
  aria-label={`${sectionId}: ${def.rowLabel} by ${def.columnLabel}`}
49
- style={[styles.card, { minWidth: bounds.minWidth, maxWidth: bounds.maxWidth }]}
50
+ style={[styles.card, { minWidth }]}
50
51
  >
51
52
  <View role="row" style={styles.row}>
52
53
  <View role="columnheader" style={[styles.cell, styles.rowHeader, styles.headerCell]}>
@@ -69,7 +70,7 @@ export function ComparisonGrid({
69
70
  cell?.node !== undefined ? (
70
71
  <SpecimenSurface key={c.key} role="cell" surface={cell.surface ?? surface} style={[styles.cell, column, styles.bodyRow, styles.specimenCell, ci === lastColumn && styles.lastColumn]}>
71
72
  <SpecimenAddressProvider address={{ pageId: sectionId, slot: 'comparison', itemKey: comparisonCellItemKey(row.key, c.key) }}>
72
- <SpecimenContent fill={cell.fill} align={cell.align}>{cell.node}</SpecimenContent>
73
+ <SpecimenContent fill={cell.fill} align={cell.align} capped={size !== 'page'}>{cell.node}</SpecimenContent>
73
74
  </SpecimenAddressProvider>
74
75
  </SpecimenSurface>
75
76
  ) : (
@@ -10,9 +10,10 @@ import type { SpecimenSize, SpecimenSurfaceKind } from './types';
10
10
  /**
11
11
  * Grouped rows: one row per variant, headed by the variant's name, holding that variant's own
12
12
  * configurations. Each cell keeps its own caption because the rows do not share column meanings
13
- * (a circle's size is not a bar's thickness). Each row is its own labeled list. Shorter rows end
14
- * in blank cells; a card wider than its container scrolls horizontally inside itself. `surface` is
15
- * the page's `specimenSurface` behind each item.
13
+ * (a circle's size is not a bar's thickness). Each row is its own labeled list. Cells share the
14
+ * card's width equally, the way a grid's columns do, and shorter rows end in blank cells; a card
15
+ * narrower than its minimum scrolls horizontally inside itself. `surface` is the page's
16
+ * `specimenSurface` behind each item.
16
17
  */
17
18
  export function ComparisonGroups({
18
19
  groups,
@@ -53,7 +54,7 @@ export function ComparisonGroups({
53
54
  </View>
54
55
  <SpecimenSurface surface={item.surface ?? surface} style={styles.specimen}>
55
56
  <SpecimenAddressProvider address={pageId && item.slot ? { pageId, slot: item.slot, itemKey: item.key } : undefined}>
56
- <SpecimenContent fill={item.fill} align={item.align}>{item.node as React.ReactNode}</SpecimenContent>
57
+ <SpecimenContent fill={item.fill} align={item.align} capped={size !== 'page'}>{item.node as React.ReactNode}</SpecimenContent>
57
58
  </SpecimenAddressProvider>
58
59
  </SpecimenSurface>
59
60
  </View>
@@ -91,7 +92,7 @@ const styles = StyleSheet.create({
91
92
  },
92
93
  rowHeaderText: { fontSize: CATALOG_TYPE.md, fontWeight: '700', color: CATALOG_COLOR.text },
93
94
  cells: { flex: 1, flexDirection: 'row' },
94
- cell: { flex: 1, maxWidth: MATRIX_LAYOUT.columnMaxWidth, borderRightWidth: 1, borderRightColor: CATALOG_COLOR.border },
95
+ cell: { flex: 1, borderRightWidth: 1, borderRightColor: CATALOG_COLOR.border },
95
96
  lastColumn: { borderRightWidth: 0 },
96
97
  caption: {
97
98
  padding: MATRIX_LAYOUT.cellPadding,
@@ -10,7 +10,7 @@ import type { SpecimenSize, SpecimenSurfaceKind } from './types';
10
10
  /**
11
11
  * One-axis examples inside ONE shared card. Cells wrap into balanced rows (listGeometry); each cell
12
12
  * has its own caption strip so labels stay attached when rows wrap. Blank cells complete an uneven
13
- * last row. Wide specimens use fixed 402px cells and the card hugs its columns. `maxColumns` caps
13
+ * last row. Wide specimens keep 402px as a minimum and share the container above it. `maxColumns` caps
14
14
  * how many columns a row ever wraps into (from `VariantSlot.maxColumns`); `surface` is the page's
15
15
  * `specimenSurface` behind each item.
16
16
  */
@@ -42,7 +42,7 @@ export function ComparisonList({
42
42
  for (let r = 0; r < geometry.rows; r += 1) rows.push(slots.slice(r * geometry.columns, (r + 1) * geometry.columns));
43
43
 
44
44
  return (
45
- <View onLayout={onLayout}>
45
+ <View onLayout={onLayout} style={styles.measure}>
46
46
  <View role="list" aria-label={label} style={[styles.card, { width: geometry.containerWidth }]}>
47
47
  {rows.map((row, ri) => (
48
48
  <View key={ri} role="none" style={[styles.row, ri === rows.length - 1 && styles.lastRow]}>
@@ -67,7 +67,7 @@ export function ComparisonList({
67
67
  </View>
68
68
  <SpecimenSurface surface={item.surface ?? surface} style={styles.specimen}>
69
69
  <SpecimenAddressProvider address={pageId && item.slot ? { pageId, slot: item.slot, itemKey: item.key } : undefined}>
70
- <SpecimenContent fill={item.fill} align={item.align}>{item.node as React.ReactNode}</SpecimenContent>
70
+ <SpecimenContent fill={item.fill} align={item.align} capped={size !== 'page'}>{item.node as React.ReactNode}</SpecimenContent>
71
71
  </SpecimenAddressProvider>
72
72
  </SpecimenSurface>
73
73
  </View>
@@ -81,7 +81,16 @@ export function ComparisonList({
81
81
  }
82
82
 
83
83
  const styles = StyleSheet.create({
84
+ // The wrapper that measures the space the list has. It must stretch: left to hug its own card it
85
+ // would keep reporting the card's width, so a list inside a narrower container (a specimen cell)
86
+ // would never learn to shrink and would overflow. `alignItems` keeps the card centred, the
87
+ // position it had when the wrapper hugged it.
88
+ measure: { alignSelf: 'stretch', alignItems: 'center' },
84
89
  card: {
90
+ // The width below is computed from the last measured space; until the first measurement lands
91
+ // it is a laptop-sized guess, so the card is also clamped to whatever space it really has —
92
+ // a list has no scroller of its own, and a too-wide guess would be cut off for a frame.
93
+ maxWidth: '100%',
85
94
  backgroundColor: CATALOG_COLOR.surface,
86
95
  borderWidth: 1,
87
96
  borderColor: CATALOG_COLOR.borderStrong,
@@ -9,7 +9,7 @@ import { SpecimenSurface } from './SpecimenSurface';
9
9
  import { SpecimenAddressProvider } from './SpecimenAddress';
10
10
  import { DEFAULT_SPECIMEN_SURFACE } from './specimenSurfaceStyle';
11
11
  import { TokenSections } from './TokenLayouts';
12
- import { MATRIX_LAYOUT, choosePlacement, listGeometry, presentationBlocks, type PresentationBlock } from './comparison';
12
+ import { MATRIX_LAYOUT, PREVIEW_CARD_CHROME, blockMinWidth, choosePlacement, presentationBlocks, type PlacementBlock, type PresentationBlock } from './comparison';
13
13
  import type { PreviewWidths, SectionDef, SpecimenSurfaceKind } from './types';
14
14
 
15
15
  // Matches a quoted-string-literal union type, e.g. "'primary' | 'secondary' | 'tertiary'" — anything
@@ -184,7 +184,7 @@ function BlockContent<TId extends string>({ block, def, surface }: { block: Pres
184
184
  </SpecimenAddressProvider>
185
185
  ) : null;
186
186
  case 'tokenSections':
187
- return <TokenSections sections={block.sections} columns={block.columns} />;
187
+ return <TokenSections sections={block.sections} columns={block.columns} label={def.id} />;
188
188
  default:
189
189
  return (
190
190
  <View style={styles.previewCard}>
@@ -195,8 +195,13 @@ function BlockContent<TId extends string>({ block, def, surface }: { block: Pres
195
195
  }
196
196
 
197
197
  /** Width a block's card occupies: lists hug their columns; everything else fills the row. */
198
- function blockWidth(block: PresentationBlock, available: number): number {
199
- return block.kind === 'list' ? listGeometry(block.items.length, available, block.size, block.maxColumns).containerWidth : available;
198
+
199
+
200
+ /** What `choosePlacement` needs about a block: how narrow it can get, and (for a list) the geometry
201
+ * that predicts its height in half a row. */
202
+ function placementBlock(block: PresentationBlock, height: number): PlacementBlock {
203
+ const base = { kind: block.kind, height, minWidth: blockMinWidth(block) };
204
+ return block.kind === 'list' ? { ...base, itemCount: block.items.length, size: block.size, maxColumns: block.maxColumns } : base;
200
205
  }
201
206
 
202
207
  /**
@@ -236,10 +241,8 @@ function Blocks<TId extends string>({ blocks, def, surface }: { blocks: Presenta
236
241
  const decision = choosePlacement({
237
242
  available,
238
243
  gap: CATALOG_LAYOUT.blockGap,
239
- first: { kind: first.kind, width: blockWidth(first, available), height: heights[0] },
240
- second: second.kind === 'list'
241
- ? { kind: 'list', height: heights[1], itemCount: second.items.length, size: second.size, maxColumns: second.maxColumns }
242
- : { kind: second.kind, height: heights[1] },
244
+ first: placementBlock(first, heights[0]),
245
+ second: placementBlock(second, heights[1]),
243
246
  });
244
247
  if (decision === 'side') setPlacement('side');
245
248
  }, [available, blocks, heights, placement]);
@@ -251,7 +254,8 @@ function Blocks<TId extends string>({ blocks, def, surface }: { blocks: Presenta
251
254
  <View
252
255
  key={block.title || block.kind}
253
256
  onLayout={onBlockLayout(i)}
254
- style={side ? (i === 0 ? { width: blockWidth(block, available), flexShrink: 0 } : styles.blockFill) : undefined}
257
+ // Side by side, each block fills its own half: a card always fills the column it is given.
258
+ style={side ? styles.blockFill : undefined}
255
259
  >
256
260
  {block.title ? <Text style={styles.blockLabel}>{block.title}</Text> : null}
257
261
  <BlockContent block={block} def={def} surface={surface} />
@@ -394,10 +398,11 @@ const styles = StyleSheet.create({
394
398
  },
395
399
  previewCard: {
396
400
  backgroundColor: CATALOG_COLOR.surface,
397
- borderWidth: 1,
401
+ // From comparison.ts, which adds the same numbers to a preview's minimum width.
402
+ borderWidth: PREVIEW_CARD_CHROME.border,
398
403
  borderColor: CATALOG_COLOR.borderStrong,
399
404
  borderRadius: CATALOG_RADIUS.card,
400
- padding: CATALOG_SPACE.xl,
405
+ padding: PREVIEW_CARD_CHROME.padding,
401
406
  gap: CATALOG_SPACE.md,
402
407
  },
403
408
  // `ComparisonList`'s own card/caption tokens, reused so a 'table' preview reads as the same shared
@@ -3,7 +3,7 @@ import { View, StyleSheet, type StyleProp, type ViewStyle } from 'react-native';
3
3
  import { CATALOG_COLOR } from './tokens';
4
4
  import {
5
5
  DEFAULT_SPECIMEN_SURFACE, SPECIMEN_SURFACE_WIDTH_STYLE, overflowsCell, paintedExtent, shouldCenterFilledContent, specimenContentAlignItems,
6
- specimenContentBoundedStyle, visibleBoxes, type LayoutBox,
6
+ specimenContentBoundedStyle, visibleBoxes, filledContentStyle, type LayoutBox,
7
7
  } from './specimenSurfaceStyle';
8
8
  import { useSpecimenAddress } from './SpecimenAddress';
9
9
  import { blendsIntoCell, measurePaintedBoxes } from './specimenFill';
@@ -115,7 +115,7 @@ function readTree(root: MeasurableNode, browser: Browser): LayoutBox {
115
115
  return read(root);
116
116
  }
117
117
 
118
- export function SpecimenContent({ fill, align, children }: { fill?: boolean; align?: SpecimenAlign; children: React.ReactNode }) {
118
+ export function SpecimenContent({ fill, align, capped = true, children }: { fill?: boolean; align?: SpecimenAlign; /** Phone-width cap for a filled example; off for page-width cells. */ capped?: boolean; children: React.ReactNode }) {
119
119
  // After layout (browser only): a `fill` example whose visible content is narrower than the cell
120
120
  // (a fixed-width skeleton, a tooltip around a small trigger) is centred instead of sitting
121
121
  // against the leading edge; an explicit `align` always wins. And in development, an example
@@ -130,17 +130,21 @@ export function SpecimenContent({ fill, align, children }: { fill?: boolean; ali
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
- `[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').`,
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
- return <View ref={ref} onLayout={measure} style={[SPECIMEN_SURFACE_WIDTH_STYLE, { alignItems }]}>{children}</View>;
147
+ return <View ref={ref} onLayout={measure} style={[SPECIMEN_SURFACE_WIDTH_STYLE, filledContentStyle(capped), { alignItems }]}>{children}</View>;
144
148
  }
145
149
  return <View ref={ref} onLayout={measure} style={specimenContentBoundedStyle(align)}>{children}</View>;
146
150
  }
@@ -1,7 +1,7 @@
1
- import React, { useState } from 'react';
1
+ import React, { useRef, useState } from 'react';
2
2
  import { View, Text, StyleSheet, type LayoutChangeEvent } from 'react-native';
3
3
  import { CATALOG_COLOR, CATALOG_LAYOUT, CATALOG_RADIUS, CATALOG_SPACE, CATALOG_TYPE } from './tokens';
4
- import { sectionColumns, tileGeometry, TOKEN_LAYOUT_GAP } from './tokenLayout';
4
+ import { sectionColumns, tileGeometry, TOKEN_LAYOUT_GAP, TOKEN_SECTION_MIN_WIDTH } from './tokenLayout';
5
5
  import type { TokenSection } from './types';
6
6
 
7
7
  /** Measures its own width; renders nothing inside until the width is known. */
@@ -18,9 +18,20 @@ function useWidth(): [number, (event: LayoutChangeEvent) => void] {
18
18
  * in up to `columns` columns. A `wide` section takes a full row; the page drops to fewer columns
19
19
  * rather than squeeze a section under its minimum width.
20
20
  */
21
- export function TokenSections({ sections, columns }: { sections: TokenSection[]; columns: 1 | 2 | 3 }) {
21
+ export function TokenSections({ sections, columns, label }: { sections: TokenSection[]; columns: 1 | 2 | 3; label?: string }) {
22
22
  const [width, onLayout] = useWidth();
23
23
  const perRow = sectionColumns(width, columns);
24
+ // Degrading quietly is how a page ends up claiming a column count it never renders: the author
25
+ // reads `columns={3}`, the reader sees one column, and nothing says the space was the reason.
26
+ // Once per transition, not once per render: a resize re-renders continuously.
27
+ const warned = useRef('');
28
+ if (width > 0 && perRow < columns && (globalThis as { __DEV__?: boolean }).__DEV__) {
29
+ const key = `${columns}:${perRow}`;
30
+ if (warned.current !== key) {
31
+ warned.current = key;
32
+ console.warn(`[Catalog] ${label ?? 'TokenSections'}: asked for ${columns} columns but only ${perRow} fit in ${width}px; sections never go below ${TOKEN_SECTION_MIN_WIDTH}px.`);
33
+ }
34
+ }
24
35
  const columnWidth = perRow === 1 ? width : (width - TOKEN_LAYOUT_GAP * (perRow - 1)) / perRow;
25
36
  return (
26
37
  <View onLayout={onLayout} style={styles.sections}>
@@ -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
  </>
@@ -663,8 +651,8 @@ const styles = StyleSheet.create({
663
651
  paddingVertical: 2,
664
652
  },
665
653
  versionsRow: { flexDirection: 'row', alignItems: 'center', gap: 10 },
666
- versionsText: { fontSize: 22, fontWeight: '800', color: CATALOG_COLOR.text },
667
- arrow: { fontSize: 22, color: CATALOG_COLOR.textMuted },
654
+ versionsText: { fontSize: CATALOG_TYPE['2xl'], fontWeight: '800', color: CATALOG_COLOR.text },
655
+ arrow: { fontSize: CATALOG_TYPE['2xl'], color: CATALOG_COLOR.textMuted },
668
656
  pill: { paddingHorizontal: 8, paddingVertical: 2, borderRadius: 10, backgroundColor: CATALOG_COLOR.accentSubtle },
669
657
  pillMajor: { backgroundColor: CATALOG_COLOR.warningSubtle },
670
658
  pillSuccess: { backgroundColor: CATALOG_COLOR.successSubtle },
@@ -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' },
@@ -714,10 +693,10 @@ const styles = StyleSheet.create({
714
693
  stepIconNow: { borderWidth: 2, borderColor: CATALOG_COLOR.accent },
715
694
  stepIconTodo: { borderWidth: 2, borderColor: CATALOG_COLOR.borderStrong },
716
695
  stepIconFail: { backgroundColor: CATALOG_COLOR.dangerSubtle },
717
- stepIconGlyph: { fontSize: 12, fontWeight: '800', color: CATALOG_COLOR.text },
696
+ stepIconGlyph: { fontSize: CATALOG_TYPE.sm, fontWeight: '800', color: CATALOG_COLOR.text },
718
697
  stepLabel: { fontSize: CATALOG_TYPE.md, color: CATALOG_COLOR.textMuted },
719
698
  stepLabelNow: { fontSize: CATALOG_TYPE.md, color: CATALOG_COLOR.text, fontWeight: '700' },
720
699
  stepLabelFail: { fontSize: CATALOG_TYPE.md, color: CATALOG_COLOR.text, fontWeight: '700' },
721
700
  logBox: { backgroundColor: '#1d1d1f', borderRadius: CATALOG_RADIUS.md, padding: 12, marginTop: 8, maxHeight: 240 },
722
- logText: { color: '#e8e8e8', fontSize: 12, fontFamily: 'Menlo, monospace' },
701
+ logText: { color: '#e8e8e8', fontSize: CATALOG_TYPE.sm, fontFamily: 'Menlo, monospace' },
723
702
  });