@awarebydefault/display-case 1.0.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.
Files changed (254) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +309 -0
  3. package/display-case.prompt.md +64 -0
  4. package/docs/ai-agents.md +126 -0
  5. package/docs/cli.md +99 -0
  6. package/docs/configuration.md +410 -0
  7. package/docs/documentation-panel.md +50 -0
  8. package/docs/examples/README.md +14 -0
  9. package/docs/examples/multi-variant.case.tsx +30 -0
  10. package/docs/examples/plain.case.tsx +22 -0
  11. package/docs/examples/tweak-control.placard.md +80 -0
  12. package/docs/examples/tweaks.case.tsx +39 -0
  13. package/docs/hierarchy.md +59 -0
  14. package/docs/quick-start.md +78 -0
  15. package/docs/style-engines.md +180 -0
  16. package/docs/testing.md +245 -0
  17. package/docs/theming.md +97 -0
  18. package/docs/tweaks.md +75 -0
  19. package/docs/writing-cases.md +144 -0
  20. package/docs/writing-placard-docs.md +194 -0
  21. package/package.json +113 -0
  22. package/skills/display-case-author-case/README.md +20 -0
  23. package/skills/display-case-author-case/SKILL.md +40 -0
  24. package/skills/display-case-author-placard-doc/README.md +24 -0
  25. package/skills/display-case-author-placard-doc/SKILL.md +65 -0
  26. package/skills/display-case-review/README.md +19 -0
  27. package/skills/display-case-review/SKILL.md +30 -0
  28. package/skills/display-case-snapshot/README.md +20 -0
  29. package/skills/display-case-snapshot/SKILL.md +29 -0
  30. package/src/checks/a11y-scanner.test.ts +240 -0
  31. package/src/checks/a11y-scanner.ts +410 -0
  32. package/src/checks/check-text.test.ts +53 -0
  33. package/src/checks/check-text.ts +78 -0
  34. package/src/checks/check.test.ts +194 -0
  35. package/src/checks/check.ts +473 -0
  36. package/src/checks/providers/pixelmatch-diff.test.ts +79 -0
  37. package/src/checks/providers/pixelmatch-diff.ts +30 -0
  38. package/src/checks/providers/playwright-driver.ts +104 -0
  39. package/src/checks/ssr-check.test.ts +73 -0
  40. package/src/checks/ssr-check.ts +96 -0
  41. package/src/checks/structure-check.cross-package.test.ts +165 -0
  42. package/src/checks/structure-check.test.ts +651 -0
  43. package/src/checks/structure-check.ts +988 -0
  44. package/src/checks/tokens-check.test.ts +159 -0
  45. package/src/checks/tokens-check.ts +162 -0
  46. package/src/cli.ts +218 -0
  47. package/src/commands/agents.test.ts +24 -0
  48. package/src/commands/agents.ts +28 -0
  49. package/src/commands/init-run.test.ts +123 -0
  50. package/src/commands/init.test.ts +63 -0
  51. package/src/commands/init.ts +412 -0
  52. package/src/commands/publish.test.ts +210 -0
  53. package/src/commands/publish.ts +292 -0
  54. package/src/core/affected.test.ts +99 -0
  55. package/src/core/affected.ts +144 -0
  56. package/src/core/catalog.test.ts +152 -0
  57. package/src/core/catalog.ts +92 -0
  58. package/src/core/discovery.test.ts +184 -0
  59. package/src/core/discovery.ts +250 -0
  60. package/src/core/manifest.ts +41 -0
  61. package/src/core/mdx-lite/__fixtures__/box-stub.tsx +7 -0
  62. package/src/core/mdx-lite/index.ts +393 -0
  63. package/src/core/mdx-lite/mdx-lite.test.ts +345 -0
  64. package/src/core/mdx-plugin.test.ts +60 -0
  65. package/src/core/mdx-plugin.ts +30 -0
  66. package/src/flow-step.test-d.ts +39 -0
  67. package/src/index.test.ts +100 -0
  68. package/src/index.ts +564 -0
  69. package/src/render/collect-styles.emotion.test.tsx +114 -0
  70. package/src/render/collect-styles.test.tsx +72 -0
  71. package/src/render/collect-styles.ts +33 -0
  72. package/src/render/documents.test.ts +184 -0
  73. package/src/render/documents.ts +88 -0
  74. package/src/render/render-node.test.tsx +160 -0
  75. package/src/render/render-node.tsx +133 -0
  76. package/src/render/ssr-primer.test.tsx +25 -0
  77. package/src/render/ssr-primer.tsx +54 -0
  78. package/src/render/ssr-render.test.tsx +142 -0
  79. package/src/render/ssr-render.tsx +63 -0
  80. package/src/render/ssr-shell.test.tsx +57 -0
  81. package/src/render/ssr-shell.tsx +54 -0
  82. package/src/server/prod-server.ts +237 -0
  83. package/src/server/server.test.ts +117 -0
  84. package/src/server/server.ts +1039 -0
  85. package/src/style-engine.test-d.ts +37 -0
  86. package/src/testing/test-helpers.ts +27 -0
  87. package/src/types/pixelmatch.d.ts +12 -0
  88. package/src/ui/browser-entry.tsx +51 -0
  89. package/src/ui/chrome.css +485 -0
  90. package/src/ui/design-system/README.md +88 -0
  91. package/src/ui/design-system/components/controls/Button.case.tsx +52 -0
  92. package/src/ui/design-system/components/controls/Button.css +89 -0
  93. package/src/ui/design-system/components/controls/Button.placard.md +14 -0
  94. package/src/ui/design-system/components/controls/Button.test.tsx +45 -0
  95. package/src/ui/design-system/components/controls/Button.tsx +41 -0
  96. package/src/ui/design-system/components/controls/IconButton.case.tsx +52 -0
  97. package/src/ui/design-system/components/controls/IconButton.css +67 -0
  98. package/src/ui/design-system/components/controls/IconButton.placard.md +13 -0
  99. package/src/ui/design-system/components/controls/IconButton.test.tsx +39 -0
  100. package/src/ui/design-system/components/controls/IconButton.tsx +47 -0
  101. package/src/ui/design-system/components/controls/Input.case.tsx +50 -0
  102. package/src/ui/design-system/components/controls/Input.css +52 -0
  103. package/src/ui/design-system/components/controls/Input.placard.md +12 -0
  104. package/src/ui/design-system/components/controls/Input.test.tsx +43 -0
  105. package/src/ui/design-system/components/controls/Input.tsx +45 -0
  106. package/src/ui/design-system/components/controls/Select.case.tsx +48 -0
  107. package/src/ui/design-system/components/controls/Select.css +44 -0
  108. package/src/ui/design-system/components/controls/Select.placard.md +15 -0
  109. package/src/ui/design-system/components/controls/Select.test.tsx +57 -0
  110. package/src/ui/design-system/components/controls/Select.tsx +58 -0
  111. package/src/ui/design-system/components/controls/SelectMenu.case.tsx +100 -0
  112. package/src/ui/design-system/components/controls/SelectMenu.css +72 -0
  113. package/src/ui/design-system/components/controls/SelectMenu.placard.md +18 -0
  114. package/src/ui/design-system/components/controls/SelectMenu.test.tsx +66 -0
  115. package/src/ui/design-system/components/controls/SelectMenu.tsx +377 -0
  116. package/src/ui/design-system/components/index.ts +66 -0
  117. package/src/ui/design-system/components/primer-specimen/ColorRamp.case.tsx +44 -0
  118. package/src/ui/design-system/components/primer-specimen/ColorRamp.placard.md +15 -0
  119. package/src/ui/design-system/components/primer-specimen/ColorRamp.tsx +51 -0
  120. package/src/ui/design-system/components/primer-specimen/DefinitionList.case.tsx +38 -0
  121. package/src/ui/design-system/components/primer-specimen/DefinitionList.placard.md +15 -0
  122. package/src/ui/design-system/components/primer-specimen/DefinitionList.tsx +41 -0
  123. package/src/ui/design-system/components/primer-specimen/FontFamilies.case.tsx +24 -0
  124. package/src/ui/design-system/components/primer-specimen/FontFamilies.placard.md +12 -0
  125. package/src/ui/design-system/components/primer-specimen/FontFamilies.tsx +41 -0
  126. package/src/ui/design-system/components/primer-specimen/GlyphGrid.case.tsx +27 -0
  127. package/src/ui/design-system/components/primer-specimen/GlyphGrid.placard.md +13 -0
  128. package/src/ui/design-system/components/primer-specimen/GlyphGrid.tsx +34 -0
  129. package/src/ui/design-system/components/primer-specimen/LayoutMock.case.tsx +36 -0
  130. package/src/ui/design-system/components/primer-specimen/LayoutMock.placard.md +7 -0
  131. package/src/ui/design-system/components/primer-specimen/LayoutMock.tsx +36 -0
  132. package/src/ui/design-system/components/primer-specimen/SpacingScale.case.tsx +20 -0
  133. package/src/ui/design-system/components/primer-specimen/SpacingScale.placard.md +12 -0
  134. package/src/ui/design-system/components/primer-specimen/SpacingScale.tsx +33 -0
  135. package/src/ui/design-system/components/primer-specimen/SpecimenBoxRow.case.tsx +56 -0
  136. package/src/ui/design-system/components/primer-specimen/SpecimenBoxRow.placard.md +17 -0
  137. package/src/ui/design-system/components/primer-specimen/SpecimenBoxRow.tsx +45 -0
  138. package/src/ui/design-system/components/primer-specimen/StatusList.case.tsx +17 -0
  139. package/src/ui/design-system/components/primer-specimen/StatusList.placard.md +16 -0
  140. package/src/ui/design-system/components/primer-specimen/StatusList.tsx +39 -0
  141. package/src/ui/design-system/components/primer-specimen/SwatchGrid.case.tsx +26 -0
  142. package/src/ui/design-system/components/primer-specimen/SwatchGrid.placard.md +15 -0
  143. package/src/ui/design-system/components/primer-specimen/SwatchGrid.tsx +42 -0
  144. package/src/ui/design-system/components/primer-specimen/TypeScale.case.tsx +23 -0
  145. package/src/ui/design-system/components/primer-specimen/TypeScale.placard.md +14 -0
  146. package/src/ui/design-system/components/primer-specimen/TypeScale.tsx +34 -0
  147. package/src/ui/design-system/components/primer-specimen/WeightSpecimen.case.tsx +28 -0
  148. package/src/ui/design-system/components/primer-specimen/WeightSpecimen.placard.md +15 -0
  149. package/src/ui/design-system/components/primer-specimen/WeightSpecimen.tsx +46 -0
  150. package/src/ui/design-system/components/primer-specimen/index.ts +31 -0
  151. package/src/ui/design-system/components/primer-specimen/styles.css +476 -0
  152. package/src/ui/design-system/components/shell/A11yPage.case.tsx +237 -0
  153. package/src/ui/design-system/components/shell/A11yPage.placard.md +15 -0
  154. package/src/ui/design-system/components/shell/CaseTemplate.case.tsx +32 -0
  155. package/src/ui/design-system/components/shell/CaseTemplate.placard.md +5 -0
  156. package/src/ui/design-system/components/shell/CasesPage.case.tsx +141 -0
  157. package/src/ui/design-system/components/shell/CasesPage.placard.md +12 -0
  158. package/src/ui/design-system/components/shell/PrimerPage.case.tsx +22 -0
  159. package/src/ui/design-system/components/shell/PrimerPage.placard.md +3 -0
  160. package/src/ui/design-system/components/shell/PrimerTemplate.case.tsx +22 -0
  161. package/src/ui/design-system/components/shell/PrimerTemplate.placard.md +5 -0
  162. package/src/ui/design-system/components/shell/ShellView.case.tsx +57 -0
  163. package/src/ui/design-system/components/shell/ShellView.placard.md +5 -0
  164. package/src/ui/design-system/components/shell/ShellView.tsx +678 -0
  165. package/src/ui/design-system/components/shell/shell-fixtures.tsx +727 -0
  166. package/src/ui/design-system/components/showcase/A11yBadge.case.tsx +46 -0
  167. package/src/ui/design-system/components/showcase/A11yBadge.css +27 -0
  168. package/src/ui/design-system/components/showcase/A11yBadge.placard.md +11 -0
  169. package/src/ui/design-system/components/showcase/A11yBadge.test.tsx +31 -0
  170. package/src/ui/design-system/components/showcase/A11yBadge.tsx +41 -0
  171. package/src/ui/design-system/components/showcase/A11yPanel.case.tsx +121 -0
  172. package/src/ui/design-system/components/showcase/A11yPanel.css +198 -0
  173. package/src/ui/design-system/components/showcase/A11yPanel.placard.md +19 -0
  174. package/src/ui/design-system/components/showcase/A11yPanel.test.tsx +81 -0
  175. package/src/ui/design-system/components/showcase/A11yPanel.tsx +144 -0
  176. package/src/ui/design-system/components/showcase/Chip.case.tsx +48 -0
  177. package/src/ui/design-system/components/showcase/Chip.css +51 -0
  178. package/src/ui/design-system/components/showcase/Chip.placard.md +13 -0
  179. package/src/ui/design-system/components/showcase/Chip.test.tsx +46 -0
  180. package/src/ui/design-system/components/showcase/Chip.tsx +54 -0
  181. package/src/ui/design-system/components/showcase/Eyebrow.case.tsx +30 -0
  182. package/src/ui/design-system/components/showcase/Eyebrow.css +16 -0
  183. package/src/ui/design-system/components/showcase/Eyebrow.placard.md +10 -0
  184. package/src/ui/design-system/components/showcase/Eyebrow.test.tsx +38 -0
  185. package/src/ui/design-system/components/showcase/Eyebrow.tsx +29 -0
  186. package/src/ui/design-system/components/showcase/FlowNav.case.tsx +35 -0
  187. package/src/ui/design-system/components/showcase/FlowNav.css +29 -0
  188. package/src/ui/design-system/components/showcase/FlowNav.placard.md +13 -0
  189. package/src/ui/design-system/components/showcase/FlowNav.test.tsx +48 -0
  190. package/src/ui/design-system/components/showcase/FlowNav.tsx +58 -0
  191. package/src/ui/design-system/components/showcase/ImpactTag.case.tsx +19 -0
  192. package/src/ui/design-system/components/showcase/ImpactTag.css +36 -0
  193. package/src/ui/design-system/components/showcase/ImpactTag.placard.md +14 -0
  194. package/src/ui/design-system/components/showcase/ImpactTag.test.tsx +40 -0
  195. package/src/ui/design-system/components/showcase/ImpactTag.tsx +35 -0
  196. package/src/ui/design-system/components/showcase/NavItem.case.tsx +86 -0
  197. package/src/ui/design-system/components/showcase/NavItem.css +111 -0
  198. package/src/ui/design-system/components/showcase/NavItem.placard.md +13 -0
  199. package/src/ui/design-system/components/showcase/NavItem.test.tsx +65 -0
  200. package/src/ui/design-system/components/showcase/NavItem.tsx +95 -0
  201. package/src/ui/design-system/components/showcase/RenderAddress.case.tsx +21 -0
  202. package/src/ui/design-system/components/showcase/RenderAddress.css +35 -0
  203. package/src/ui/design-system/components/showcase/RenderAddress.placard.md +7 -0
  204. package/src/ui/design-system/components/showcase/RenderAddress.test.tsx +26 -0
  205. package/src/ui/design-system/components/showcase/RenderAddress.tsx +43 -0
  206. package/src/ui/design-system/components/showcase/SegmentedToggle.case.tsx +84 -0
  207. package/src/ui/design-system/components/showcase/SegmentedToggle.css +61 -0
  208. package/src/ui/design-system/components/showcase/SegmentedToggle.placard.md +21 -0
  209. package/src/ui/design-system/components/showcase/SegmentedToggle.test.tsx +81 -0
  210. package/src/ui/design-system/components/showcase/SegmentedToggle.tsx +75 -0
  211. package/src/ui/design-system/components/showcase/Sidebar.case.tsx +67 -0
  212. package/src/ui/design-system/components/showcase/Sidebar.css +6 -0
  213. package/src/ui/design-system/components/showcase/Sidebar.placard.md +14 -0
  214. package/src/ui/design-system/components/showcase/Sidebar.test.tsx +32 -0
  215. package/src/ui/design-system/components/showcase/Sidebar.tsx +30 -0
  216. package/src/ui/design-system/components/showcase/Stage.case.tsx +51 -0
  217. package/src/ui/design-system/components/showcase/Stage.css +91 -0
  218. package/src/ui/design-system/components/showcase/Stage.placard.md +15 -0
  219. package/src/ui/design-system/components/showcase/Stage.test.tsx +84 -0
  220. package/src/ui/design-system/components/showcase/Stage.tsx +97 -0
  221. package/src/ui/design-system/components/showcase/TweaksPanel.case.tsx +81 -0
  222. package/src/ui/design-system/components/showcase/TweaksPanel.css +169 -0
  223. package/src/ui/design-system/components/showcase/TweaksPanel.placard.md +20 -0
  224. package/src/ui/design-system/components/showcase/TweaksPanel.tsx +230 -0
  225. package/src/ui/design-system/components/showcase/Wordmark.case.tsx +42 -0
  226. package/src/ui/design-system/components/showcase/Wordmark.css +31 -0
  227. package/src/ui/design-system/components/showcase/Wordmark.placard.md +10 -0
  228. package/src/ui/design-system/components/showcase/Wordmark.test.tsx +22 -0
  229. package/src/ui/design-system/components/showcase/Wordmark.tsx +22 -0
  230. package/src/ui/design-system/primer-specimens/brand.tsx +26 -0
  231. package/src/ui/design-system/primer-specimens/colors.tsx +83 -0
  232. package/src/ui/design-system/primer-specimens/components.tsx +308 -0
  233. package/src/ui/design-system/primer-specimens/foundations.tsx +71 -0
  234. package/src/ui/design-system/primer-specimens/index.ts +25 -0
  235. package/src/ui/design-system/primer-specimens/showcase.tsx +68 -0
  236. package/src/ui/design-system/primer-specimens/spacing.tsx +101 -0
  237. package/src/ui/design-system/primer-specimens/type.tsx +75 -0
  238. package/src/ui/design-system/primer.mdx +236 -0
  239. package/src/ui/design-system/styles.css +14 -0
  240. package/src/ui/design-system/tokens/colors.css +172 -0
  241. package/src/ui/design-system/tokens/fonts.css +18 -0
  242. package/src/ui/design-system/tokens/spacing.css +48 -0
  243. package/src/ui/design-system/tokens/typography.css +49 -0
  244. package/src/ui/markdown.test.tsx +54 -0
  245. package/src/ui/markdown.tsx +19 -0
  246. package/src/ui/primer-mount.tsx +76 -0
  247. package/src/ui/primer.css +175 -0
  248. package/src/ui/primer.tsx +277 -0
  249. package/src/ui/render-mount.tsx +284 -0
  250. package/src/ui/shell-core.test.ts +340 -0
  251. package/src/ui/shell-core.ts +295 -0
  252. package/src/ui/shell.tsx +60 -0
  253. package/src/ui/test-ids.ts +53 -0
  254. package/src/ui/use-shell.ts +1230 -0
@@ -0,0 +1,1230 @@
1
+ import type {
2
+ CSSProperties,
3
+ KeyboardEvent as ReactKeyboardEvent,
4
+ PointerEvent as ReactPointerEvent,
5
+ RefCallback,
6
+ } from 'react'
7
+ import { useCallback, useEffect, useMemo, useRef, useState } from 'react'
8
+ import type { Manifest, ManifestComponent } from '../core/manifest'
9
+ import type { A11yViolation } from '../index'
10
+ import {
11
+ buildAddressUrl,
12
+ buildRenderSrc,
13
+ buildUrl,
14
+ DEVICES,
15
+ DOC_DEFAULT_W,
16
+ DOC_MAX_W,
17
+ DOC_MIN_W,
18
+ GRID,
19
+ gridPad,
20
+ groupByLevel,
21
+ groupPrimerSections,
22
+ initialSelectionFor,
23
+ MIN_PAD,
24
+ MODE_FADE_MS,
25
+ NAV_COLLAPSE_MAX,
26
+ type ParsedRoute,
27
+ type PrimerGroup,
28
+ type PrimerSection,
29
+ parseLocation,
30
+ primerForLocation,
31
+ RESPONSIVE,
32
+ resolveMode,
33
+ type Selection,
34
+ STAGE_FADE_MS,
35
+ selSignature,
36
+ type Theme,
37
+ } from './shell-core'
38
+ import { DcTestIds } from './test-ids'
39
+
40
+ /** The accessibility audit results the chrome surfaces — per-variant nav markers
41
+ * across the library plus the active variant's verdict. The whole field is only
42
+ * present when a11y scanning is configured; when it's absent the chrome shows no
43
+ * markers and no panel at all (see {@link ShellViewModel.a11y}). */
44
+ export interface A11ySurface {
45
+ /** componentId → (caseId → that variant's WCAG violation count). Violations
46
+ * belong to a *variant* (each `/render/<component>/<case>` is audited on its
47
+ * own), so this is keyed per case. The nav rail folds it: a collapsed
48
+ * component shows the sum across its variants; expanded, the per-variant
49
+ * counts move onto the case rows and the parent shows a plain marker.
50
+ * Absent components / all-zero variants render no marker. A still-scanning
51
+ * case simply has no entry yet (its marker appears once the scan lands). */
52
+ byVariant: Record<string, Record<string, number>>
53
+ /** The active variant's verdict for the stage panel: `'pending'` while its
54
+ * scan is in flight, `'unavailable'` when the scan prerequisite can't run, an
55
+ * empty array for a clean pass, or the violations.
56
+ * (There is no "not audited" value — the panel only renders when a11y is
57
+ * configured, and a configured, viewed variant is always pending-or-resolved.) */
58
+ current: A11yViolation[] | 'pending' | 'unavailable'
59
+ /** How the resolved verdict should animate in: `'cascade'` when it just
60
+ * resolved from a live scan (the user watched "Scanning…"), `'all'` when it
61
+ * came straight from cache (already scanned — fade in at once). */
62
+ reveal?: 'cascade' | 'all'
63
+ }
64
+
65
+ // The view model the chrome's pure {@link ShellView} renders. Everything the
66
+ // chrome needs to draw a frame is here — state, derived layout numbers, event
67
+ // handlers and the imperative refs the live iframes attach to. `useShell`
68
+ // produces the live one (state + effects + the render-frame handshake); a case
69
+ // can hand-build a static one to exhibit the chrome as a page/flow.
70
+ export interface ShellViewModel {
71
+ manifest: Manifest
72
+ theme: Theme
73
+ setTheme: (update: (t: Theme) => Theme) => void
74
+ navCollapsed: boolean
75
+ setNavCollapsed: (update: (c: boolean) => boolean) => void
76
+
77
+ // Primer ↔ Cases mode.
78
+ mode: 'primer' | 'library'
79
+ setMode: (m: 'primer' | 'library') => void
80
+ shownMode: 'primer' | 'library'
81
+ modeFadeStyle: CSSProperties
82
+
83
+ // Device toolbar / zoom / grid.
84
+ sizeId: string
85
+ setSizeId: (id: string) => void
86
+ manualZoom: number
87
+ setManualZoom: (update: (z: number) => number) => void
88
+ showGrid: boolean
89
+ setShowGrid: (update: (g: boolean) => boolean) => void
90
+ widthInputValue: number | ''
91
+ fixed: { w: number; h: number } | null
92
+ fitted: boolean
93
+ scale: number
94
+ sizeMeta: string
95
+ editDim: (axis: 'w' | 'h', raw: string) => void
96
+ rotateDims: () => void
97
+
98
+ // The exhibit on the stage.
99
+ stageDecor: boolean
100
+ component: ManifestComponent | null
101
+ activeCase: ManifestComponent['cases'][number] | null
102
+ shownSel: Selection | null
103
+ sel: Selection | null
104
+ /** The exhibit's shareable standalone-snapshot address (origin-prefixed). Held
105
+ * on the model — not derived in the view — so the pure view never reads
106
+ * `window`, and an exhibit can supply a deterministic address. */
107
+ addressUrl: string
108
+
109
+ // Docs panel.
110
+ docOpen: boolean
111
+ changeDocsOpen: (open: boolean) => void
112
+ docText: string | null
113
+ docWidth: number
114
+ startDocResize: (e: ReactPointerEvent<HTMLDivElement>) => void
115
+ onDocResizeKey: (e: ReactKeyboardEvent<HTMLDivElement>) => void
116
+
117
+ // Tweaks panel.
118
+ tweaksFloating: boolean
119
+ setTweaksFloating: (update: (f: boolean) => boolean) => void
120
+
121
+ // Accessibility audit surface. Optional — absent until live audits are wired
122
+ // into the running chrome; a page/template exhibit supplies it to demonstrate
123
+ // the surfacing. `byVariant` drives the nav-rail markers (a component is
124
+ // discoverable as having issues without selecting it — summed when collapsed
125
+ // or a single-case leaf, per-variant when expanded); `current` lists the
126
+ // active variant's violations in the stage's a11y panel.
127
+ a11y?: A11ySurface
128
+ /** Force a fresh audit of the viewed variant (the panel's "re-scan" control).
129
+ * Absent in a static exhibit unless it wires its own. */
130
+ rescanA11y?: () => void
131
+
132
+ // Nav rail (scroll-fade refs + library tree).
133
+ navScrollRef: RefCallback<HTMLDivElement> | { current: HTMLDivElement | null }
134
+ navBodyRef: RefCallback<HTMLDivElement> | { current: HTMLDivElement | null }
135
+ groups: ReturnType<typeof groupByLevel>
136
+ expanded: Set<string>
137
+ toggleExpanded: (id: string) => void
138
+ selectComponent: (c: ManifestComponent) => void
139
+ select: (next: Selection) => void
140
+
141
+ // Nav rail (primer table of contents).
142
+ primerGroups: PrimerGroup[]
143
+ primerActive: string
144
+ primerExpanded: Set<string>
145
+ togglePrimerGroup: (id: string) => void
146
+ scrollToSection: (id: string) => void
147
+
148
+ // Stage geometry.
149
+ attachPreview: RefCallback<HTMLDivElement>
150
+ padX: number
151
+ padY: number
152
+ stageShown: boolean
153
+ /** True for the duration of a navigation crossfade (fade-out through fade-in).
154
+ * The a11y panel uses it to hard-switch its verdict colour while faded rather
155
+ * than easing the previous exhibit's colour across the fade. */
156
+ colorSnap?: boolean
157
+ boxW: number
158
+ boxH: number
159
+
160
+ // Live-frame plumbing (the container builds the iframes from these; a case
161
+ // ignores them and supplies a static stage/primer slot instead).
162
+ frameRef: { current: HTMLIFrameElement | null }
163
+ frameSrc: string | null
164
+ targetW: number
165
+ renderH: number
166
+ primerRef: { current: HTMLIFrameElement | null }
167
+ primerSrc: string | null
168
+ }
169
+
170
+ /**
171
+ * The browse chrome's state machine: manifest loading, the address ↔ selection
172
+ * sync, the stage crossfade + sizing math, the docs panel, and the render/
173
+ * primer frame handshakes. It owns every hook so {@link ShellView} can stay a
174
+ * pure function of the {@link ShellViewModel} it returns — which is also what
175
+ * lets the chrome be exhibited as a page/flow from a static, hand-built model.
176
+ *
177
+ * The shell is seeded: the server renders it from the in-memory manifest + the
178
+ * request route + theme, and the client hydrates from the same seed (the inlined
179
+ * manifest + the live address), so the render-affecting initial state is
180
+ * deterministic on both sides. Measured values (panel/content size, frame src)
181
+ * start at constants and update in effects after hydration.
182
+ */
183
+ export interface ShellSeed {
184
+ manifest: Manifest
185
+ route: ParsedRoute
186
+ theme: Theme
187
+ /** Whether live a11y surfacing is configured (drives nav markers + panel). */
188
+ a11y: boolean
189
+ }
190
+
191
+ export function useShell(seed: ShellSeed): ShellViewModel | { manifest: null } {
192
+ // Initial selection + mode are derived from the seed (route + manifest), not
193
+ // from `window`, so the server render and the client hydration agree.
194
+ const seedSel = initialSelectionFor(seed.manifest, seed.route)
195
+ const seedMode = resolveMode(seed.route, seed.manifest)
196
+ const [manifest, setManifest] = useState<Manifest | null>(seed.manifest)
197
+ // Read in the popstate listener (a `[]`-deps effect) to decide whether `/`
198
+ // means the Primer — without resubscribing the listener on every manifest change.
199
+ const manifestRef = useRef(manifest)
200
+ manifestRef.current = manifest
201
+ const [sel, setSel] = useState<Selection | null>(seedSel)
202
+ const [theme, setTheme] = useState<Theme>(seed.theme)
203
+ // Page origin for absolute shareable addresses. Empty during the server render
204
+ // and the client's first render (so they match); filled in after hydration.
205
+ const [origin, setOrigin] = useState('')
206
+ useEffect(() => {
207
+ setOrigin(window.location.origin)
208
+ }, [])
209
+
210
+ // Live accessibility surfacing. Whether scanning is configured is part of the
211
+ // seed (the server knows; the client gets the same value inlined), so the
212
+ // markers/panel render identically on both sides. Results come from `/a11y`
213
+ // (per viewed variant) and are pushed in over the SSE stream as scans complete.
214
+ const dcConfig = (
215
+ globalThis as {
216
+ __displayCase?: { reload?: boolean; a11y?: boolean; dev?: boolean }
217
+ }
218
+ ).__displayCase
219
+ const a11yEnabled = seed.a11y
220
+ const [a11y, setA11y] = useState<A11ySurface | undefined>(
221
+ a11yEnabled ? { byVariant: {}, current: 'pending' } : undefined,
222
+ )
223
+ // The variant the panel currently reflects, so a late-arriving scan for a
224
+ // variant the viewer has since left updates only the nav markers, not the panel.
225
+ const a11yCurRef = useRef<{ c?: string; cs?: string; th?: string }>({})
226
+
227
+ const applyA11yResult = useCallback(
228
+ (
229
+ c: string,
230
+ cs: string,
231
+ th: string,
232
+ res: {
233
+ status: 'ok' | 'pending' | 'unavailable'
234
+ violations?: A11yViolation[]
235
+ reason?: string
236
+ },
237
+ // True when this result came from a live scan completing (the SSE push),
238
+ // false when it came straight from the cache (the fetch response).
239
+ fromScan: boolean,
240
+ ) => {
241
+ setA11y((prev) => {
242
+ const byVariant = { ...(prev?.byVariant ?? {}) }
243
+ if (res.status === 'ok') {
244
+ byVariant[c] = {
245
+ ...(byVariant[c] ?? {}),
246
+ [cs]: res.violations?.length ?? 0,
247
+ }
248
+ }
249
+ const cur = a11yCurRef.current
250
+ const isCurrent = c === cur.c && cs === cur.cs && th === cur.th
251
+ let current = prev?.current ?? 'pending'
252
+ let reveal = prev?.reveal ?? 'all'
253
+ if (isCurrent) {
254
+ if (res.status === 'ok') {
255
+ current = res.violations ?? []
256
+ // Cascade only when the user watched it scan; a cache hit fades in.
257
+ reveal = fromScan ? 'cascade' : 'all'
258
+ } else current = res.status
259
+ }
260
+ return { byVariant, current, reveal }
261
+ })
262
+ },
263
+ [],
264
+ )
265
+
266
+ const requestA11y = useCallback(
267
+ (c: string, cs: string, th: string, force?: boolean) => {
268
+ a11yCurRef.current = { c, cs, th }
269
+ // A forced re-scan always runs a real scan, so show "Scanning…" at once.
270
+ // A plain view lets the response decide: a cache hit resolves straight to
271
+ // the result (faded in), only a real scan shows "Scanning…" then cascades.
272
+ if (force) {
273
+ setA11y((prev) => ({
274
+ byVariant: prev?.byVariant ?? {},
275
+ current: 'pending',
276
+ reveal: prev?.reveal ?? 'all',
277
+ }))
278
+ }
279
+ const rescan = force ? '&rescan=1' : ''
280
+ fetch(
281
+ `/a11y?component=${encodeURIComponent(c)}&case=${encodeURIComponent(cs)}&theme=${th}${rescan}`,
282
+ )
283
+ .then((r) => r.json())
284
+ .then((res) => applyA11yResult(c, cs, th, res, false))
285
+ .catch(() => {})
286
+ },
287
+ [applyA11yResult],
288
+ )
289
+
290
+ // The panel's "re-scan" affordance: force a fresh audit of the viewed variant.
291
+ const rescanA11y = useCallback(() => {
292
+ const cur = a11yCurRef.current
293
+ if (cur.c && cur.cs && cur.th) requestA11y(cur.c, cur.cs, cur.th, true)
294
+ }, [requestA11y])
295
+ // Selected size: a responsive/device preset id, or 'custom' (uses `custom`).
296
+ const [sizeId, setSizeId] = useState<string>('full')
297
+ const [custom, setCustom] = useState<{ w: number; h: number }>({
298
+ w: 1280,
299
+ h: 800,
300
+ })
301
+ const [manualZoom, setManualZoom] = useState(1)
302
+ // Stage backdrop for decorated components: the dotted grid (default) or the
303
+ // consumer app's own background colour (`--color-bg`, the same the iframe uses).
304
+ const [showGrid, setShowGrid] = useState(true)
305
+ // Measured inner size of the preview panel, for fit-to-panel scaling.
306
+ const [panel, setPanel] = useState<{ w: number; h: number }>({
307
+ w: 0,
308
+ h: 0,
309
+ })
310
+ const previewObserver = useRef<ResizeObserver | null>(null)
311
+ // Natural size of the rendered component, reported by the render frame. In
312
+ // Responsive mode a decorated component is sized to its own height (and
313
+ // centered on the grid) instead of stretching to fill the panel.
314
+ const [content, setContent] = useState<{ w: number; h: number } | null>(null)
315
+ // The selection the stage currently *displays*. It trails `sel` by one
316
+ // fade-out so the iframe content, size, and mode all swap while the stage is
317
+ // hidden — a clean crossfade rather than a mid-flight jump. The sidebar tracks
318
+ // `sel` directly (instant highlight); only the exhibit waits.
319
+ const [shownSel, setShownSel] = useState<Selection | null>(seedSel)
320
+ const shownSelRef = useRef(shownSel)
321
+ shownSelRef.current = shownSel
322
+ const selRef = useRef(sel)
323
+ selRef.current = sel
324
+ const selSig = sel ? selSignature(sel) : ''
325
+ // Drives the stage opacity. Starts hidden so the first exhibit fades in once
326
+ // measured; flipped false on navigation (fade out) and true once the shown
327
+ // exhibit has caught up and reported its size (fade in).
328
+ const [stageShown, setStageShown] = useState(false)
329
+ // True while a navigation crossfade is in flight (fade-out start → fade-in
330
+ // end). The a11y panel snaps its verdict colour during this window so the new
331
+ // exhibit's colour is in place before it fades in, instead of easing the old
332
+ // colour across the fade.
333
+ const [colorSnap, setColorSnap] = useState(true)
334
+ // Signature of the exhibit the render frame last reported a size for. The
335
+ // stage only fades in once this matches what's shown, so a swap never reveals
336
+ // at the wrong size.
337
+ const [measuredSig, setMeasuredSig] = useState('')
338
+ const [docOpen, setDocOpen] = useState(seed.route.docs)
339
+ // Kept current so the stable `select` callback can preserve the docs flag in
340
+ // the address across navigations without taking `docOpen` as a dependency.
341
+ const docOpenRef = useRef(docOpen)
342
+ docOpenRef.current = docOpen
343
+ const [docText, setDocText] = useState<string | null>(null)
344
+ const [docWidth, setDocWidth] = useState(DOC_DEFAULT_W)
345
+ // Tweaks panel can be undocked into a free-floating, draggable overlay.
346
+ const [tweaksFloating, setTweaksFloating] = useState(false)
347
+ // Starts expanded deterministically (the server has no viewport width); an
348
+ // effect collapses it on a narrow viewport after mount, so hydration matches.
349
+ const [navCollapsed, setNavCollapsed] = useState(false)
350
+ // Which components are expanded in the nav. Collapsed by default; the
351
+ // initially-selected component is seeded open so its active case is visible.
352
+ const [expanded, setExpanded] = useState<Set<string>>(
353
+ () => new Set(seedSel.componentId ? [seedSel.componentId] : []),
354
+ )
355
+ const frameRef = useRef<HTMLIFrameElement | null>(null)
356
+ // The iframe loads once at a fixed src; every later change (case, theme,
357
+ // width, tweaks) is pushed in via postMessage so it never reloads/flickers.
358
+ const [frameSrc, setFrameSrc] = useState<string | null>(null)
359
+ const [frameReady, setFrameReady] = useState(false)
360
+
361
+ // ── Primer (the optional .mdx reading page) ──────────────────────────────
362
+ // Which sidebar view is active. The Primer, when configured, is the default
363
+ // landing view (it orients you before you browse) — but a deep link to a case
364
+ // opens straight into the library.
365
+ const [mode, setMode] = useState<'primer' | 'library'>(seedMode)
366
+ // The mode actually on screen. `mode` is the target (drives the mode-switch
367
+ // highlight box, which lerps to it instantly); `shownMode` lags by one fade so
368
+ // the nav, screen content, and header controls swap while hidden — a crossfade
369
+ // rather than a hard cut. Mirrors the `sel`/`shownSel` stage pattern above.
370
+ const [shownMode, setShownMode] = useState<'primer' | 'library'>(seedMode)
371
+ const shownModeRef = useRef(shownMode)
372
+ shownModeRef.current = shownMode
373
+ // Drives the opacity of the faded regions: true = shown, false = mid-swap.
374
+ const [modeContentShown, setModeContentShown] = useState(true)
375
+ // The nav scroll region — an inner wrapper, so the rail's right border (on the
376
+ // outer nav) and the pinned mode switch above it are never faded. Its native
377
+ // scrollbar is hidden (see chrome.css); a soft gradient fade at whichever edge
378
+ // has more content off-screen is the affordance instead. `data-fade-top` /
379
+ // `data-fade-bottom` toggle the mask; this recomputes them from scroll position.
380
+ const navScrollRef = useRef<HTMLDivElement | null>(null)
381
+ const navBodyRef = useRef<HTMLDivElement | null>(null)
382
+ const updateNavFade = useCallback(() => {
383
+ const el = navScrollRef.current
384
+ if (!el) return
385
+ const max = el.scrollHeight - el.clientHeight
386
+ const T = 4 // px slack so a resting top/bottom reads as fully docked
387
+ el.setAttribute('data-fade-top', el.scrollTop > T ? 'true' : 'false')
388
+ el.setAttribute(
389
+ 'data-fade-bottom',
390
+ el.scrollTop < max - T ? 'true' : 'false',
391
+ )
392
+ }, [])
393
+ // The Primer renders in its own isolated iframe (like /render), created lazily
394
+ // the first time the Primer view is opened.
395
+ const primerRef = useRef<HTMLIFrameElement | null>(null)
396
+ const [primerSrc, setPrimerSrc] = useState<string | null>(null)
397
+ const [primerReady, setPrimerReady] = useState(false)
398
+ // Section table-of-contents + active section, reported by the Primer frame.
399
+ const [primerSections, setPrimerSections] = useState<PrimerSection[]>([])
400
+ const [primerActive, setPrimerActive] = useState('')
401
+ // Heading ids the reader has expanded in the table of contents. Tracking the
402
+ // open set (not the closed one) makes groups collapsed by default — the ids
403
+ // arrive asynchronously, so there's nothing to pre-seed a "collapsed" set with.
404
+ const [primerExpanded, setPrimerExpanded] = useState<Set<string>>(
405
+ () => new Set(),
406
+ )
407
+ // Accordion: at most one TOC group is open. Expanding a group collapses any
408
+ // other; clicking the open group's chevron closes it (leaving all closed).
409
+ const togglePrimerGroup = useCallback((id: string) => {
410
+ setPrimerExpanded((prev) => (prev.has(id) ? new Set() : new Set([id])))
411
+ }, [])
412
+
413
+ // The manifest, selection, and mode are seeded at init (the server renders from
414
+ // them and the client hydrates from the same seed), so there is no initial
415
+ // fetch here. The live-reload refresh below still refetches `/manifest.json` to
416
+ // pick up catalog changes without a full reload.
417
+ //
418
+ // Collapse the nav on a narrow viewport once mounted. It starts expanded (the
419
+ // server has no viewport width); collapsing in an effect runs only on the
420
+ // client, after hydration, so the first render matches on both sides.
421
+ useEffect(() => {
422
+ if (window.innerWidth <= NAV_COLLAPSE_MAX) setNavCollapsed(true)
423
+ }, [])
424
+
425
+ // Crossfade the chrome when the view mode changes. The highlight box lerps to
426
+ // `mode` immediately (CSS); everything that swaps content — nav, screen, the
427
+ // mode-specific header controls — fades out first, swaps `shownMode` while
428
+ // hidden, then fades back in (two rAFs so the swapped-in view paints at opacity
429
+ // 0 before the transition to 1). Keyed on `mode` only; `shownMode` is read
430
+ // through a ref so a rapid toggle-back mid-fade just fades the current view
431
+ // back in (the pending swap is cancelled) instead of restarting.
432
+ useEffect(() => {
433
+ if (mode === shownModeRef.current) {
434
+ setModeContentShown(true)
435
+ return
436
+ }
437
+ setModeContentShown(false) // fade out
438
+ let raf1 = 0
439
+ let raf2 = 0
440
+ const swap = setTimeout(() => {
441
+ setShownMode(mode) // swap while hidden
442
+ raf1 = requestAnimationFrame(() => {
443
+ raf2 = requestAnimationFrame(() => setModeContentShown(true)) // fade in
444
+ })
445
+ }, MODE_FADE_MS)
446
+ return () => {
447
+ clearTimeout(swap)
448
+ cancelAnimationFrame(raf1)
449
+ cancelAnimationFrame(raf2)
450
+ }
451
+ }, [mode])
452
+
453
+ // Keep the nav's scroll-fade in sync: on scroll, when the container is resized
454
+ // (the sidebar's height tracks the window), and when its content height changes
455
+ // — expand/collapse, a mode swap, the primer TOC loading — which a
456
+ // ResizeObserver on the content body catches without listing state deps.
457
+ useEffect(() => {
458
+ // Gate on `manifest`: until it loads, the chrome renders a loading screen and
459
+ // the nav isn't mounted, so re-run once it is (the ref is null before then).
460
+ if (!manifest) return
461
+ const el = navScrollRef.current
462
+ if (!el) return
463
+ updateNavFade()
464
+ el.addEventListener('scroll', updateNavFade, { passive: true })
465
+ const ro = new ResizeObserver(updateNavFade)
466
+ ro.observe(el)
467
+ if (navBodyRef.current) ro.observe(navBodyRef.current)
468
+ return () => {
469
+ el.removeEventListener('scroll', updateNavFade)
470
+ ro.disconnect()
471
+ }
472
+ }, [manifest, updateNavFade])
473
+
474
+ // Lazily mount the Primer frame the first time its view is opened, seeding the
475
+ // theme into the URL so the initial paint is correct; later theme changes are
476
+ // pushed in via postMessage (below) so it never reloads.
477
+ useEffect(() => {
478
+ if (mode === 'primer' && !primerSrc) {
479
+ setPrimerSrc(`/render/primer?theme=${theme}`)
480
+ }
481
+ }, [mode, primerSrc, theme])
482
+
483
+ // Receive the Primer frame's section list, active section, and readiness.
484
+ useEffect(() => {
485
+ function onMessage(e: MessageEvent) {
486
+ if (e.source !== primerRef.current?.contentWindow) return
487
+ const data = e.data as {
488
+ type?: string
489
+ sections?: PrimerSection[]
490
+ id?: string
491
+ }
492
+ if (data?.type === 'dc-primer-ready') setPrimerReady(true)
493
+ else if (data?.type === 'dc-primer-sections' && data.sections)
494
+ setPrimerSections(data.sections)
495
+ else if (data?.type === 'dc-primer-active' && data.id)
496
+ setPrimerActive(data.id)
497
+ }
498
+ window.addEventListener('message', onMessage)
499
+ return () => window.removeEventListener('message', onMessage)
500
+ }, [])
501
+
502
+ // Keep the Primer frame's theme in step with the chrome's.
503
+ useEffect(() => {
504
+ if (!primerReady) return
505
+ primerRef.current?.contentWindow?.postMessage(
506
+ { type: 'dc-primer-theme', theme },
507
+ '*',
508
+ )
509
+ }, [theme, primerReady])
510
+
511
+ // Scroll the Primer to a section when its TOC entry is clicked. Setting the
512
+ // active section immediately drives the accordion (see the effect below) so
513
+ // the clicked group opens without waiting for the scroll to settle.
514
+ const scrollToSection = useCallback((id: string) => {
515
+ setPrimerActive(id)
516
+ primerRef.current?.contentWindow?.postMessage(
517
+ { type: 'dc-primer-scroll', id },
518
+ '*',
519
+ )
520
+ }, [])
521
+
522
+ const select = useCallback((next: Selection) => {
523
+ setSel(next)
524
+ window.history.pushState(
525
+ null,
526
+ '',
527
+ buildUrl(next.componentId, next.caseId, next.tweaks, docOpenRef.current),
528
+ )
529
+ }, [])
530
+
531
+ // Switch between the Primer and the library, reflecting the mode in the
532
+ // address so the boundary is a real navigation step: back/forward cross it and
533
+ // a copied link reopens the same view. The Primer lives at `/primer`; the
534
+ // library at the *remembered* case address — so toggling back to Cases returns
535
+ // you to the exhibit you were last on (held in `sel`, never unmounted) rather
536
+ // than a reset. `setMode` then runs the crossfade as before.
537
+ const changeMode = useCallback((m: 'primer' | 'library') => {
538
+ setMode(m)
539
+ const s = selRef.current
540
+ const url =
541
+ m === 'primer' || !s
542
+ ? '/primer'
543
+ : buildUrl(s.componentId, s.caseId, s.tweaks, docOpenRef.current)
544
+ window.history.pushState(null, '', url)
545
+ }, [])
546
+
547
+ // Open/close the docs panel and reflect it in the address (replaceState, so a
548
+ // toggle isn't a back-button step) so the open panel is deep-linkable.
549
+ const changeDocsOpen = useCallback(
550
+ (open: boolean) => {
551
+ setDocOpen(open)
552
+ if (!sel) return
553
+ window.history.replaceState(
554
+ null,
555
+ '',
556
+ buildUrl(sel.componentId, sel.caseId, sel.tweaks, open),
557
+ )
558
+ },
559
+ [sel],
560
+ )
561
+
562
+ // Browser back/forward only changes the address; this is the read side that
563
+ // applies it back to state. `select`/`changeDocsOpen` are the write side
564
+ // (they pushState/replaceState), so here we set state directly — never push a
565
+ // new entry, or back/forward would fight history.
566
+ useEffect(() => {
567
+ const onPop = () => {
568
+ const loc = parseLocation()
569
+ if (loc.componentId) {
570
+ setSel({
571
+ componentId: loc.componentId,
572
+ caseId: loc.caseId,
573
+ tweaks: loc.tweaks,
574
+ })
575
+ setMode('library')
576
+ } else {
577
+ // Non-case address: `/primer` is the Primer; the bare `/` honors the
578
+ // configured landing (Primer unless `landing: 'cases'`). The library
579
+ // `sel` is left untouched so a later toggle back to Cases still restores
580
+ // it.
581
+ const m = manifestRef.current
582
+ setMode(m && primerForLocation(m) ? 'primer' : 'library')
583
+ }
584
+ setDocOpen(loc.docs)
585
+ }
586
+ window.addEventListener('popstate', onPop)
587
+ return () => window.removeEventListener('popstate', onPop)
588
+ }, [])
589
+
590
+ // Toggle a component's case list open/closed without navigating.
591
+ const toggleExpanded = useCallback((id: string) => {
592
+ setExpanded((prev) => {
593
+ const next = new Set(prev)
594
+ if (next.has(id)) next.delete(id)
595
+ else next.add(id)
596
+ return next
597
+ })
598
+ }, [])
599
+
600
+ // Navigate to a component's first case. Collapse every other accordion so only
601
+ // this component stays open (and reopen it if it was manually collapsed).
602
+ const selectComponent = useCallback(
603
+ (c: ManifestComponent) => {
604
+ const first = c.cases[0]
605
+ if (!first) return
606
+ select({ componentId: c.id, caseId: first.id, tweaks: {} })
607
+ setExpanded(new Set([c.id]))
608
+ },
609
+ [select],
610
+ )
611
+
612
+ // Whenever the selected component changes — by any path: a component click, a
613
+ // case click in another component, a flow `goto`, or a deep-link — collapse
614
+ // every other accordion so only the active component stays open. Keyed on the
615
+ // component id, so manual chevron toggles between navigations are preserved
616
+ // (they don't change the id and so don't re-fire this).
617
+ const selectedComponentId = sel?.componentId
618
+ useEffect(() => {
619
+ if (!selectedComponentId) return
620
+ setExpanded(new Set([selectedComponentId]))
621
+ }, [selectedComponentId])
622
+
623
+ // Keep the active nav row on screen. A deep link can land straight on a
624
+ // component far down the rail — taller than the viewport — leaving its
625
+ // highlighted row scrolled out of view. Re-run on the selection (the row's
626
+ // `data-current` follows `sel`), on `expanded` (a case row only mounts once
627
+ // its component is open, so the first pass may not find it yet), and on
628
+ // `shownMode` (the library nav isn't mounted in Primer view). The first
629
+ // reveal centers the row so it clears the rail's edge fade mask; afterwards
630
+ // `block: 'nearest'` keeps an off-screen selection in view without yanking
631
+ // rows that are already visible, so an ordinary click never jumps the rail.
632
+ const didInitialNavScrollRef = useRef(false)
633
+ // biome-ignore lint/correctness/useExhaustiveDependencies: `expanded` isn't read here but a case row only mounts once its component is expanded, so re-run to find it
634
+ useEffect(() => {
635
+ if (shownMode !== 'library') return
636
+ const container = navScrollRef.current
637
+ if (!container || !sel?.componentId) return
638
+ const id = sel.caseId
639
+ ? DcTestIds.navCase(sel.componentId, sel.caseId)
640
+ : DcTestIds.navComponent(sel.componentId)
641
+ const raf = requestAnimationFrame(() => {
642
+ const el = container.querySelector<HTMLElement>(`[data-testid="${id}"]`)
643
+ if (!el) return
644
+ el.scrollIntoView({
645
+ block: didInitialNavScrollRef.current ? 'nearest' : 'center',
646
+ })
647
+ didInitialNavScrollRef.current = true
648
+ })
649
+ return () => cancelAnimationFrame(raf)
650
+ }, [sel?.componentId, sel?.caseId, expanded, shownMode])
651
+
652
+ // Drop the previous exhibit's measured size when the *shown* case changes, so
653
+ // the new one is sized from its own report. Keyed on `shownSel` (not `sel`) so
654
+ // it fires at the swap point — while the stage is hidden — not when the user
655
+ // first clicks.
656
+ // biome-ignore lint/correctness/useExhaustiveDependencies: reset keyed on shown selection
657
+ useEffect(() => {
658
+ setContent(null)
659
+ }, [shownSel?.componentId, shownSel?.caseId])
660
+
661
+ // Crossfade controller. When the *exhibit* (component or case) changes, fade
662
+ // the stage out, then — once it's hidden — swap `shownSel` to the new
663
+ // selection so the iframe content, size, and mode all change behind the fade.
664
+ // The reveal effect (below) fades it back in once measured.
665
+ //
666
+ // A tweak-only change keeps the same exhibit: the frame retweaks in place via
667
+ // postMessage (see the push effect below), so we swap `shownSel` immediately
668
+ // with no fade — adjusting a knob shouldn't blink the stage.
669
+ useEffect(() => {
670
+ const next = selRef.current
671
+ const shown = shownSelRef.current
672
+ if (!next || !shown) return
673
+ // Already in lock-step (initial mount, a flow's in-place goto, or a theme
674
+ // toggle that left the selection untouched): nothing to do.
675
+ if (selSignature(shown) === selSig) return
676
+ const sameExhibit =
677
+ shown.componentId === next.componentId && shown.caseId === next.caseId
678
+ if (sameExhibit) {
679
+ // Tweak-only change: swap in place, no crossfade.
680
+ setShownSel(next)
681
+ return
682
+ }
683
+ setStageShown(false) // fade out; `shownSel` holds so the box keeps its size
684
+ setColorSnap(true) // snap the a11y colour while faded — see the fade-in effect
685
+ const id = setTimeout(() => setShownSel(next), STAGE_FADE_MS)
686
+ return () => clearTimeout(id)
687
+ }, [selSig])
688
+
689
+ const groups = useMemo(
690
+ () => groupByLevel(manifest?.components ?? []),
691
+ [manifest],
692
+ )
693
+
694
+ const primerGroups = useMemo(
695
+ () => groupPrimerSections(primerSections),
696
+ [primerSections],
697
+ )
698
+
699
+ // Keep the TOC accordion in step with the reading position. Whenever the
700
+ // active section changes — the first section reports active on load, then the
701
+ // scrollspy tracks it as the reader scrolls — open the group that owns it
702
+ // (heading or one of its Displays) and collapse the rest. A section in the
703
+ // leading headless group collapses every heading group. The functional update
704
+ // is a no-op while the active section stays within the open group, so a manual
705
+ // chevron toggle survives until the reader scrolls into a different group.
706
+ useEffect(() => {
707
+ if (!primerActive) return
708
+ const owner = primerGroups.find(
709
+ (g) =>
710
+ g.heading?.id === primerActive ||
711
+ g.items.some((it) => it.id === primerActive),
712
+ )
713
+ const headingId = owner?.heading?.id
714
+ setPrimerExpanded((prev) => {
715
+ const already = headingId
716
+ ? prev.size === 1 && prev.has(headingId)
717
+ : prev.size === 0
718
+ if (already) return prev
719
+ return headingId ? new Set([headingId]) : new Set()
720
+ })
721
+ }, [primerActive, primerGroups])
722
+
723
+ // The stage renders the *shown* selection (which trails `sel` across a fade),
724
+ // not `sel` itself — so the exhibit, its size, and its mode change together
725
+ // while hidden.
726
+ const component =
727
+ manifest?.components.find((c) => c.id === shownSel?.componentId) ?? null
728
+ const activeCase =
729
+ component?.cases.find((c) => c.id === shownSel?.caseId) ?? null
730
+
731
+ // The exhibit's shareable /render address (origin-prefixed). Computed here so
732
+ // the pure view doesn't reach for `window.location`. `origin` starts empty
733
+ // (matching the server render) and is filled in after hydration, so the copied
734
+ // address becomes absolute without a hydration mismatch.
735
+ const addressUrl = activeCase
736
+ ? buildAddressUrl(
737
+ activeCase.renderUrl,
738
+ theme,
739
+ shownSel?.tweaks ?? {},
740
+ origin,
741
+ )
742
+ : ''
743
+
744
+ // Full pages and flows are exhibited on a clean, framed stage; smaller
745
+ // component levels (atoms…templates, and unclassified) keep the vitrine
746
+ // dotted grid + corner ticks that help judge a component's edges.
747
+ const stageDecor = component?.level !== 'page' && !component?.isFlow
748
+
749
+ // Callback ref: (re)attach a ResizeObserver to the preview panel to track the
750
+ // space available for fit-to-panel scaling.
751
+ const attachPreview = useCallback((el: HTMLDivElement | null) => {
752
+ previewObserver.current?.disconnect()
753
+ if (!el) return
754
+ // Measure the *content* box (clientWidth/Height include padding); the frame
755
+ // must fit inside that, or it overflows by the padding and shows a scrollbar.
756
+ const measure = () => {
757
+ const cs = getComputedStyle(el)
758
+ const padX = parseFloat(cs.paddingLeft) + parseFloat(cs.paddingRight)
759
+ const padY = parseFloat(cs.paddingTop) + parseFloat(cs.paddingBottom)
760
+ // The Stage's caption strip sits inside the preview but above the body the
761
+ // frame-box lives in, so it steals vertical room the fit math must not
762
+ // count. Without reserving it, a fitted size (device W×H, or a responsive
763
+ // width whose content fills the height) scales to the full preview height,
764
+ // then the caption pushes the stage past the bottom — and `.dc-preview`'s
765
+ // `align-items: safe center` snaps the overflow to the top, jamming the
766
+ // exhibit above the corner ticks. Subtract its measured height (0 before it
767
+ // mounts) so the panel height reflects the space the body actually gets.
768
+ const caption = el.querySelector('.dcui-stage-caption')
769
+ const captionH = caption ? caption.getBoundingClientRect().height : 0
770
+ setPanel({
771
+ w: Math.max(0, Math.floor(el.clientWidth - padX)),
772
+ h: Math.max(0, Math.floor(el.clientHeight - padY - captionH)),
773
+ })
774
+ }
775
+ measure()
776
+ // Observe the preview for available-space changes, and the caption too so a
777
+ // font swap or wrap that changes its height re-runs the fit.
778
+ const ro = new ResizeObserver(measure)
779
+ ro.observe(el)
780
+ const caption = el.querySelector('.dcui-stage-caption')
781
+ if (caption) ro.observe(caption)
782
+ previewObserver.current = ro
783
+ }, [])
784
+
785
+ // Resolve the active sizing mode into concrete iframe dimensions + scale.
786
+ const responsive = RESPONSIVE.find((r) => r.id === sizeId)
787
+ const device = DEVICES.find((d) => d.id === sizeId)
788
+ let fixed: { w: number; h: number } | null = null
789
+ if (sizeId === 'custom') fixed = custom
790
+ else if (device) fixed = { w: device.w, h: device.h }
791
+
792
+ const responsiveWidth =
793
+ responsive && responsive.width !== 'full' ? responsive.width : null
794
+ // A "fitted" mode has fixed pixel dimensions that must auto-scale to stay
795
+ // fully on-screen: device presets (W×H — both axes) and the numbered
796
+ // responsive widths (Desktop/Tablet/Mobile — width only). Only "Responsive
797
+ // (full)" zooms manually.
798
+ const fitted = fixed !== null || responsiveWidth !== null
799
+
800
+ // In the default Responsive (full) view a decorated component shrink-wraps to
801
+ // its natural width — the render frame is told to `fit` — so a small component
802
+ // (a square button, a chip) doesn't stretch to fill the frame. Picking a
803
+ // preset/device width opts back into full-width layout for responsive testing.
804
+ const fitWidth = stageDecor && !fixed && responsiveWidth === null
805
+
806
+ // Fade the stage in once the shown exhibit has caught up to the selection and
807
+ // the render frame has reported *its* size (matched by signature, so an
808
+ // in-flight size report for the previous exhibit can't reveal early). This
809
+ // gate is what keeps a swap from flashing at the wrong size.
810
+ const stageMeasured =
811
+ shownSel != null && measuredSig === selSignature(shownSel)
812
+ useEffect(() => {
813
+ if (!shownSel) return
814
+ if (selSignature(shownSel) !== selSig) return // still mid-swap
815
+ if (stageMeasured) setStageShown(true)
816
+ }, [shownSel, selSig, stageMeasured])
817
+
818
+ // Release the a11y colour snap once the fade-in has finished, so in-place state
819
+ // changes (a scan resolving while the panel is visible) ease again. Keyed on
820
+ // the fade-in starting (stageShown → true); a new navigation re-arms the snap.
821
+ useEffect(() => {
822
+ if (!stageShown) return
823
+ const id = setTimeout(() => setColorSnap(false), STAGE_FADE_MS)
824
+ return () => clearTimeout(id)
825
+ }, [stageShown])
826
+
827
+ // Area available for the (scaled) component. A decorated component reserves at
828
+ // least a 1-dot margin (+1px border) on each side so a near-max component can
829
+ // give back padding for width; a page/flow uses the whole panel (edge-to-edge).
830
+ const reserve = stageDecor ? MIN_PAD + 1 : 0
831
+ const availW = Math.max(1, panel.w - 2 * reserve)
832
+ const availH = Math.max(1, panel.h - 2 * reserve)
833
+
834
+ // `renderH` is the iframe element's height — the viewport the component lays
835
+ // out against (kept = panel height in Responsive mode so `vh`/media queries
836
+ // stay stable and never feed back as the visible box shrinks). `visibleH` is
837
+ // how much of it the stage actually shows: a decorated component (atom…
838
+ // template) is clipped to its own measured height and centered on the grid,
839
+ // while pages/flows — and anything not yet measured — fill the panel.
840
+ let targetW: number
841
+ let renderH: number
842
+ let visibleH: number
843
+ if (fixed) {
844
+ targetW = fixed.w
845
+ renderH = fixed.h
846
+ visibleH = fixed.h
847
+ } else if (stageDecor) {
848
+ // Decorated: render at the preset width (or the available width), and clip
849
+ // to the component's own height so the frame hugs it (centered on the grid).
850
+ targetW = responsiveWidth ?? availW
851
+ renderH = panel.h
852
+ // Until the new case reports its size, collapse to nothing so the vitrine
853
+ // rests at its CSS min size (centered) rather than ballooning to fill the
854
+ // panel — a full-height box overflows by the caption strip and `safe center`
855
+ // snaps it to the top-left for a frame. It grows once `dc-size` lands.
856
+ visibleH = content && content.h > 0 ? Math.min(content.h, availH) : 0
857
+ } else {
858
+ // Page/flow: fill the frame edge-to-edge.
859
+ targetW = responsiveWidth ?? panel.w
860
+ renderH = panel.h
861
+ visibleH = panel.h
862
+ }
863
+
864
+ // Scale a fitted mode down to fit the available area (never up past 100%): a
865
+ // device fits both axes; a numbered responsive width fits horizontally (its
866
+ // height fills/conforms). Full responsive uses the manual zoom.
867
+ let scale = manualZoom
868
+ if (fixed) {
869
+ scale =
870
+ panel.w > 0 && panel.h > 0
871
+ ? Math.min(availW / fixed.w, availH / fixed.h, 1)
872
+ : 1
873
+ } else if (responsiveWidth !== null) {
874
+ scale = panel.w > 0 ? Math.min(availW / responsiveWidth, 1) : 1
875
+ }
876
+ // When fitting width, the iframe still renders at `targetW` (a stable viewport
877
+ // so `vw`/media queries don't shift), but the box clips to the component's own
878
+ // measured width so the frame hugs it horizontally — symmetric with `visibleH`.
879
+ let visibleW = targetW
880
+ if (fitWidth) {
881
+ // Measured: hug the content's width. Unmeasured: collapse to the vitrine's
882
+ // min width (centered) rather than full width — symmetric with `visibleH`.
883
+ visibleW = content && content.w > 0 ? Math.min(content.w, targetW) : 0
884
+ }
885
+ // Value for the width input: a fixed width, the responsive preset width, or
886
+ // blank (Full / auto).
887
+ const widthInputValue = fixed ? fixed.w : (responsiveWidth ?? '')
888
+ // The currently set size, for the stage caption meta: a named responsive
889
+ // preset (Responsive / Desktop / Tablet / Mobile), else a fixed W×H
890
+ // (device or custom).
891
+ let sizeMeta = fixed ? `${fixed.w} × ${fixed.h}` : 'Responsive'
892
+ if (responsive) sizeMeta = responsive.label
893
+ // The frame box occupies the *scaled* size so the panel can center it and
894
+ // scroll to every edge (no left-side cutoff when zoomed in). Ceil (not floor)
895
+ // so a fractional scaled dimension never crops the component's right/bottom
896
+ // edge — flooring shaved a sub-pixel row/column, cutting the last border.
897
+ //
898
+ // A decorated exhibit hugs its own measured size, so when the rightmost (or
899
+ // bottom) element's border sits *exactly* on the measured edge — e.g. a
900
+ // full-width `<input>` whose box-sizing border lands flush at `fit-content` —
901
+ // the clip seam coincides with that border and `overflow: hidden` eats it
902
+ // (ceil can't help: the value is already integral). A 1px guard on the hugged
903
+ // axes keeps the seam off the border without a visible gap (it falls inside
904
+ // the grid margin). Skipped for pages/flows, which fill the panel edge-to-edge
905
+ // and would overflow it by the guard.
906
+ const edgeGuard = stageDecor ? 1 : 0
907
+ const boxW = Math.max(1, Math.ceil(visibleW * scale) + edgeGuard)
908
+ // The box clips the iframe (which is `renderH` tall) to the *visible* height,
909
+ // hiding everything below the component so the grid shows through. Ceil (not
910
+ // floor) so a fractional scaled height never crops the component's bottom edge
911
+ // — flooring it shaved a sub-pixel row, cutting the last border.
912
+ const boxH = Math.max(1, Math.ceil(visibleH * scale) + edgeGuard)
913
+ // Grid margin around the exhibit, scaled to the spare room (1–3 dots) on each
914
+ // axis. Driven inline since it's dynamic; only decorated stages get it.
915
+ const padX = stageDecor ? gridPad(panel.w, boxW) : 0
916
+ const padY = stageDecor ? gridPad(panel.h, boxH) : 0
917
+
918
+ // Editing a dimension or rotating switches to a custom fixed size, seeded from
919
+ // the current dimensions so the untouched axis is preserved.
920
+ const editDim = (axis: 'w' | 'h', raw: string) => {
921
+ const value = Math.max(1, Math.round(Number(raw) || 0))
922
+ const base = fixed ?? { w: targetW || 1280, h: renderH || 800 }
923
+ setCustom({ ...base, [axis]: value })
924
+ setSizeId('custom')
925
+ }
926
+ const rotateDims = () => {
927
+ if (!fixed) return
928
+ setCustom({ w: fixed.h, h: fixed.w })
929
+ setSizeId('custom')
930
+ }
931
+
932
+ // Drag the doc panel's left edge to resize it. Dragging left (toward the
933
+ // content) widens it; the width is clamped and applied via a CSS variable so
934
+ // the narrow-screen stacking rule still wins.
935
+ const startDocResize = useCallback(
936
+ (e: ReactPointerEvent<HTMLDivElement>) => {
937
+ e.preventDefault()
938
+ const startX = e.clientX
939
+ const startW = docWidth
940
+ const onMove = (ev: PointerEvent) => {
941
+ const next = startW + (startX - ev.clientX)
942
+ setDocWidth(Math.max(DOC_MIN_W, Math.min(DOC_MAX_W, next)))
943
+ }
944
+ const onUp = () => {
945
+ window.removeEventListener('pointermove', onMove)
946
+ window.removeEventListener('pointerup', onUp)
947
+ document.body.style.cursor = ''
948
+ document.body.style.userSelect = ''
949
+ }
950
+ document.body.style.cursor = 'col-resize'
951
+ document.body.style.userSelect = 'none'
952
+ window.addEventListener('pointermove', onMove)
953
+ window.addEventListener('pointerup', onUp)
954
+ },
955
+ [docWidth],
956
+ )
957
+ // Keyboard resize: arrows nudge by one grid step (left widens, like the drag).
958
+ const onDocResizeKey = useCallback(
959
+ (e: ReactKeyboardEvent<HTMLDivElement>) => {
960
+ let step = 0
961
+ if (e.key === 'ArrowLeft') step = GRID
962
+ else if (e.key === 'ArrowRight') step = -GRID
963
+ if (!step) return
964
+ e.preventDefault()
965
+ setDocWidth((w) => Math.max(DOC_MIN_W, Math.min(DOC_MAX_W, w + step)))
966
+ },
967
+ [],
968
+ )
969
+
970
+ // Drive the token theme from the document root so html/body (not just the
971
+ // app chrome) pick up the themed background — no white bars around the app.
972
+ useEffect(() => {
973
+ document.documentElement.dataset.theme = theme
974
+ }, [theme])
975
+
976
+ // Capture a fixed initial src once a case is available; never change it after.
977
+ // biome-ignore lint/correctness/useExhaustiveDependencies: initial-only (frameSrc gate); fit is the initial mode's value
978
+ useEffect(() => {
979
+ if (frameSrc || !activeCase) return
980
+ setFrameSrc(
981
+ buildRenderSrc(
982
+ activeCase.renderUrl,
983
+ theme,
984
+ shownSel?.tweaks ?? {},
985
+ fitWidth,
986
+ stageDecor,
987
+ ),
988
+ )
989
+ }, [frameSrc, activeCase, theme, shownSel?.tweaks])
990
+
991
+ // Listen for the render frame's readiness handshake and in-flow transitions.
992
+ useEffect(() => {
993
+ function onMessage(e: MessageEvent) {
994
+ if (e.source !== frameRef.current?.contentWindow) return
995
+ const data = e.data as {
996
+ type?: string
997
+ state?: {
998
+ componentId: string
999
+ caseId: string
1000
+ tweaks: Record<string, string>
1001
+ }
1002
+ size?: { width: number; height: number }
1003
+ }
1004
+ if (data?.type === 'dc-ready') {
1005
+ setFrameReady(true)
1006
+ return
1007
+ }
1008
+ // The render frame reported its content's natural size — used to shrink
1009
+ // the iframe to the component in Responsive mode. Tag the measurement with
1010
+ // the exhibit it belongs to (what the iframe is currently showing) so the
1011
+ // reveal gate only trusts a size that matches the shown selection.
1012
+ if (data?.type === 'dc-size' && data.size) {
1013
+ setContent({ w: data.size.width, h: data.size.height })
1014
+ if (shownSelRef.current) {
1015
+ setMeasuredSig(selSignature(shownSelRef.current))
1016
+ }
1017
+ return
1018
+ }
1019
+ // A flow step advanced itself via `goto` — follow it so the sidebar's
1020
+ // active step and the address stay in sync with the preview. The iframe
1021
+ // already transitioned in place, so move `shownSel` in lock-step (no
1022
+ // crossfade — the controller sees the stage already matches `sel`).
1023
+ if (data?.type === 'dc-step-changed' && data.state) {
1024
+ const next = {
1025
+ componentId: data.state.componentId,
1026
+ caseId: data.state.caseId,
1027
+ tweaks: data.state.tweaks ?? {},
1028
+ }
1029
+ select(next)
1030
+ setShownSel(next)
1031
+ }
1032
+ }
1033
+ window.addEventListener('message', onMessage)
1034
+ return () => window.removeEventListener('message', onMessage)
1035
+ }, [select])
1036
+
1037
+ // Push the current selection into the frame in place (no reload).
1038
+ useEffect(() => {
1039
+ if (!frameReady || !component || !activeCase) return
1040
+ frameRef.current?.contentWindow?.postMessage(
1041
+ {
1042
+ type: 'dc-render',
1043
+ state: {
1044
+ componentId: component.id,
1045
+ caseId: activeCase.id,
1046
+ theme,
1047
+ width: null,
1048
+ tweaks: shownSel?.tweaks ?? {},
1049
+ fit: fitWidth,
1050
+ transparent: stageDecor,
1051
+ },
1052
+ },
1053
+ '*',
1054
+ )
1055
+ }, [
1056
+ frameReady,
1057
+ component,
1058
+ activeCase,
1059
+ theme,
1060
+ shownSel?.tweaks,
1061
+ fitWidth,
1062
+ stageDecor,
1063
+ ])
1064
+
1065
+ // Load the doc when the active component changes.
1066
+ // biome-ignore lint/correctness/useExhaustiveDependencies: keyed by component id
1067
+ useEffect(() => {
1068
+ setDocText(null)
1069
+ if (!component?.placardDoc) return
1070
+ fetch(`/doc/${component.id}`)
1071
+ .then((r) => (r.ok ? r.text() : null))
1072
+ .then(setDocText)
1073
+ }, [component?.id])
1074
+
1075
+ // Request the viewed variant's a11y result whenever the selection or theme
1076
+ // changes (per-theme, since contrast differs by theme). The result arrives
1077
+ // here (cached) or later over the SSE stream.
1078
+ useEffect(() => {
1079
+ if (!a11yEnabled || !component || !activeCase) return
1080
+ requestA11y(component.id, activeCase.id, theme)
1081
+ }, [a11yEnabled, component, activeCase, theme, requestA11y])
1082
+
1083
+ // One SSE subscription drives live a11y pushes and (in non-dev) the rebuild
1084
+ // refresh. The render iframe reloads itself via its own document script; here
1085
+ // we refetch the manifest (so added/removed cases appear) and re-request the
1086
+ // current variant's a11y (its cache may have been invalidated by the edit).
1087
+ // In `--dev` the shell does a full reload instead (chrome may have changed).
1088
+ // biome-ignore lint/correctness/useExhaustiveDependencies: subscribe once for the session
1089
+ useEffect(() => {
1090
+ if (!a11yEnabled && !dcConfig?.reload) return
1091
+ const es = new EventSource('/__livereload')
1092
+ if (a11yEnabled) {
1093
+ es.addEventListener('a11y', (e) => {
1094
+ try {
1095
+ const d = JSON.parse((e as MessageEvent).data)
1096
+ // From the live scan completing → cascade the reveal.
1097
+ applyA11yResult(d.component, d.case, d.theme, d, true)
1098
+ } catch {
1099
+ // ignore malformed event
1100
+ }
1101
+ })
1102
+ }
1103
+ if (dcConfig?.reload && !dcConfig?.dev) {
1104
+ es.addEventListener('reload', (e) => {
1105
+ // A shell-bundle change (the chrome itself) needs a full reload — the
1106
+ // injected styles + chrome code only re-run on a fresh document. A
1107
+ // content-only change refreshes the manifest + re-requests a11y while the
1108
+ // stage iframe reloads itself, preserving nav state.
1109
+ if ((e as MessageEvent).data === 'shell') {
1110
+ location.reload()
1111
+ return
1112
+ }
1113
+ fetch('/manifest.json')
1114
+ .then((r) => r.json())
1115
+ .then((m: Manifest) => setManifest(m))
1116
+ .catch(() => {})
1117
+ const cur = a11yCurRef.current
1118
+ if (a11yEnabled && cur.c && cur.cs && cur.th) {
1119
+ requestA11y(cur.c, cur.cs, cur.th)
1120
+ }
1121
+ })
1122
+ }
1123
+ return () => es.close()
1124
+ }, [])
1125
+
1126
+ // Seed the nav markers from every verdict the server already knows at connect
1127
+ // time — start-up population (the `cached`/`refresh` modes) and any scan that
1128
+ // completed before this tab opened. SSE only carries events emitted *after* we
1129
+ // subscribe, so without this one-shot replay a freshly opened tab would show
1130
+ // an empty nav until each variant is viewed. Results land via `applyA11yResult`
1131
+ // with `fromScan: false`, so only the markers fill in — the panel (keyed to
1132
+ // the viewed variant) is untouched.
1133
+ // biome-ignore lint/correctness/useExhaustiveDependencies: seed once on mount
1134
+ useEffect(() => {
1135
+ if (!a11yEnabled) return
1136
+ fetch('/a11y/known')
1137
+ .then((r) => r.json())
1138
+ .then(
1139
+ (
1140
+ rows: Array<{
1141
+ component: string
1142
+ case: string
1143
+ theme: string
1144
+ status: 'ok' | 'pending' | 'unavailable'
1145
+ violations?: A11yViolation[]
1146
+ reason?: string
1147
+ }>,
1148
+ ) => {
1149
+ for (const d of rows)
1150
+ applyA11yResult(d.component, d.case, d.theme, d, false)
1151
+ },
1152
+ )
1153
+ .catch(() => {})
1154
+ }, [])
1155
+
1156
+ if (!manifest) return { manifest: null }
1157
+
1158
+ // Shared opacity crossfade for every region that swaps on a mode change (nav
1159
+ // body, screen content, mode-specific header controls). They fade as one.
1160
+ const modeFadeStyle: CSSProperties = {
1161
+ opacity: modeContentShown ? 1 : 0,
1162
+ transition: `opacity ${MODE_FADE_MS}ms var(--dc-ease)`,
1163
+ }
1164
+
1165
+ return {
1166
+ manifest,
1167
+ theme,
1168
+ setTheme,
1169
+ navCollapsed,
1170
+ setNavCollapsed,
1171
+ mode,
1172
+ setMode: changeMode,
1173
+ shownMode,
1174
+ modeFadeStyle,
1175
+ sizeId,
1176
+ setSizeId,
1177
+ manualZoom,
1178
+ setManualZoom,
1179
+ showGrid,
1180
+ setShowGrid,
1181
+ widthInputValue,
1182
+ fixed,
1183
+ fitted,
1184
+ scale,
1185
+ sizeMeta,
1186
+ editDim,
1187
+ rotateDims,
1188
+ stageDecor,
1189
+ component,
1190
+ activeCase,
1191
+ addressUrl,
1192
+ shownSel,
1193
+ sel,
1194
+ docOpen,
1195
+ changeDocsOpen,
1196
+ docText,
1197
+ docWidth,
1198
+ startDocResize,
1199
+ onDocResizeKey,
1200
+ tweaksFloating,
1201
+ setTweaksFloating,
1202
+ a11y,
1203
+ rescanA11y,
1204
+ navScrollRef,
1205
+ navBodyRef,
1206
+ groups,
1207
+ expanded,
1208
+ toggleExpanded,
1209
+ selectComponent,
1210
+ select,
1211
+ primerGroups,
1212
+ primerActive,
1213
+ primerExpanded,
1214
+ togglePrimerGroup,
1215
+ scrollToSection,
1216
+ attachPreview,
1217
+ padX,
1218
+ padY,
1219
+ stageShown,
1220
+ colorSnap,
1221
+ boxW,
1222
+ boxH,
1223
+ frameRef,
1224
+ frameSrc,
1225
+ targetW,
1226
+ renderH,
1227
+ primerRef,
1228
+ primerSrc,
1229
+ }
1230
+ }