@krapwoo/ds-viewer 0.4.5 → 0.4.7

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 (44) hide show
  1. package/README.md +111 -18
  2. package/dist/cli/dev.js +31 -6
  3. package/dist/cli/doctor.js +137 -0
  4. package/dist/cli/endpoint.js +76 -1
  5. package/dist/cli/explain.js +44 -6
  6. package/dist/cli/main.js +4 -2
  7. package/dist/cli/props.js +80 -1
  8. package/dist/cli/staticPage.js +228 -6
  9. package/dist/cli/updateCheck.js +174 -43
  10. package/dist/cli/workspace.js +2 -1
  11. package/dist/native/catalog/catalogNavigation.js +11 -0
  12. package/dist/native/catalog/comparison.js +29 -4
  13. package/native/catalog/BoundedOverlayViewport.tsx +112 -0
  14. package/native/catalog/CatalogShell.tsx +69 -12
  15. package/native/catalog/CatalogSidebar.tsx +8 -7
  16. package/native/catalog/ComparisonGrid.tsx +2 -2
  17. package/native/catalog/ComparisonGroups.tsx +2 -2
  18. package/native/catalog/ComparisonList.tsx +2 -2
  19. package/native/catalog/PhoneFrame.tsx +6 -0
  20. package/native/catalog/ReferenceDetails.tsx +75 -20
  21. package/native/catalog/SectionBlock.tsx +66 -4
  22. package/native/catalog/SpecimenSurface.tsx +18 -10
  23. package/native/catalog/UpdatePanel.tsx +161 -5
  24. package/native/catalog/catalogNavigation.ts +14 -1
  25. package/native/catalog/comparison.ts +39 -5
  26. package/native/catalog/index.ts +4 -0
  27. package/native/catalog/overlayViewport.ts +156 -0
  28. package/native/catalog/pageApi.ts +6 -0
  29. package/native/catalog/specimenSurfaceStyle.ts +70 -1
  30. package/native/catalog/types.ts +62 -12
  31. package/native/catalog/updatePanelState.ts +72 -0
  32. package/package.json +1 -1
  33. package/starter-kit/components/Banner/Banner.catalog.tsx +3 -0
  34. package/starter-kit/components/Button/Button.catalog.tsx +3 -0
  35. package/starter-kit/components/Dropdown/Dropdown.catalog.tsx +6 -0
  36. package/starter-kit/components/EmptyState/EmptyState.catalog.tsx +5 -0
  37. package/starter-kit/components/InputField/InputField.catalog.tsx +3 -0
  38. package/starter-kit/components/List/List.catalog.tsx +3 -0
  39. package/starter-kit/components/Pill/Pill.catalog.tsx +3 -0
  40. package/starter-kit/components/PillRow/PillRow.catalog.tsx +3 -0
  41. package/starter-kit/components/SearchField/SearchField.catalog.tsx +4 -0
  42. package/starter-kit/components/SectionHeader/SectionHeader.catalog.tsx +3 -0
  43. package/starter-kit/components/TextArea/TextArea.catalog.tsx +3 -0
  44. package/starter-kit/components/Toast/Toast.catalog.tsx +7 -0
@@ -129,3 +129,14 @@ export function footerLabel(update) {
129
129
  return undefined;
130
130
  return `Update available · ${update.latest}`;
131
131
  }
132
+ /** The sidebar's footer line: the blue "Update available" notice when a newer version is known
133
+ * (from startup or **Check now**), else the installed version, quietly. `undefined` only for a
134
+ * workspace generated before version status existed, which keeps the old notice-only footer. */
135
+ export function footerLink(update, status) {
136
+ const notice = footerLabel(update) ?? footerLabel(status?.update);
137
+ if (notice)
138
+ return { label: notice, kind: 'update' };
139
+ if (status)
140
+ return { label: `DS Viewer ${status.current}`, kind: 'version' };
141
+ return undefined;
142
+ }
@@ -15,6 +15,12 @@ export const PLACEMENT_MIN_SAVING = 120;
15
15
  export const PROPS_MIN_COLUMN_WIDTH = 360;
16
16
  /** Gap between the two Props columns (CATALOG_SPACE['2xl']). */
17
17
  export const PROPS_COLUMN_GAP = 32;
18
+ /** A reference column (Guidance, Quick reference, or Composition) is never narrower than this;
19
+ * below it, `referenceColumns` drops to fewer columns. */
20
+ export const REFERENCE_MIN_COLUMN_WIDTH = 280;
21
+ /** Gap between reference columns (CATALOG_SPACE['2xl'], the same token the card's columns already
22
+ * use). */
23
+ export const REFERENCE_COLUMN_GAP = 32;
18
24
  /** Smallest column or cell width per specimen size. Wide specimens are fixed at phone width. */
19
25
  export const COLUMN_MIN_WIDTH = { compact: 160, regular: 240, wide: 402 };
20
26
  /** `Array.isArray`'s built-in type guard narrows to a mutable `any[]`, which a `readonly T[]`
@@ -184,7 +190,9 @@ export function slotSize(def, slot) {
184
190
  return def.specimenSize ?? (slot.itemsFill ? 'wide' : 'regular');
185
191
  }
186
192
  function slotItems(slot) {
187
- return slot.items.map((item) => ({ key: item.key, label: item.name, node: item.node, fill: item.fill || slot.itemsFill }));
193
+ // Nullish, not `||`: an item's own explicit `fill: false` must override an inherited
194
+ // `itemsFill: true`, which `false || slot.itemsFill` would silently discard.
195
+ 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 }));
188
196
  }
189
197
  /** Preview widths, each capped at phone width. 'full' is kept for catalog chrome and token pages. */
190
198
  function previewWidths(requested, fallback) {
@@ -198,12 +206,14 @@ function groupStates(variants, states) {
198
206
  const variantKeys = new Set(variants.items.map((v) => v.key));
199
207
  if (!states.items.some((s) => s.group !== undefined && variantKeys.has(s.group)))
200
208
  return undefined;
201
- const fill = (item, slot) => item.fill || slot.itemsFill;
209
+ // Nullish, not `||` — same reasoning as `slotItems`: an explicit `fill: false` must win over an
210
+ // inherited `itemsFill: true`.
211
+ const fill = (item, slot) => item.fill ?? slot.itemsFill;
202
212
  const groups = variants.items.map((variant) => {
203
213
  const own = states.items.filter((s) => s.group === variant.key);
204
214
  const items = own.length > 0
205
- ? own.map((s) => ({ key: s.key, label: s.name, node: s.node, fill: fill(s, states) }))
206
- : [{ key: variant.key, label: variant.name, node: variant.node, fill: fill(variant, variants) }];
215
+ ? own.map((s) => ({ key: s.key, label: s.name, node: s.node, fill: fill(s, states), surface: s.surface, align: s.align }))
216
+ : [{ key: variant.key, label: variant.name, node: variant.node, fill: fill(variant, variants), surface: variant.surface, align: variant.align }];
207
217
  return { key: variant.key, label: variant.name, items };
208
218
  });
209
219
  const rest = states.items.filter((s) => s.group === undefined || !variantKeys.has(s.group));
@@ -297,3 +307,18 @@ export function choosePlacement({ available, gap, first, second }) {
297
307
  export function propsColumns(count, width) {
298
308
  return count >= 4 && width >= 2 * PROPS_MIN_COLUMN_WIDTH + PROPS_COLUMN_GAP ? 2 : 1;
299
309
  }
310
+ /** How many columns the reference card's Guidance / Quick reference / Composition panel uses at a
311
+ * given measured inner card width: 3 only when there is confirmed composition to show as its own
312
+ * column AND the card is wide enough for three REFERENCE_MIN_COLUMN_WIDTH columns; 2 when wide
313
+ * enough for two (any composition then stays nested under Quick reference, as it always has been);
314
+ * 1 (stacked, full content in document order — nothing dropped) when even two columns wouldn't
315
+ * each keep their minimum. An unmeasured width (`<= 0`) stacks, so the first frame before layout
316
+ * settles never overflows. */
317
+ export function referenceColumns(width, hasComposition) {
318
+ const fits = (n) => width >= n * REFERENCE_MIN_COLUMN_WIDTH + (n - 1) * REFERENCE_COLUMN_GAP;
319
+ if (hasComposition && fits(3))
320
+ return 3;
321
+ if (fits(2))
322
+ return 2;
323
+ return 1;
324
+ }
@@ -0,0 +1,112 @@
1
+ import React, { useState } from 'react';
2
+ import { Platform, StyleSheet, Text, View } from 'react-native';
3
+ import { CATALOG_COLOR, CATALOG_TYPE } from './tokens';
4
+ import { isFiniteOverlaySize, overlayStageDimensions, resolveOverlayViewport, type OverlaySlot } from './overlayViewport';
5
+
6
+ function isWeb(): boolean {
7
+ return Platform.OS === 'web' && typeof window !== 'undefined';
8
+ }
9
+
10
+ export interface BoundedOverlayViewportProps {
11
+ /** This specimen's own page id — `SectionDef.id`. */
12
+ pageId: string;
13
+ /** Which block the demo wrapped here is authored in. */
14
+ slot: OverlaySlot;
15
+ /** That item's own key: `VariantExample.key` for `'variants'`/`'states'`, or
16
+ * `overlayViewport.ts`'s own `comparisonCellItemKey(rowKey, columnKey)` for a `'comparison'`
17
+ * cell — never inferred. */
18
+ itemKey: string;
19
+ /** `height` is the child document's exact usable viewport height, in CSS pixels — never
20
+ * renegotiated. `width` is only a MAXIMUM: this component adds no border or padding of its own
21
+ * (unlike `PhoneFrame`'s cosmetic hairline border), so this is the outer stage's widest possible
22
+ * size, but the stage still fills and shrinks to whatever its real owning specimen cell actually
23
+ * gives it at narrower widths — a fixed pixel width previously let the stage overflow a
24
+ * narrower owner and get silently clipped by it (see `overlayStageDimensions`). Both must be
25
+ * finite and strictly positive (`isFiniteOverlaySize`); an invalid value renders an honest
26
+ * unavailable state instead of an iframe sized to a meaningless bound. */
27
+ width: number;
28
+ height: number;
29
+ /** The real, unmodified demo — the same node that would otherwise render directly on the page. */
30
+ children: React.ReactNode;
31
+ }
32
+
33
+ /**
34
+ * An opt-in, bounded browsing context for a specimen whose demo opens a modal/sheet/portal that
35
+ * would otherwise escape its own card and cover the whole catalog page (RNW's Modal portals into
36
+ * `document.body`, so no styled wrapper or `overflow: hidden` can give it a different boundary —
37
+ * see the design's §5 "Bounded overlay viewport"). Opens a same-origin child document that boots
38
+ * the SAME app entry/module/provider tree this catalog page itself runs under, sized to
39
+ * `width`×`height`, so the demo's own overlay opens inside that rectangle instead of the outer
40
+ * document's body — the real production component, unmodified: no renderer is copied, no prop is
41
+ * mutated, no callback/source string is serialized or evaluated.
42
+ *
43
+ * Opt-in and additive: a page that never imports this renders exactly as it did before. Purely a
44
+ * documentation device, like `PhoneFrame` — a real app screen never frames its own overlays this
45
+ * way, since the actual device viewport already is the boundary.
46
+ *
47
+ * Browser-only containment: there is no native equivalent (React Native has no child-document
48
+ * boundary to open), so on a native runtime this renders `children` directly, with no boundary.
49
+ * Call sites must not read that fallback as native parity for the contained behavior.
50
+ */
51
+ export function BoundedOverlayViewport({ pageId, slot, itemKey, width, height, children }: BoundedOverlayViewportProps) {
52
+ const [status, setStatus] = useState<'loading' | 'ready' | 'unavailable'>('loading');
53
+ const web = isWeb();
54
+ const decision = resolveOverlayViewport({
55
+ address: { pageId, slot, itemKey },
56
+ isWeb: web,
57
+ currentSearch: web ? window.location.search : '',
58
+ pathname: web ? window.location.pathname : '',
59
+ });
60
+
61
+ // Not web, or this document IS the selected child for this exact address: render the real demo
62
+ // directly — on native there is no containment to offer, and in the selected child document
63
+ // framing again would recurse into another, identical child document forever.
64
+ if (decision.mode === 'inline') return <>{children}</>;
65
+
66
+ // An invalid bound (not finite, zero, negative — a measurement that hasn't settled yet, a
67
+ // miscomputed layout value, an author typo) must never reach the iframe below: an honest
68
+ // unavailable state, not a frame opened at some browser-decided fallback size.
69
+ if (!isFiniteOverlaySize(width, height)) {
70
+ return (
71
+ <View style={styles.stage}>
72
+ <Text style={styles.status}>This example is unavailable right now.</Text>
73
+ </View>
74
+ );
75
+ }
76
+
77
+ return (
78
+ <View style={[styles.stage, overlayStageDimensions(width, height)]}>
79
+ {status !== 'ready' && (
80
+ <Text style={styles.status}>{status === 'unavailable' ? 'This example is unavailable right now.' : 'Loading…'}</Text>
81
+ )}
82
+ {
83
+ // react-native-web has no `<Iframe>` primitive; this is the one place this component
84
+ // reaches past it for a genuine same-origin child document. `as unknown as string` only
85
+ // bypasses React Native's own JSX.IntrinsicElements typing (which never declares host-web
86
+ // tags) — the element itself, and every prop it's given, is still a plain, real iframe.
87
+ React.createElement('iframe' as unknown as string, {
88
+ src: decision.href,
89
+ title: `${pageId}: ${itemKey}`,
90
+ style: { ...StyleSheet.flatten(styles.frame), display: status === 'unavailable' ? 'none' : undefined },
91
+ onLoad: () => setStatus('ready'),
92
+ onError: () => setStatus('unavailable'),
93
+ })
94
+ }
95
+ </View>
96
+ );
97
+ }
98
+
99
+ const styles = StyleSheet.create({
100
+ stage: {
101
+ backgroundColor: CATALOG_COLOR.surfacePressed,
102
+ alignSelf: 'center',
103
+ alignItems: 'center',
104
+ justifyContent: 'center',
105
+ },
106
+ frame: {
107
+ width: '100%',
108
+ height: '100%',
109
+ borderWidth: 0,
110
+ },
111
+ status: { position: 'absolute', fontSize: CATALOG_TYPE.sm, color: CATALOG_COLOR.textMuted },
112
+ });
@@ -5,8 +5,9 @@ import { CATALOG_COLOR, CATALOG_LAYOUT, CATALOG_MAX_CONTENT_WIDTH, CATALOG_RADIU
5
5
  import { CatalogSidebar } from './CatalogSidebar';
6
6
  import { SectionBlock } from './SectionBlock';
7
7
  import { UpdatePanel } from './UpdatePanel';
8
- import { groupLabelFor, hashForId, neighbors, orderedIds, resolveActiveFromHash, shouldShowMajorBanner, footerLabel, UPDATE_PAGE_ID } from './catalogNavigation';
9
- import type { NavGroup, PreviewWidths, SectionDef, UpdateNotice } from './types';
8
+ import { DS_VIEWER_SECRET_HEADER, footerLink, groupLabelFor, hashForId, neighbors, orderedIds, resolveActiveFromHash, shouldShowMajorBanner, UPDATE_PAGE_ID } from './catalogNavigation';
9
+ import { findOverlayNode, overlayAddressFromSearch } from './overlayViewport';
10
+ import type { NavGroup, PreviewWidths, SectionDef, UpdateNotice, VersionStatus } from './types';
10
11
 
11
12
  /** `active` can be a real page id, or the reserved update-page id — never both a generic `TId`
12
13
  * constraint and a hardcoded string literal type at once, which is why this is its own alias. */
@@ -111,6 +112,7 @@ export function CatalogShell<TId extends string>({
111
112
  sections,
112
113
  defaultPreviewWidths,
113
114
  update,
115
+ versionStatus,
114
116
  updateEndpoint,
115
117
  }: {
116
118
  /** Short product/app name — the sidebar logo and the breadcrumb root. */
@@ -129,12 +131,27 @@ export function CatalogShell<TId extends string>({
129
131
  * or already up to date; the viewer treats all three identically: no footer, no banner, no
130
132
  * update page content). */
131
133
  update?: UpdateNotice | null;
134
+ /** The installed version, whether automatic checks are on (and why), and the last check —
135
+ * `dev` writes it at startup (`.ds-viewer/version.json`), and the shell refreshes it from the
136
+ * local endpoint. With it, the sidebar always shows a version line and the update page offers
137
+ * **Check now** and the automatic-check switch. Without it (an older generated workspace), the
138
+ * footer appears only when an update is known, as before. */
139
+ versionStatus?: VersionStatus | null;
132
140
  /** The local endpoint's base URL and per-run secret — `dev` generates both (Task 18) and
133
141
  * `writeWorkspace`'s generated entry file bakes them in (Task 17). Absent in a build that
134
142
  * disables the endpoint entirely — `UpdatePanel` degrades to showing the plan/notice with no
135
143
  * **Update now** button when this is missing. */
136
144
  updateEndpoint?: { baseUrl: string; secret: string };
137
145
  }) {
146
+ // A `BoundedOverlayViewport`'s own child document (design §5): render ONLY the one authored
147
+ // node this document's URL addresses, with no sidebar/breadcrumb/title/pager — never the whole
148
+ // page, since that page's other examples (and this same example's own viewport wrapper, now
149
+ // recognizing itself as the selected address) would otherwise render alongside it for nothing.
150
+ // Read once per mount, like `active`'s own initial read below: this mode is only ever entered by
151
+ // a fresh document load, never a same-document transition.
152
+ const overlayAddress = useMemo(() => (isWeb() ? overlayAddressFromSearch(window.location.search) : undefined), []);
153
+ const overlayNode = useMemo(() => (overlayAddress ? findOverlayNode(sections, overlayAddress) : undefined), [overlayAddress, sections]);
154
+
138
155
  const sectionsById = useMemo(() => new Map(sections.map((def) => [def.id, def])), [sections]);
139
156
  const labels = useMemo(() => new Map(sections.filter((def) => def.title).map((def) => [def.id as string, def.title as string])), [sections]);
140
157
  const order = useMemo(() => orderedIds(groups, new Set(sectionsById.keys())), [groups, sectionsById]);
@@ -198,28 +215,64 @@ export function CatalogShell<TId extends string>({
198
215
 
199
216
  const activeDef = active !== undefined && active !== UPDATE_PAGE_ID ? sectionsById.get(active) : undefined;
200
217
 
218
+ // The live version status: starts from what `dev` wrote at startup, then follows the local
219
+ // endpoint (a reload after **Check now** must not fall back to the startup snapshot).
220
+ const [liveStatus, setLiveStatus] = useState<VersionStatus | null>(versionStatus ?? null);
221
+ useEffect(() => {
222
+ if (!updateEndpoint || !isWeb()) return;
223
+ let cancelled = false;
224
+ fetch(`${updateEndpoint.baseUrl}/version`, { headers: { [DS_VIEWER_SECRET_HEADER]: updateEndpoint.secret } })
225
+ .then((res) => (res.ok ? (res.json() as Promise<VersionStatus>) : undefined))
226
+ .then((next) => {
227
+ if (next && !cancelled) setLiveStatus(next);
228
+ })
229
+ .catch(() => {
230
+ // The startup snapshot stays; the endpoint may be restarting.
231
+ });
232
+ return () => {
233
+ cancelled = true;
234
+ };
235
+ }, [updateEndpoint]);
236
+ // With a live status, it is the truth (it includes a **Check now** result); otherwise the
237
+ // startup notice, as before.
238
+ const effectiveUpdate: UpdateNotice | null = liveStatus ? liveStatus.update : update ?? null;
239
+
201
240
  const [dismissedMajor, setDismissedMajor] = useState<string | null>(() =>
202
241
  isWeb() ? window.localStorage.getItem('ds-viewer-dismissed-major') : null,
203
242
  );
204
243
  const dismissMajorBanner = useCallback(() => {
205
- if (!update) return;
206
- if (isWeb()) window.localStorage.setItem('ds-viewer-dismissed-major', update.latest);
207
- setDismissedMajor(update.latest);
208
- }, [update]);
209
- const footer = footerLabel(update)
210
- ? { label: footerLabel(update)!, active: active === UPDATE_PAGE_ID, onPress: () => select(UPDATE_PAGE_ID) }
211
- : undefined;
244
+ if (!effectiveUpdate) return;
245
+ if (isWeb()) window.localStorage.setItem('ds-viewer-dismissed-major', effectiveUpdate.latest);
246
+ setDismissedMajor(effectiveUpdate.latest);
247
+ }, [effectiveUpdate]);
248
+ const link = footerLink(effectiveUpdate, liveStatus);
249
+ const footer = link ? { ...link, active: active === UPDATE_PAGE_ID, onPress: () => select(UPDATE_PAGE_ID) } : undefined;
212
250
  // Design: the banner shows on every *normal* page; once you're already looking at the update
213
251
  // page there is nothing left for "Review update" to do, so it's hidden there (confirmed against
214
252
  // the approved mockup's own `frame()`: the banner only ever renders when `!isPanel`).
215
- const showBanner = active !== UPDATE_PAGE_ID && shouldShowMajorBanner(update, dismissedMajor);
253
+ const showBanner = active !== UPDATE_PAGE_ID && shouldShowMajorBanner(effectiveUpdate, dismissedMajor);
254
+
255
+ if (overlayAddress) {
256
+ return (
257
+ <View style={styles.overlayDocumentRoot}>
258
+ {overlayNode ?? <Text style={styles.overlayUnavailable}>This example is no longer available.</Text>}
259
+ </View>
260
+ );
261
+ }
216
262
 
217
263
  return (
218
264
  <View style={styles.root}>
219
265
  <CatalogSidebar logo={appName} logoImageSource={logoImageSource} caption={title} groups={groups} labels={labels} active={active} onPress={select} footer={footer} />
220
266
  <ScrollView ref={scrollRef} style={styles.main} contentContainerStyle={styles.mainContent}>
221
267
  {active === UPDATE_PAGE_ID ? (
222
- <UpdatePanel update={update ?? null} endpoint={updateEndpoint} appName={appName} headingRef={headingRef} />
268
+ <UpdatePanel
269
+ update={effectiveUpdate}
270
+ versionStatus={liveStatus}
271
+ onVersionStatus={setLiveStatus}
272
+ endpoint={updateEndpoint}
273
+ appName={appName}
274
+ headingRef={headingRef}
275
+ />
223
276
  ) : activeDef ? (
224
277
  <SectionBlock
225
278
  key={activeDef.id}
@@ -229,7 +282,7 @@ export function CatalogShell<TId extends string>({
229
282
  // The approved mockup's own `PAGE_BG` order: crumb, then banner, then title — the
230
283
  // banner used to render above the breadcrumb instead (Important finding, Fable
231
284
  // correction pass).
232
- banner={showBanner && update ? <MajorBanner update={update} onReviewUpdate={() => select(UPDATE_PAGE_ID)} onDismiss={dismissMajorBanner} /> : undefined}
285
+ banner={showBanner && effectiveUpdate ? <MajorBanner update={effectiveUpdate} onReviewUpdate={() => select(UPDATE_PAGE_ID)} onDismiss={dismissMajorBanner} /> : undefined}
233
286
  pager={{
234
287
  previousId: neighbors(order, activeDef.id).previous,
235
288
  nextId: neighbors(order, activeDef.id).next,
@@ -249,6 +302,10 @@ export function CatalogShell<TId extends string>({
249
302
 
250
303
  const styles = StyleSheet.create({
251
304
  root: { flex: 1, flexDirection: 'row', backgroundColor: CATALOG_COLOR.pageBackground },
305
+ // A `BoundedOverlayViewport` child document's own root: no sidebar/chrome, just the one
306
+ // addressed node — sized by whatever that node itself renders, not the catalog's own layout.
307
+ overlayDocumentRoot: { flex: 1 },
308
+ overlayUnavailable: { fontSize: CATALOG_TYPE.md, color: CATALOG_COLOR.textMuted, padding: CATALOG_SPACE.lg },
252
309
  main: { flex: 1 },
253
310
  mainContent: {
254
311
  width: '100%',
@@ -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>
@@ -65,8 +65,8 @@ export function ComparisonGrid({
65
65
  const cell = cells.get(cellKey(row.key, c.key));
66
66
  return (
67
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>
68
+ <SpecimenSurface key={c.key} role="cell" surface={cell.surface ?? surface} style={[styles.cell, column, styles.bodyRow, styles.specimenCell, ci === lastColumn && styles.lastColumn]}>
69
+ <SpecimenContent fill={cell.fill} align={cell.align}>{cell.node}</SpecimenContent>
70
70
  </SpecimenSurface>
71
71
  ) : (
72
72
  <View key={c.key} role="cell" style={[styles.cell, column, styles.bodyRow, styles.specimenCell, ci === lastColumn && styles.lastColumn]}>
@@ -47,8 +47,8 @@ export function ComparisonGroups({
47
47
  <View style={styles.caption}>
48
48
  <Text style={styles.captionText}>{item.label}</Text>
49
49
  </View>
50
- <SpecimenSurface surface={surface} style={styles.specimen}>
51
- <SpecimenContent fill={item.fill}>{item.node as React.ReactNode}</SpecimenContent>
50
+ <SpecimenSurface surface={item.surface ?? surface} style={styles.specimen}>
51
+ <SpecimenContent fill={item.fill} align={item.align}>{item.node as React.ReactNode}</SpecimenContent>
52
52
  </SpecimenSurface>
53
53
  </View>
54
54
  );
@@ -61,8 +61,8 @@ export function ComparisonList({
61
61
  <View style={styles.caption}>
62
62
  <Text style={styles.captionText}>{item.label}</Text>
63
63
  </View>
64
- <SpecimenSurface surface={surface} style={styles.specimen}>
65
- <SpecimenContent fill={item.fill}>{item.node as React.ReactNode}</SpecimenContent>
64
+ <SpecimenSurface surface={item.surface ?? surface} style={styles.specimen}>
65
+ <SpecimenContent fill={item.fill} align={item.align}>{item.node as React.ReactNode}</SpecimenContent>
66
66
  </SpecimenSurface>
67
67
  </View>
68
68
  );
@@ -16,6 +16,11 @@ const HEIGHT = 480;
16
16
  * `width: '100%'` capped at 280 (not a fixed width) — a fixed px width could exceed the card's real
17
17
  * content width once the surrounding column shrinks below its own max-width cap (it does at common
18
18
  * viewport sizes), overflowing past the card's edge since the card itself doesn't clip.
19
+ *
20
+ * `alignSelf: 'center'` is this frame's own responsibility, not its `SpecimenContent` wrapper's:
21
+ * a `fill` specimen's wrapper now stretches by default (preserving an unrelated full-width
22
+ * auto-sized specimen elsewhere), which leaves a capped-width child like this one flush to one
23
+ * side instead of centered — see `specimenContentAlignItems` in `specimenSurfaceStyle.ts`.
19
24
  */
20
25
  export function PhoneFrame({ children }: { children: React.ReactNode }) {
21
26
  return <View style={styles.frame}>{children}</View>;
@@ -33,5 +38,6 @@ const styles = StyleSheet.create({
33
38
  overflow: 'hidden',
34
39
  alignItems: 'center',
35
40
  justifyContent: 'center',
41
+ alignSelf: 'center',
36
42
  },
37
43
  });
@@ -1,9 +1,9 @@
1
1
  import React, { useState } from 'react';
2
- import { View, Text, StyleSheet, type LayoutChangeEvent } from 'react-native';
2
+ import { View, Text, StyleSheet, type LayoutChangeEvent, type TextStyle } from 'react-native';
3
3
  import { CATALOG_COLOR, CATALOG_LAYOUT, CATALOG_RADIUS, CATALOG_SPACE, CATALOG_TYPE } from './tokens';
4
4
  import { PropsTable } from './PropsTable';
5
- import { propsColumns } from './comparison';
6
- import type { SectionDef } from './types';
5
+ import { propsColumns, referenceColumns } from './comparison';
6
+ import type { ComposedOfEntry, SectionDef } from './types';
7
7
 
8
8
  // react-native-web reads `aria-level`; React Native's prop types do not declare it.
9
9
  const HEADING_LEVEL_2 = { 'aria-level': 2 } as Record<string, unknown>;
@@ -40,13 +40,51 @@ function PropsBox<TId extends string>({ def }: { def: SectionDef<TId> }) {
40
40
  );
41
41
  }
42
42
 
43
+ // `built-in` is composition's assumed default relationship, so spelling it out reads redundantly
44
+ // (e.g. "TrainArrivalRemainingTime · built-in"); `slot`/`related` still need their own suffix to
45
+ // stay distinguishable from a plain built-in entry.
46
+ function compositionLabel(entry: ComposedOfEntry): string {
47
+ return entry.relationship === 'built-in' ? entry.component : `${entry.component} · ${entry.relationship}`;
48
+ }
49
+
50
+ function CompositionFacts({ composedOf }: { composedOf: ComposedOfEntry[] }) {
51
+ return (
52
+ <>
53
+ {composedOf.map((entry, i) => (
54
+ <Fact key={`${entry.component}-${entry.relationship}-${i}`} label={compositionLabel(entry)} value={entry.role} />
55
+ ))}
56
+ </>
57
+ );
58
+ }
59
+
43
60
  /**
44
- * Always-visible reference below a page's specimens: a card with Guidance and Quick reference
45
- * (source path, accessibility), then Props in their own box. Token galleries show only Quick
46
- * reference with the source path. No collapse control by design.
61
+ * Always-visible reference below a page's specimens: a card with Guidance, Quick reference
62
+ * (source path, accessibility), and — only when the page has confirmed composition to disclose —
63
+ * Composition, then Props in their own box. Token galleries show only Quick reference with the
64
+ * source path. No collapse control by design.
65
+ *
66
+ * Guidance/Quick reference/Composition lay out from the card's own measured INNER width
67
+ * (`referenceColumns`), not viewport width alone: three columns only when there is confirmed
68
+ * composition and the card is wide enough for all three (Composition gets its own column only
69
+ * then — otherwise it stays nested under Quick reference, as it always has); two when there's
70
+ * room for Guidance and Quick reference side by side; one (stacked, full document order, nothing
71
+ * dropped) when the card is too narrow even for two. `minWidth: 0` on every column lets its facts
72
+ * and source path actually shrink and wrap at that width instead of pushing the card wider than
73
+ * its own container.
74
+ *
75
+ * `onLayout` sits on the `columns` row itself, not the padded/bordered `card` around it:
76
+ * `referenceColumns` fits each column against `REFERENCE_MIN_COLUMN_WIDTH`, an INNER measurement —
77
+ * the card's own padding (`panelPadding`) and 1px border never belong in that budget. Measuring the
78
+ * outer card previously fed `referenceColumns` an outer width up to ~42px wider than what the three
79
+ * columns' content actually has, which can report a column count the inner content doesn't fit.
47
80
  */
48
81
  export function ReferenceDetails<TId extends string>({ def }: { def: SectionDef<TId> }) {
49
82
  const hide = def.hide ?? {};
83
+ const [width, setWidth] = useState(0);
84
+ const onLayout = (event: LayoutChangeEvent) => {
85
+ const next = Math.round(event.nativeEvent.layout.width);
86
+ if (next > 0 && next !== width) setWidth(next);
87
+ };
50
88
  if (def.tokenGallery) {
51
89
  return (
52
90
  <View style={styles.card}>
@@ -55,27 +93,37 @@ export function ReferenceDetails<TId extends string>({ def }: { def: SectionDef<
55
93
  </View>
56
94
  );
57
95
  }
96
+ const hasComposition = Boolean(def.composedOf && def.composedOf.length > 0);
97
+ const columns = referenceColumns(width, hasComposition);
98
+ const stacked = columns === 1;
99
+ const compositionIsOwnColumn = columns === 3 && hasComposition;
100
+ const columnStyle = stacked ? styles.columnStacked : styles.column;
101
+
58
102
  return (
59
103
  <View style={styles.stack}>
60
104
  <View style={styles.card}>
61
- <View style={styles.columns}>
62
- <View style={styles.column}>
105
+ <View style={[styles.columns, stacked && styles.columnsStacked]} onLayout={onLayout}>
106
+ <View style={columnStyle}>
63
107
  <Text role="heading" {...HEADING_LEVEL_2} style={styles.heading}>Guidance</Text>
64
108
  <Text style={styles.body}>{def.whenToUse ?? 'No usage guidance documented.'}</Text>
65
109
  </View>
66
- <View style={styles.column}>
110
+ <View style={columnStyle}>
67
111
  <Text role="heading" {...HEADING_LEVEL_2} style={styles.heading}>Quick reference</Text>
68
112
  {def.path && <Fact label="Source" value={def.path} mono />}
69
113
  {!hide.accessibility && <Fact label="Accessibility" value={def.a11y ?? 'No accessibility notes documented.'} />}
70
- {def.composedOf && def.composedOf.length > 0 && (
114
+ {!compositionIsOwnColumn && hasComposition && def.composedOf && (
71
115
  <>
72
116
  <Text style={styles.subheading}>Composition</Text>
73
- {def.composedOf.map((entry, i) => (
74
- <Fact key={`${entry.component}-${entry.relationship}-${i}`} label={`${entry.component} · ${entry.relationship}`} value={entry.role} />
75
- ))}
117
+ <CompositionFacts composedOf={def.composedOf} />
76
118
  </>
77
119
  )}
78
120
  </View>
121
+ {compositionIsOwnColumn && def.composedOf && (
122
+ <View style={columnStyle}>
123
+ <Text role="heading" {...HEADING_LEVEL_2} style={styles.heading}>Composition</Text>
124
+ <CompositionFacts composedOf={def.composedOf} />
125
+ </View>
126
+ )}
79
127
  </View>
80
128
  </View>
81
129
  {!hide.props && <PropsBox def={def} />}
@@ -93,7 +141,9 @@ const styles = StyleSheet.create({
93
141
  padding: CATALOG_LAYOUT.panelPadding,
94
142
  },
95
143
  columns: { flexDirection: 'row', gap: CATALOG_SPACE['2xl'] },
96
- column: { flex: 1 },
144
+ columnsStacked: { flexDirection: 'column' },
145
+ column: { flex: 1, minWidth: 0 },
146
+ columnStacked: { minWidth: 0 },
97
147
  heading: {
98
148
  fontSize: CATALOG_TYPE.panelHeading,
99
149
  fontWeight: '700',
@@ -111,15 +161,20 @@ const styles = StyleSheet.create({
111
161
  marginBottom: CATALOG_SPACE.xs,
112
162
  },
113
163
  fact: {
114
- flexDirection: 'row',
115
- justifyContent: 'space-between',
116
- gap: CATALOG_SPACE.lg,
164
+ flexDirection: 'column',
165
+ gap: CATALOG_SPACE.xs,
117
166
  paddingVertical: CATALOG_LAYOUT.factPaddingY,
118
167
  borderBottomWidth: 1,
119
168
  borderBottomColor: CATALOG_COLOR.borderSubtle,
120
169
  },
121
- factLabel: { fontSize: CATALOG_TYPE.md, fontWeight: '700', color: CATALOG_COLOR.text },
122
- factValue: { flex: 1, fontSize: CATALOG_TYPE.md, lineHeight: 20, textAlign: 'right', color: CATALOG_COLOR.textMuted },
123
- mono: { fontFamily: CATALOG_COLOR.code },
170
+ factLabel: { flexShrink: 1, minWidth: 0, fontSize: CATALOG_TYPE.md, fontWeight: '700', color: CATALOG_COLOR.text },
171
+ factValue: { minWidth: 0, fontSize: CATALOG_TYPE.md, lineHeight: 20, textAlign: 'left', color: CATALOG_COLOR.textMuted },
172
+ // `wordBreak` isn't in the installed `TextStyle` type (RN's own, not RNW's — RNW's own
173
+ // `Text/types.js` declares it; a browser applies any valid CSS property name it recognizes
174
+ // regardless), so a bare literal here would report an unknown property under `--noEmit` even
175
+ // though it genuinely works: a long unbroken token (a source path with no spaces, e.g.
176
+ // `AnimatedChevron.tsx`) has no space for the browser's default line-breaking to use, so without
177
+ // this it stays one unbroken run and overflows a narrow card instead of wrapping at its '/'s.
178
+ mono: { fontFamily: CATALOG_COLOR.code, wordBreak: 'break-word' } as TextStyle,
124
179
  empty: { fontSize: CATALOG_TYPE.sm, fontStyle: 'italic', color: CATALOG_COLOR.textMuted },
125
180
  });