@krapwoo/ds-viewer 0.14.2 → 0.15.0

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.
@@ -5,7 +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 { FamilyPage } from './FamilyPage';
8
+ import { GroupPage } from './GroupPage';
8
9
  import { UpdatePanel } from './UpdatePanel';
10
+ import { SourceReferencePicker } from './SourceReferencePicker';
9
11
  import {
10
12
  aliasTargets,
11
13
  DS_VIEWER_SECRET_HEADER,
@@ -19,6 +21,7 @@ import {
19
21
  UPDATE_PAGE_ID,
20
22
  type MemberSearchEntry,
21
23
  } from './catalogNavigation';
24
+ import { browsableIds, buildReferenceIndex, hasReferenceNavigation, referenceLinkProps, resolveReference, type ReferenceEntry, type ReferenceNavigation } from './catalogReferences';
22
25
  import { findOverlayNode, overlayAddressFromSearch } from './overlayViewport';
23
26
  import { SpecimenAddressProvider } from './SpecimenAddress';
24
27
  import type { NavGroup, PreviewWidths, SectionDef, UpdateNotice, VersionStatus } from './types';
@@ -27,6 +30,12 @@ import type { NavGroup, PreviewWidths, SectionDef, UpdateNotice, VersionStatus }
27
30
  * constraint and a hardcoded string literal type at once, which is why this is its own alias. */
28
31
  type ShellPageId<TId extends string> = TId | typeof UPDATE_PAGE_ID;
29
32
 
33
+ /** #60's "opening context" (plan Task 2B item 4): the page/member a Source reference journey
34
+ * started from, remembered only in memory (no persistent history manager) so a reference
35
+ * destination can offer a real `Back to <origin title>` link. Never an alias — always the actual
36
+ * page/member identity the user was looking at. */
37
+ type ReferenceOrigin<TId extends string> = { pageId: TId; memberId?: string };
38
+
30
39
  function isWeb(): boolean {
31
40
  return Platform.OS === 'web' && typeof window !== 'undefined';
32
41
  }
@@ -128,6 +137,32 @@ function NotFoundPage({ raw, firstHref, onOpenFirst }: { raw: string; firstHref?
128
137
  );
129
138
  }
130
139
 
140
+ /** A quiet return or browse fallback. The callback owns activation in both shell modes,
141
+ * using the same modified-click policy as inline references. A same-family return can reuse
142
+ * this anchor as "Browse catalog" before the browser runs its default, so that default must
143
+ * never follow the newly rendered href after the intended origin has already been selected. */
144
+ function ReferenceReturnLink<TId extends string>({ label, targetId, memberId, onNavigate }: {
145
+ label: string;
146
+ targetId: TId;
147
+ memberId?: string;
148
+ onNavigate: (id: TId, memberId?: string) => void;
149
+ }) {
150
+ const activation = referenceLinkProps({
151
+ resolve: () => ({ pageId: targetId, memberId }),
152
+ open: onNavigate,
153
+ }, targetId);
154
+ return (
155
+ <Pressable
156
+ onPress={activation?.onPress}
157
+ accessibilityRole="link"
158
+ {...({ href: hashForId(memberId ?? targetId) } as Record<string, unknown>)}
159
+ style={notFoundStyles.link}
160
+ >
161
+ <Text style={notFoundStyles.linkText}>{label}</Text>
162
+ </Pressable>
163
+ );
164
+ }
165
+
131
166
  const notFoundStyles = StyleSheet.create({
132
167
  headingTarget: { alignSelf: 'flex-start', marginBottom: 6 },
133
168
  title: { ...CATALOG_TEXT.pageTitle, color: CATALOG_COLOR.text },
@@ -221,7 +256,19 @@ export function CatalogShell<TId extends string>({
221
256
 
222
257
  const sectionsById = useMemo(() => new Map(sections.map((def) => [def.id, def])), [sections]);
223
258
  const labels = useMemo(() => new Map(sections.filter((def) => def.title).map((def) => [def.id as string, def.title as string])), [sections]);
224
- const order = useMemo(() => orderedIds(groups, new Set(sectionsById.keys()), labels), [groups, sectionsById, labels]);
259
+ // #60: the browse-facing subset only (reference-only pages, and their members, stay routable —
260
+ // see `knownAddressIds` below — but drop out of the sidebar/pager/hash-fallback sequence
261
+ // `order` itself is).
262
+ const browsable = useMemo(() => browsableIds(sections), [sections]);
263
+ const order = useMemo(() => orderedIds(groups, browsable, labels), [groups, browsable, labels]);
264
+ // #60: true once any page opts into the `'reference'` layer — the sidebar's own trigger for
265
+ // showing the separate Source reference entry at all; a legacy catalog with no such page keeps
266
+ // its present sidebar unchanged.
267
+ const hasReference = useMemo(() => hasReferenceNavigation(sections), [sections]);
268
+ // #60: the catalog-wide reference index the picker searches — built from every registered page,
269
+ // never filtered to `browsable`, so a page's family members are findable even when the page
270
+ // itself is reference-only.
271
+ const referenceEntries = useMemo(() => buildReferenceIndex(sections), [sections]);
225
272
  // Spec §5.3: every family page's own member ids/aliases, resolved to their page (and member, for
226
273
  // anything other than the whole) — used both for an old/alias/member address (`resolveRoute`)
227
274
  // and for the sidebar's member search (`memberSearchEntries` below).
@@ -230,10 +277,18 @@ export function CatalogShell<TId extends string>({
230
277
  // outside its own family — every other page's own id, plus every member id/alias `aliasMap`
231
278
  // already resolves (`FamilyPage` itself always adds its own family's member ids on top of this).
232
279
  const knownAddressIds = useMemo(() => new Set<string>([...sectionsById.keys(), ...aliasMap.keys()]), [sectionsById, aliasMap]);
280
+ // #60: every registered page id, independent of `order` (browse-only) — `resolveRoute` only
281
+ // ever uses this to decide whether a hash literally names a real page (never for display
282
+ // order), so a reference-only page's own address still resolves even though it's absent from
283
+ // `order`. The "Implementation seams" warning this plan carries: never repurpose the filtered
284
+ // browse set as the known-address set.
285
+ const fullRouteIds = useMemo(() => Array.from(sectionsById.keys()), [sectionsById]);
286
+ // #60: a reference-only family page's own members are excluded here too (not just `order`) —
287
+ // "Reference-only pages and their family member search hits stay out of ordinary browsing".
233
288
  const memberIndex = useMemo(() => {
234
289
  const map = new Map<string, string[]>();
235
290
  for (const section of sections) {
236
- if (!section.family) continue;
291
+ if (!section.family || !browsable.has(section.id)) continue;
237
292
  const ids: string[] = [];
238
293
  for (const member of section.family.members) {
239
294
  ids.push(member.id, ...(member.aliases ?? []));
@@ -241,20 +296,28 @@ export function CatalogShell<TId extends string>({
241
296
  map.set(section.id, ids);
242
297
  }
243
298
  return map;
244
- }, [sections]);
299
+ }, [sections, browsable]);
245
300
  const memberSearchEntries = useMemo(() => {
246
301
  const entries: MemberSearchEntry<TId>[] = [];
247
302
  for (const section of sections) {
248
- if (!section.family) continue;
303
+ if (!section.family || !browsable.has(section.id)) continue;
249
304
  for (const member of section.family.members) {
250
305
  if (member.id === section.family.whole) continue;
251
306
  entries.push({ pageId: section.id, memberId: member.id, aliases: member.aliases, familyTitle: section.title ?? section.id });
252
307
  }
253
308
  }
254
309
  return entries;
255
- }, [sections]);
310
+ }, [sections, browsable]);
311
+
312
+ // #60: the sidebar's own `groups` filtered to `browsable` — a reference-only page is never
313
+ // listed there regardless of whether the catalog author also left it out of `groups` (defense
314
+ // in depth, same reasoning as `memberIndex`/`memberSearchEntries` above).
315
+ const browsableGroups = useMemo(
316
+ () => groups.map((group) => ({ label: group.label, ids: group.ids.filter((id) => browsable.has(id)) })).filter((group) => group.ids.length > 0),
317
+ [groups, browsable],
318
+ );
256
319
 
257
- const routeFromHash = useCallback((hash: string) => resolveRoute(hash, order, aliasMap), [order, aliasMap]);
320
+ const routeFromHash = useCallback((hash: string) => resolveRoute(hash, fullRouteIds, aliasMap), [fullRouteIds, aliasMap]);
258
321
 
259
322
  const [active, setActive] = useState<ShellPageId<TId> | undefined>(() => {
260
323
  if (embedded) return initialPage ?? order[0];
@@ -275,21 +338,65 @@ export function CatalogShell<TId extends string>({
275
338
  // one-shot jump (`shouldRunMemberJump`) counts a re-selected member address as fresh — a plain
276
339
  // `pendingMemberId` state update bails out (no re-render) when the string value is unchanged.
277
340
  const [pendingMemberNonce, setPendingMemberNonce] = useState(0);
341
+ // #64 B5: the tokenDefinition section (if any) a page route's own `sectionId` named — set only
342
+ // by hash resolution (a section-alias route), never by `select` (an ordinary navigation never
343
+ // carries one). Consumed once by `SectionBlock`'s own scroll effect, the same one-shot-jump
344
+ // convention `pendingMemberId`/`pendingMemberNonce` already establishes for a member address.
345
+ const [pendingSectionId, setPendingSectionId] = useState<string | undefined>(() => {
346
+ if (embedded || !isWeb()) return undefined;
347
+ const route = routeFromHash(window.location.hash);
348
+ return route.kind === 'page' ? route.sectionId : undefined;
349
+ });
350
+ const [pendingSectionNonce, setPendingSectionNonce] = useState(0);
278
351
  const [notFoundRaw, setNotFoundRaw] = useState<string | undefined>(() => {
279
352
  if (embedded || !isWeb()) return undefined;
280
353
  const route = routeFromHash(window.location.hash);
281
354
  return route.kind === 'notFound' ? route.raw : undefined;
282
355
  });
283
356
 
357
+ // #60: the Source reference picker's own open/close state, and the one journey it may have
358
+ // started (plan Task 2B item 4) — `undefined` means no recorded origin, which is also the
359
+ // truthful state for a directly loaded reference URL (no invented origin).
360
+ const [pickerOpen, setPickerOpen] = useState(false);
361
+ const [sourceOrigin, setSourceOrigin] = useState<ReferenceOrigin<TId> | undefined>(undefined);
362
+ // What page/member to remember as this journey's origin, captured the moment the picker opens
363
+ // (a ref, not state — opening never itself changes the route, so it must not re-render or touch
364
+ // `sourceOrigin` until an actual selection commits to a destination).
365
+ const pendingOriginRef = useRef<ReferenceOrigin<TId> | undefined>(undefined);
366
+ // A selected entry, parked here until the picker has actually finished closing (see the effect
367
+ // below) — the focus-ordering seam both `SourceReferencePicker.tsx` and this file's own module
368
+ // doc comment warn about: Modal's trigger-restore effect must run first, or it can restore the
369
+ // trigger focus OVER the destination this same selection is about to open.
370
+ const pendingReferenceSelectionRef = useRef<{ entry: ReferenceEntry<TId>; origin: ReferenceOrigin<TId> | undefined } | null>(null);
371
+
284
372
  const scrollRef = useRef<ScrollView>(null);
285
373
  const headingRef = useRef<View>(null);
286
374
  const activeRef = useRef(active);
287
375
  activeRef.current = active;
288
376
  const pushHistoryNext = useRef(false);
289
377
  const focusHeadingNext = useRef(false);
378
+ // #60 correction: the fragment-sync effect below writes `window.location.hash` itself to keep
379
+ // the URL in step with `active`/`pendingMemberId` — on web that write queues its own real
380
+ // `hashchange` event, which arrives at the very listener below meant for genuine external/manual/
381
+ // back-forward navigation. Recording the exact hash we just wrote (cleared the moment the next
382
+ // `hashchange` is seen, matched or not) lets that listener recognise its own settling event and
383
+ // skip it, instead of treating it as new navigation and erasing a just-recorded source origin.
384
+ const ownHashWriteRef = useRef<string | undefined>(undefined);
385
+ // The page/member the last scroll reset ran for — see the URL-sync effect.
386
+ const lastScrollTargetRef = useRef<{ active: ShellPageId<TId>; memberId: string | undefined } | undefined>(undefined);
290
387
 
291
388
  const select = useCallback((id: ShellPageId<TId>, memberId?: string) => {
389
+ // Every ordinary navigation — sidebar, pager, a member hit, the recovery/banner/return links
390
+ // below — starts a fresh page, so any previously recorded Source reference origin is now
391
+ // stale (plan Task 2B item 4: "must not leave a stale source-return control on unrelated
392
+ // pages"). A real selection from the picker re-records it afterwards (see the effect below),
393
+ // in the same batch, so that later call always wins.
394
+ setSourceOrigin(undefined);
292
395
  setNotFoundRaw(undefined);
396
+ // #64 B5: `select` is always an ordinary navigation (sidebar, pager, a member hit, …), never a
397
+ // section-alias arrival — any section scroll still owed from a previous hash resolution is now
398
+ // stale.
399
+ setPendingSectionId(undefined);
293
400
  if (id === activeRef.current) {
294
401
  if (memberId) {
295
402
  pushHistoryNext.current = true;
@@ -308,20 +415,80 @@ export function CatalogShell<TId extends string>({
308
415
  setActive(id);
309
416
  }, []);
310
417
 
418
+ // Task 2B2 correction: the one private reference-selection routine both the picker's deferred
419
+ // selection (below) and inline reference activation (`referenceNavigation.open`, further below)
420
+ // share — `select` alone always clears any recorded origin (every *ordinary* navigation must),
421
+ // so this is what actually records a truthful one afterwards, in the same batch: a self-
422
+ // referential selection (destination already equals the recorded origin) records no origin at
423
+ // all, the same "no self-referential Back link" rule the picker already established.
424
+ const openReference = useCallback(
425
+ (pageId: TId, memberId: string | undefined, origin: ReferenceOrigin<TId> | undefined) => {
426
+ const sameAsOrigin = origin !== undefined && origin.pageId === pageId && origin.memberId === memberId;
427
+ select(pageId, memberId);
428
+ setSourceOrigin(sameAsOrigin ? undefined : origin);
429
+ },
430
+ [select],
431
+ );
432
+
433
+ // Task 2B2: the shared navigation `SectionBlock`/`FamilyPage` use to turn an already-true
434
+ // reference fact (a Composition name, an "also used by" user, a `related`/anatomy name) into a
435
+ // real canonical link — through the exact same `resolveReference`/`openReference` this shell's
436
+ // own Source reference journey already uses, never a second lookup or a global context. Supplied
437
+ // to every active page's own renderer below, independent of `hasReference` (plan: "CatalogShell
438
+ // supplies optional reference-navigation behavior to its own ordinary/family renderers,
439
+ // independent of whether reference-only navigation metadata exists").
440
+ const referenceNavigation: ReferenceNavigation<TId> = useMemo(
441
+ () => ({
442
+ resolve: (name) => {
443
+ const result = resolveReference(name, fullRouteIds, aliasMap, referenceEntries);
444
+ return result.kind === 'address' ? { pageId: result.pageId, memberId: result.memberId } : undefined;
445
+ },
446
+ // The inline origin is the page the reference was actually clicked from — never the stale
447
+ // `pendingMemberId` of a previously selected, unrelated member (plan correction R1: "a
448
+ // page-level anatomy/relatedPages reference starts at the page"). `sourceMemberId`, when the
449
+ // calling renderer supplied one, narrows that to the actual owning part/whole instead — but
450
+ // only once validated against the current page's own real family; an unknown hint can never
451
+ // fabricate an origin.
452
+ open: (pageId, memberId, sourceMemberId) => {
453
+ const current = active !== undefined && active !== UPDATE_PAGE_ID ? active : undefined;
454
+ if (current === undefined) {
455
+ openReference(pageId, memberId, undefined);
456
+ return;
457
+ }
458
+ const ownerFamily = sectionsById.get(current)?.family;
459
+ const validHint = sourceMemberId !== undefined && ownerFamily?.members.some((member) => member.id === sourceMemberId) ? sourceMemberId : undefined;
460
+ openReference(pageId, memberId, { pageId: current, memberId: validHint });
461
+ },
462
+ }),
463
+ [fullRouteIds, aliasMap, referenceEntries, active, sectionsById, openReference],
464
+ );
465
+
311
466
  // Back/forward, manual fragment edits, and our own hash assignments below (which also fire
312
467
  // `hashchange`) all funnel through here.
313
468
  useEffect(() => {
314
469
  if (!routesByUrl) return;
315
470
  const onHashChange = () => {
471
+ // This event may just be our own fragment-sync write (below) settling, not new navigation —
472
+ // state already reflects that write, so there is nothing to reconcile and no stale origin to
473
+ // clear. One-shot: consumed (and cleared) on the very next event, matched or not, so a later
474
+ // genuine navigation that happens to land on the same hash is never mistaken for our own.
475
+ const ownWrite = ownHashWriteRef.current !== undefined && ownHashWriteRef.current === window.location.hash;
476
+ ownHashWriteRef.current = undefined;
477
+ if (ownWrite) return;
478
+ // Back/forward and manual fragment edits bypass `select` entirely — clear any recorded
479
+ // Source reference origin here too, for the same "no stale return control" reason.
480
+ setSourceOrigin(undefined);
316
481
  const route = routeFromHash(window.location.hash);
317
482
  if (route.kind === 'notFound') {
318
483
  setNotFoundRaw(route.raw);
319
484
  setPendingMemberId(undefined);
485
+ setPendingSectionId(undefined);
320
486
  return;
321
487
  }
322
488
  setNotFoundRaw(undefined);
323
489
  if (route.kind === 'empty') {
324
490
  setPendingMemberId(undefined);
491
+ setPendingSectionId(undefined);
325
492
  if (order[0] !== undefined && order[0] !== activeRef.current) {
326
493
  focusHeadingNext.current = true;
327
494
  setActive(order[0]);
@@ -330,6 +497,7 @@ export function CatalogShell<TId extends string>({
330
497
  }
331
498
  if (route.kind === 'update') {
332
499
  setPendingMemberId(undefined);
500
+ setPendingSectionId(undefined);
333
501
  if (UPDATE_PAGE_ID !== activeRef.current) {
334
502
  focusHeadingNext.current = true;
335
503
  setActive(UPDATE_PAGE_ID);
@@ -338,6 +506,12 @@ export function CatalogShell<TId extends string>({
338
506
  }
339
507
  if (route.kind === 'page') {
340
508
  setPendingMemberId(undefined);
509
+ // #64 B5: a section-alias route re-scrolls even to the page it's already on (a plain
510
+ // `pendingSectionId` string update would otherwise bail out when re-visiting the same
511
+ // section) — bump the nonce whenever one is named, the same "even a repeat one" reasoning
512
+ // `pendingMemberNonce` already uses for a member address.
513
+ setPendingSectionId(route.sectionId);
514
+ if (route.sectionId) setPendingSectionNonce((n) => n + 1);
341
515
  if (route.id !== activeRef.current) {
342
516
  focusHeadingNext.current = true;
343
517
  setActive(route.id);
@@ -346,6 +520,7 @@ export function CatalogShell<TId extends string>({
346
520
  }
347
521
  // member: scroll/open/focus is the member itself (FamilyPage's own effect), never the page
348
522
  // heading — even when this also switches the active page.
523
+ setPendingSectionId(undefined);
349
524
  setPendingMemberId(route.memberId);
350
525
  setPendingMemberNonce((n) => n + 1);
351
526
  if (route.pageId !== activeRef.current) {
@@ -366,21 +541,73 @@ export function CatalogShell<TId extends string>({
366
541
  const focus = focusHeadingNext.current;
367
542
  pushHistoryNext.current = false;
368
543
  focusHeadingNext.current = false;
369
- if (routesByUrl) {
544
+ // F7: while a section-alias arrival is live (`pendingSectionId` is cleared by every other
545
+ // navigation), the URL already names that section — leave the alias hash alone so a reload or
546
+ // a copied link keeps the section instead of collapsing to the page id.
547
+ const keepSectionAliasHash = pendingMemberId === undefined && pendingSectionId !== undefined;
548
+ if (routesByUrl && !keepSectionAliasHash) {
370
549
  const expected = hashForId(pendingMemberId ?? active);
371
550
  if (window.location.hash !== expected) {
551
+ ownHashWriteRef.current = expected;
372
552
  if (push) window.location.hash = expected;
373
553
  else window.history.replaceState(null, '', expected);
374
554
  }
375
555
  }
376
- if (!pendingMemberId) {
556
+ // Only a real page or member change returns to the top. A change of `pendingSectionId` alone
557
+ // (a same-page section-alias arrival) re-runs this effect for the hash above, but the section
558
+ // has already scrolled itself into view (child effects run first), so resetting would undo it.
559
+ const previous = lastScrollTargetRef.current;
560
+ lastScrollTargetRef.current = { active, memberId: pendingMemberId };
561
+ const targetChanged = previous === undefined || previous.active !== active || previous.memberId !== pendingMemberId;
562
+ if (!pendingMemberId && targetChanged) {
377
563
  resetMainScroll(scrollRef.current);
378
564
  if (focus) focusElement(headingRef.current);
379
565
  }
380
- }, [active, pendingMemberId, routesByUrl]);
566
+ }, [active, pendingMemberId, pendingSectionId, routesByUrl]);
567
+
568
+ // #60: captures the current page/member as this journey's origin (before the picker has
569
+ // changed anything), then opens it. A cancellation (Escape/Close) never reaches the effect
570
+ // below, so nothing it captured here is ever applied.
571
+ const openSourceReferencePicker = useCallback(() => {
572
+ pendingOriginRef.current = active !== undefined && active !== UPDATE_PAGE_ID ? { pageId: active, memberId: pendingMemberId } : undefined;
573
+ setPickerOpen(true);
574
+ }, [active, pendingMemberId]);
575
+
576
+ const dismissSourceReferencePicker = useCallback(() => setPickerOpen(false), []);
577
+
578
+ // The picker leaf never navigates itself (`SourceReferencePicker.tsx`'s own module doc
579
+ // comment) — only parks the choice and closes. The effect below applies it once closed.
580
+ const selectReference = useCallback((entry: ReferenceEntry<TId>) => {
581
+ pendingReferenceSelectionRef.current = { entry, origin: pendingOriginRef.current };
582
+ setPickerOpen(false);
583
+ }, []);
584
+
585
+ // Runs only after the picker has actually finished closing — React commits a child's (the
586
+ // Modal's) own closing/trigger-restore effects before this component's own effect, so the
587
+ // destination's focus below can never be clobbered by that restore (the ordering hazard this
588
+ // file's own module doc comment and `SourceReferencePicker.tsx`'s both warn about).
589
+ useEffect(() => {
590
+ if (pickerOpen) return;
591
+ const pending = pendingReferenceSelectionRef.current;
592
+ if (!pending) return;
593
+ pendingReferenceSelectionRef.current = null;
594
+ const { entry, origin } = pending;
595
+ openReference(entry.pageId, entry.memberId, origin);
596
+ }, [pickerOpen, openReference]);
381
597
 
382
598
  const activeDef = active !== undefined && active !== UPDATE_PAGE_ID ? sectionsById.get(active) : undefined;
383
599
 
600
+ // Return to the recorded source-lookup origin for any destination. A directly opened
601
+ // reference-only page has no origin and may instead offer the first real browsing page.
602
+ const referenceReturn = useMemo(() => {
603
+ if (!activeDef) return undefined;
604
+ if (sourceOrigin) {
605
+ return { label: `Back to ${labels.get(sourceOrigin.pageId) ?? sourceOrigin.pageId}`, targetId: sourceOrigin.pageId, memberId: sourceOrigin.memberId };
606
+ }
607
+ if (activeDef.navigation === 'reference' && order[0] !== undefined) return { label: 'Browse catalog', targetId: order[0], memberId: undefined };
608
+ return undefined;
609
+ }, [activeDef, sourceOrigin, labels, order]);
610
+
384
611
  // The live version status: starts from what `dev` wrote at startup, then follows the local
385
612
  // endpoint (a reload after **Check now** must not fall back to the startup snapshot).
386
613
  const [liveStatus, setLiveStatus] = useState<VersionStatus | null>(versionStatus ?? null);
@@ -435,7 +662,7 @@ export function CatalogShell<TId extends string>({
435
662
  logo={appName}
436
663
  logoImageSource={logoImageSource}
437
664
  caption={title}
438
- groups={groups}
665
+ groups={browsableGroups}
439
666
  labels={labels}
440
667
  memberIndex={memberIndex}
441
668
  memberSearchEntries={memberSearchEntries}
@@ -443,8 +670,10 @@ export function CatalogShell<TId extends string>({
443
670
  onPress={select}
444
671
  onSelectMember={(pageId, memberId) => select(pageId, memberId)}
445
672
  footer={footer}
673
+ sourceReference={hasReference ? { onPress: openSourceReferencePicker } : undefined}
446
674
  keepDocumentUrl={embedded}
447
675
  />
676
+ <SourceReferencePicker visible={pickerOpen} entries={referenceEntries} onDismiss={dismissSourceReferencePicker} onSelect={selectReference} />
448
677
  <ScrollView ref={scrollRef} style={styles.main} contentContainerStyle={styles.mainContent}>
449
678
  {notFoundRaw !== undefined ? (
450
679
  <NotFoundPage raw={notFoundRaw} firstHref={order[0] !== undefined ? hashForId(order[0]) : undefined} onOpenFirst={() => order[0] !== undefined && select(order[0])} />
@@ -457,44 +686,87 @@ export function CatalogShell<TId extends string>({
457
686
  appName={appName}
458
687
  headingRef={headingRef}
459
688
  />
689
+ ) : activeDef?.family?.kind === 'group' ? (
690
+ <React.Fragment key={activeDef.id}>
691
+ {referenceReturn && <ReferenceReturnLink label={referenceReturn.label} targetId={referenceReturn.targetId} memberId={referenceReturn.memberId} onNavigate={select} />}
692
+ <GroupPage
693
+ def={activeDef}
694
+ groupLabel={groupLabelFor(groups, activeDef.id)}
695
+ breadcrumbRoot={appName}
696
+ banner={showBanner && effectiveUpdate ? <MajorBanner update={effectiveUpdate} onReviewUpdate={() => select(UPDATE_PAGE_ID)} onDismiss={dismissMajorBanner} keepDocumentUrl={embedded} /> : undefined}
697
+ pager={{
698
+ previousId: neighbors(order, activeDef.id).previous,
699
+ nextId: neighbors(order, activeDef.id).next,
700
+ onNavigate: select,
701
+ labelOf: (id) => labels.get(id) ?? id,
702
+ }}
703
+ headingRef={headingRef}
704
+ defaultPreviewWidths={defaultPreviewWidths}
705
+ focusMemberId={pendingMemberId}
706
+ focusNonce={pendingMemberNonce}
707
+ knownAddressIds={knownAddressIds}
708
+ navigation={referenceNavigation}
709
+ />
710
+ </React.Fragment>
460
711
  ) : activeDef?.family ? (
461
- <FamilyPage
462
- key={activeDef.id}
463
- def={activeDef}
464
- groupLabel={groupLabelFor(groups, activeDef.id)}
465
- breadcrumbRoot={appName}
466
- banner={showBanner && effectiveUpdate ? <MajorBanner update={effectiveUpdate} onReviewUpdate={() => select(UPDATE_PAGE_ID)} onDismiss={dismissMajorBanner} keepDocumentUrl={embedded} /> : undefined}
467
- pager={{
468
- previousId: neighbors(order, activeDef.id).previous,
469
- nextId: neighbors(order, activeDef.id).next,
470
- onNavigate: select,
471
- labelOf: (id) => labels.get(id) ?? id,
472
- }}
473
- headingRef={headingRef}
474
- defaultPreviewWidths={defaultPreviewWidths}
475
- focusMemberId={pendingMemberId}
476
- focusNonce={pendingMemberNonce}
477
- knownAddressIds={knownAddressIds}
478
- />
712
+ <React.Fragment key={activeDef.id}>
713
+ {referenceReturn && <ReferenceReturnLink label={referenceReturn.label} targetId={referenceReturn.targetId} memberId={referenceReturn.memberId} onNavigate={select} />}
714
+ <FamilyPage
715
+ def={activeDef}
716
+ groupLabel={groupLabelFor(groups, activeDef.id)}
717
+ breadcrumbRoot={appName}
718
+ banner={showBanner && effectiveUpdate ? <MajorBanner update={effectiveUpdate} onReviewUpdate={() => select(UPDATE_PAGE_ID)} onDismiss={dismissMajorBanner} keepDocumentUrl={embedded} /> : undefined}
719
+ pager={{
720
+ previousId: neighbors(order, activeDef.id).previous,
721
+ nextId: neighbors(order, activeDef.id).next,
722
+ onNavigate: select,
723
+ labelOf: (id) => labels.get(id) ?? id,
724
+ }}
725
+ headingRef={headingRef}
726
+ defaultPreviewWidths={defaultPreviewWidths}
727
+ focusMemberId={pendingMemberId}
728
+ focusNonce={pendingMemberNonce}
729
+ knownAddressIds={knownAddressIds}
730
+ navigation={referenceNavigation}
731
+ />
732
+ </React.Fragment>
479
733
  ) : activeDef ? (
480
- <SectionBlock
481
- key={activeDef.id}
482
- def={activeDef}
483
- groupLabel={groupLabelFor(groups, activeDef.id)}
484
- breadcrumbRoot={appName}
485
- // The approved mockup's own `PAGE_BG` order: crumb, then banner, then title — the
486
- // banner used to render above the breadcrumb instead (Important finding, Fable
487
- // correction pass).
488
- banner={showBanner && effectiveUpdate ? <MajorBanner update={effectiveUpdate} onReviewUpdate={() => select(UPDATE_PAGE_ID)} onDismiss={dismissMajorBanner} keepDocumentUrl={embedded} /> : undefined}
489
- pager={{
490
- previousId: neighbors(order, activeDef.id).previous,
491
- nextId: neighbors(order, activeDef.id).next,
492
- onNavigate: select,
493
- labelOf: (id) => labels.get(id) ?? id,
494
- }}
495
- headingRef={headingRef}
496
- defaultPreviewWidths={defaultPreviewWidths}
497
- />
734
+ <React.Fragment key={activeDef.id}>
735
+ {referenceReturn && <ReferenceReturnLink label={referenceReturn.label} targetId={referenceReturn.targetId} memberId={referenceReturn.memberId} onNavigate={select} />}
736
+ <SectionBlock
737
+ def={activeDef}
738
+ groupLabel={groupLabelFor(groups, activeDef.id)}
739
+ breadcrumbRoot={appName}
740
+ // The approved mockup's own `PAGE_BG` order: crumb, then banner, then title — the
741
+ // banner used to render above the breadcrumb instead (Important finding, Fable
742
+ // correction pass).
743
+ banner={showBanner && effectiveUpdate ? <MajorBanner update={effectiveUpdate} onReviewUpdate={() => select(UPDATE_PAGE_ID)} onDismiss={dismissMajorBanner} keepDocumentUrl={embedded} /> : undefined}
744
+ pager={{
745
+ previousId: neighbors(order, activeDef.id).previous,
746
+ nextId: neighbors(order, activeDef.id).next,
747
+ onNavigate: select,
748
+ labelOf: (id) => labels.get(id) ?? id,
749
+ }}
750
+ headingRef={headingRef}
751
+ defaultPreviewWidths={defaultPreviewWidths}
752
+ navigation={referenceNavigation}
753
+ focusSectionId={pendingSectionId}
754
+ focusNonce={pendingSectionNonce}
755
+ />
756
+ </React.Fragment>
757
+ ) : hasReference ? (
758
+ // #60 correction: an all-reference catalog with no `initialPage`/browsable page has
759
+ // `active === undefined` here too, same as a genuinely empty one — but every one of its
760
+ // registered reference pages is real and selectable, so claiming "No catalog pages are
761
+ // available" would be false. No destination is fabricated (there is no browsable page to
762
+ // invent one from); this only ever offers the real Source reference action already wired
763
+ // above, never a second picker or a guessed route.
764
+ <View>
765
+ <Text style={styles.empty}>No pages are set up for ordinary browsing yet.</Text>
766
+ <Pressable onPress={openSourceReferencePicker} accessibilityRole="button" style={notFoundStyles.link}>
767
+ <Text style={notFoundStyles.linkText}>Open source reference</Text>
768
+ </Pressable>
769
+ </View>
498
770
  ) : (
499
771
  <Text style={styles.empty}>No catalog pages are available.</Text>
500
772
  )}
@@ -75,6 +75,31 @@ function MemberResultItem({ label, familyTitle, href, onPress, keepDocumentUrl =
75
75
  );
76
76
  }
77
77
 
78
+ /** #60's quiet Source reference entry (plan Task 2B, "the accepted A access model"): opens the
79
+ * catalog-wide picker modal, so — unlike every other row here — it is a real button, not a link
80
+ * (no `href`, no navigation of its own). Rendered outside `filtered`/`memberHits` so a filter that
81
+ * matches no page never hides it (`CatalogShell` only passes `sourceReference` at all once
82
+ * `hasReferenceNavigation` says the catalog has one). Reuses the plain nav-row visual `NavItem`
83
+ * already uses. */
84
+ function SourceReferenceEntry({ onPress }: { onPress: () => void }) {
85
+ const [hovered, setHovered] = useState(false);
86
+ const [focused, setFocused] = useState(false);
87
+ return (
88
+ <Pressable
89
+ onPress={onPress}
90
+ onHoverIn={() => setHovered(true)}
91
+ onHoverOut={() => setHovered(false)}
92
+ onFocus={() => setFocused(true)}
93
+ onBlur={() => setFocused(false)}
94
+ accessibilityRole="button"
95
+ accessibilityLabel="Open source reference"
96
+ style={({ pressed }) => [styles.item, styles.sourceReferenceItem, (pressed || hovered) && styles.itemHover, focused && styles.focusRing]}
97
+ >
98
+ <Text style={styles.label}>Source reference</Text>
99
+ </Pressable>
100
+ );
101
+ }
102
+
78
103
  function FooterLink({ label, kind, active, onPress, keepDocumentUrl = false }: { label: string; kind: 'update' | 'version'; active: boolean; onPress: () => void; keepDocumentUrl?: boolean }) {
79
104
  const [hovered, setHovered] = useState(false);
80
105
  const [focused, setFocused] = useState(false);
@@ -119,6 +144,7 @@ export function CatalogSidebar<TId extends string>({
119
144
  onPress,
120
145
  onSelectMember,
121
146
  footer,
147
+ sourceReference,
122
148
  keepDocumentUrl = false,
123
149
  }: {
124
150
  logo: string;
@@ -148,6 +174,10 @@ export function CatalogSidebar<TId extends string>({
148
174
  * or the update notice with a dot (`'update'`, design §5's footer line). `active` is true while
149
175
  * the update page itself is open — it gets the same active styling a selected nav item would. */
150
176
  footer?: { label: string; kind?: 'update' | 'version'; active: boolean; onPress: () => void };
177
+ /** #60's catalog-wide Source reference entry: present only once `CatalogShell` has decided (via
178
+ * `hasReferenceNavigation`) that the catalog has at least one `'reference'` page. Omitted keeps
179
+ * the legacy sidebar unchanged. */
180
+ sourceReference?: { onPress: () => void };
151
181
  }) {
152
182
  const [query, setQuery] = useState('');
153
183
  // A bundled local image (the only kind `cli/workspace.ts` ever produces — see its doc comment
@@ -238,7 +268,12 @@ export function CatalogSidebar<TId extends string>({
238
268
 
239
269
  <CatalogSearchInput value={query} onChangeText={setQuery} placeholder="Filter components…" />
240
270
 
241
- {filtered.length === 0 && memberHits.length === 0 && <Text style={styles.empty}>No matches</Text>}
271
+ {/* Outside `filtered`/`memberHits`: a filter that matches no page must not hide this. */}
272
+ {sourceReference && <SourceReferenceEntry onPress={sourceReference.onPress} />}
273
+
274
+ {/* Preserve omitted-metadata catalogs; only source-enabled empty lists suppress an
275
+ unfiltered no-match message. A genuine filtered search still reports no matches. */}
276
+ {(!sourceReference || query.trim() !== '') && filtered.length === 0 && memberHits.length === 0 && <Text style={styles.empty}>No matches</Text>}
242
277
  {/* V2c item 10: pages first, member hits below them — matching `memberSearchEntries`'s own
243
278
  doc comment ("below the filtered pages") and the spec's "the sidebar lists family pages
244
279
  only, never members" emphasis. */}
@@ -299,6 +334,7 @@ const styles = StyleSheet.create({
299
334
  inlineHeaderTop: { alignItems: 'flex-start' },
300
335
  inlineText: { flex: 1, minWidth: 0 },
301
336
  empty: { ...CATALOG_TEXT.caption, color: CATALOG_COLOR.textMuted, paddingHorizontal: CATALOG_LAYOUT.navItemPaddingX, paddingVertical: 8 },
337
+ sourceReferenceItem: { marginBottom: CATALOG_SPACE.sm },
302
338
  footerContainer: {
303
339
  borderTopWidth: 1,
304
340
  borderTopColor: CATALOG_COLOR.borderHairline,