@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,277 @@
1
+ import type { ReactNode } from 'react'
2
+ import { useCallback, useEffect, useRef } from 'react'
3
+ import { slugify } from '../core/catalog'
4
+
5
+ /**
6
+ * The Primer — Display Case's long-form "wall text". A consumer authors an
7
+ * `.mdx` document (referenced from `display-case.config.ts`), and it renders
8
+ * here as a scrolling reading page with embedded LIVE specimens. The MDX can
9
+ * import any component — case files *and* arbitrary `.tsx` — and wraps each
10
+ * specimen in the {@link Display} contract below.
11
+ *
12
+ * This module is bundled into the isolated `/render/primer` document (never the browse
13
+ * chrome), so a specimen that throws on load can't blank the chrome — the same
14
+ * isolation the `/render` frame gives a case. It talks to the chrome over
15
+ * `postMessage`: it reports its section list (for the sidebar table of contents)
16
+ * and the active section on scroll, and accepts scroll-to / theme messages back.
17
+ */
18
+
19
+ export interface DisplayProps {
20
+ /** Specimen title — also the sidebar table-of-contents label and scroll anchor. */
21
+ title: string
22
+ /** Optional one-line description shown under the title. */
23
+ subtitle?: string
24
+ /** Force a theme inside this specimen (e.g. show a dark-mode component on a
25
+ * light primer). Omit to inherit the primer's current theme. */
26
+ theme?: 'light' | 'dark'
27
+ /** Drop the specimen's own border and padding so a single self-bordered child
28
+ * (e.g. a DefinitionList) fills the box edge-to-edge — avoids a
29
+ * box-within-a-box. The child supplies the border; this frame just clips it. */
30
+ flush?: boolean
31
+ /** Paint the specimen box with the consumer design system's own canvas
32
+ * (`--color-bg`/`--color-fg`) instead of the Vitrine's `--dc-bg`, so the
33
+ * component sits on the exact background the real app gives it. Opt-in;
34
+ * degrades to `--dc-bg` when the consumer defines no `--color-bg`. Combine
35
+ * with `theme` to show the app's themed surface. */
36
+ appSurface?: boolean
37
+ children?: ReactNode
38
+ }
39
+
40
+ /**
41
+ * The contract an `.mdx` primer wraps each live specimen in. Renders a titled
42
+ * card; the body is a flex row the specimen lays out in. `theme` forces a
43
+ * light/dark scope local to the card.
44
+ *
45
+ * @example
46
+ * <Display title="Button" subtitle="The quiet bordered control" theme="dark">
47
+ * <Button variant="accent">Snapshot</Button>
48
+ * </Display>
49
+ */
50
+ export function Display({
51
+ title,
52
+ subtitle,
53
+ theme,
54
+ flush,
55
+ appSurface,
56
+ children,
57
+ }: DisplayProps) {
58
+ return (
59
+ <section
60
+ className="dc-display"
61
+ id={`section-${slugify(title)}`}
62
+ data-dc-section=""
63
+ data-dc-title={title}>
64
+ <div className="dc-display-head">
65
+ <div className="dc-display-title">{title}</div>
66
+ {subtitle ? <div className="dc-display-sub">{subtitle}</div> : null}
67
+ </div>
68
+ <div
69
+ className="dc-display-specimen"
70
+ data-theme={theme}
71
+ data-flush={flush ? '' : undefined}
72
+ data-app-surface={appSurface ? '' : undefined}>
73
+ {children}
74
+ </div>
75
+ </section>
76
+ )
77
+ }
78
+
79
+ interface PrimerSection {
80
+ id: string
81
+ title: string
82
+ /** `heading` is a `##` group header; `display` is a specimen card under it. */
83
+ kind: 'heading' | 'display'
84
+ }
85
+
86
+ /** Flatten a React node tree to its text content (for a heading's slug + label). */
87
+ function textOf(node: ReactNode): string {
88
+ if (node == null || typeof node === 'boolean') return ''
89
+ if (typeof node === 'string' || typeof node === 'number') return String(node)
90
+ if (Array.isArray(node)) return node.map(textOf).join('')
91
+ if (typeof node === 'object' && 'props' in node)
92
+ return textOf(
93
+ (node as { props?: { children?: ReactNode } }).props?.children,
94
+ )
95
+ return ''
96
+ }
97
+
98
+ /**
99
+ * The `#`/`##` headings in a primer MDX. Beyond rendering the prose heading,
100
+ * each becomes a navigable group header in the sidebar table of contents: the
101
+ * Displays that follow it nest under it (see {@link PrimerRoot} reporting). The
102
+ * H1 thus doubles as the "top of page" entry, with its intro specimens nested.
103
+ */
104
+ function PrimerHeading({
105
+ tag: Tag,
106
+ children,
107
+ }: {
108
+ tag: 'h1' | 'h2'
109
+ children?: ReactNode
110
+ }) {
111
+ const text = textOf(children)
112
+ return (
113
+ <Tag
114
+ id={`heading-${slugify(text)}`}
115
+ data-dc-heading=""
116
+ data-dc-title={text}>
117
+ {children}
118
+ </Tag>
119
+ )
120
+ }
121
+
122
+ /** Components map handed to the compiled MDX — resolves `<Display>` and `#`/`##`. */
123
+ export const primerComponents = {
124
+ Display,
125
+ h1: ({ children }: { children?: ReactNode }) => (
126
+ <PrimerHeading tag="h1">{children}</PrimerHeading>
127
+ ),
128
+ h2: ({ children }: { children?: ReactNode }) => (
129
+ <PrimerHeading tag="h2">{children}</PrimerHeading>
130
+ ),
131
+ }
132
+
133
+ /**
134
+ * The scrolling reading page. Renders the compiled MDX document and wires the
135
+ * scrollspy ↔ chrome messaging: it reports the section list and the active
136
+ * section, and reacts to scroll-to / theme messages from the parent chrome.
137
+ */
138
+ export function PrimerRoot({
139
+ content,
140
+ }: {
141
+ content: (props: { components?: unknown }) => ReactNode
142
+ }) {
143
+ // Capitalize for JSX use (the compiled MDX document is a component).
144
+ const Content = content
145
+ const scrollRef = useRef<HTMLDivElement | null>(null)
146
+ // During a click-driven smooth scroll, the highlight is locked to the target
147
+ // and scrollspy is paused — otherwise the active row flickers across every
148
+ // section the scroll passes over (many hidden inside collapsed nav groups).
149
+ const programmatic = useRef(false)
150
+ const settleTimer = useRef<ReturnType<typeof setTimeout> | null>(null)
151
+ const embedded = typeof window !== 'undefined' && window.parent !== window
152
+
153
+ const post = useCallback(
154
+ (message: unknown) => {
155
+ if (embedded) window.parent.postMessage(message, '*')
156
+ },
157
+ [embedded],
158
+ )
159
+
160
+ // Both `##` headings and Displays anchor the table of contents; in document
161
+ // order they let the chrome nest each Display under its heading.
162
+ const sectionEls = useCallback((): HTMLElement[] => {
163
+ const root = scrollRef.current
164
+ if (!root) return []
165
+ return Array.from(
166
+ root.querySelectorAll<HTMLElement>(
167
+ '[data-dc-section], [data-dc-heading]',
168
+ ),
169
+ )
170
+ }, [])
171
+
172
+ const reportSections = useCallback(() => {
173
+ const sections: PrimerSection[] = sectionEls().map((el) => ({
174
+ id: el.id,
175
+ title: el.dataset.dcTitle ?? el.id,
176
+ kind: el.hasAttribute('data-dc-heading') ? 'heading' : 'display',
177
+ }))
178
+ post({ type: 'dc-primer-sections', sections })
179
+ }, [post, sectionEls])
180
+
181
+ const reportActive = useCallback(() => {
182
+ const root = scrollRef.current
183
+ if (!root) return
184
+ const els = sectionEls()
185
+ if (!els.length) return
186
+ // At (or near) the bottom the last section can't scroll to the top, so the
187
+ // "topmost past the line" rule would never reach it — pin it active there.
188
+ const atBottom = root.scrollTop + root.clientHeight >= root.scrollHeight - 4
189
+ let active = els[0].id
190
+ if (atBottom) {
191
+ active = els[els.length - 1].id
192
+ } else {
193
+ const top = root.getBoundingClientRect().top
194
+ for (const el of els) {
195
+ if (el.getBoundingClientRect().top - top <= 80) active = el.id
196
+ }
197
+ }
198
+ post({ type: 'dc-primer-active', id: active })
199
+ }, [post, sectionEls])
200
+
201
+ // Resume scrollspy once a programmatic scroll has settled (no scroll events
202
+ // for a beat) and re-sync the active section to where it actually landed. Also
203
+ // covers the no-op case where the target is already at the top, so nothing
204
+ // ever scrolls and `onScroll` never fires to release the lock.
205
+ const armSettle = useCallback(() => {
206
+ if (settleTimer.current) clearTimeout(settleTimer.current)
207
+ settleTimer.current = setTimeout(() => {
208
+ programmatic.current = false
209
+ reportActive()
210
+ }, 150)
211
+ }, [reportActive])
212
+
213
+ // Report the section list once mounted (and whenever the content resizes, e.g.
214
+ // late fonts/images shifting offsets), and keep the active section current.
215
+ useEffect(() => {
216
+ const root = scrollRef.current
217
+ if (!root) return
218
+ reportSections()
219
+ reportActive()
220
+ post({ type: 'dc-primer-ready' })
221
+ const onScroll = () => {
222
+ // While a click-driven scroll animates, hold the highlight on the target
223
+ // and just keep pushing the settle deadline out until the motion stops.
224
+ if (programmatic.current) {
225
+ armSettle()
226
+ return
227
+ }
228
+ reportActive()
229
+ }
230
+ root.addEventListener('scroll', onScroll, { passive: true })
231
+ const ro = new ResizeObserver(() => {
232
+ reportSections()
233
+ reportActive()
234
+ })
235
+ ro.observe(root)
236
+ return () => {
237
+ root.removeEventListener('scroll', onScroll)
238
+ ro.disconnect()
239
+ if (settleTimer.current) clearTimeout(settleTimer.current)
240
+ }
241
+ }, [reportSections, reportActive, armSettle, post])
242
+
243
+ // Accept scroll-to (sidebar TOC click) and theme messages from the chrome.
244
+ useEffect(() => {
245
+ function onMessage(e: MessageEvent) {
246
+ if (e.source !== window.parent) return
247
+ const data = e.data as { type?: string; id?: string; theme?: string }
248
+ if (data?.type === 'dc-primer-scroll' && data.id) {
249
+ // Section ids are slug-safe (`section-<kebab>`), so id lookup needs no
250
+ // escaping — and the local `CSS` here is the style string, not the global.
251
+ const el = document.getElementById(data.id)
252
+ if (el) {
253
+ // Lock the highlight to the clicked target and pause scrollspy until
254
+ // the smooth scroll settles, so it doesn't crawl across intermediate
255
+ // (often hidden) sections on the way there.
256
+ programmatic.current = true
257
+ post({ type: 'dc-primer-active', id: data.id })
258
+ el.scrollIntoView({ behavior: 'smooth', block: 'start' })
259
+ armSettle()
260
+ }
261
+ } else if (data?.type === 'dc-primer-theme' && data.theme) {
262
+ document.documentElement.dataset.theme = data.theme
263
+ document.documentElement.dataset.themePref = data.theme
264
+ }
265
+ }
266
+ window.addEventListener('message', onMessage)
267
+ return () => window.removeEventListener('message', onMessage)
268
+ }, [post, armSettle])
269
+
270
+ return (
271
+ <div className="dc-primer" ref={scrollRef}>
272
+ <div className="dc-primer-inner">
273
+ <Content components={primerComponents} />
274
+ </div>
275
+ </div>
276
+ )
277
+ }
@@ -0,0 +1,284 @@
1
+ import { flushSync } from 'react-dom'
2
+ import type { Root } from 'react-dom/client'
3
+ import { createRoot, hydrateRoot } from 'react-dom/client'
4
+ import { slugify } from '../core/catalog'
5
+ import type { CaseModule, DisplayCaseConfig, GotoFn } from '../index'
6
+ import { caseTree, encodeOverrides } from '../render/render-node'
7
+
8
+ /**
9
+ * Entry point for the isolated `/render/:component/:case` document. Renders
10
+ * exactly one case (wrapped in the optional decorator) into #root and nothing
11
+ * else — this is what the browse iframe embeds and what screenshot tools
12
+ * capture.
13
+ *
14
+ * Three ways to drive it:
15
+ * - **Standalone** (snapshot/screenshot tools, direct navigation): the target
16
+ * case + theme + width + tweaks are read from the URL on load.
17
+ * - **Embedded** (browse chrome iframe): after announcing readiness, the parent
18
+ * pushes `dc-render` messages to swap case/theme/width/tweaks *in place*, so
19
+ * the iframe never reloads or remounts — no flicker on switch or tweak.
20
+ * - **In-flow transition**: a flow step calls its injected `goto`, which makes
21
+ * the target step active in place, updates the address (so the new step is
22
+ * deep-linkable/snapshottable), and notifies the chrome via `dc-step-changed`.
23
+ */
24
+
25
+ interface RenderState {
26
+ componentId: string
27
+ caseId: string
28
+ theme: 'light' | 'dark'
29
+ width: number | null
30
+ tweaks: Record<string, string>
31
+ /** Shrink-wrap #root to the case's natural width (so a small component
32
+ * doesn't stretch to fill the frame). Driven by the browse chrome. */
33
+ fit: boolean
34
+ /** Drop the document background so the component shows on the stage's grid.
35
+ * Decorated components only; the chrome never sets it for pages/flows. */
36
+ transparent: boolean
37
+ }
38
+
39
+ interface NavOptions {
40
+ /** Push the active step to history so it is directly addressable. */
41
+ pushUrl?: boolean
42
+ /** Tell the parent chrome the active step changed (in-flow transitions). */
43
+ notifyParent?: boolean
44
+ }
45
+
46
+ function stateFromUrl(): RenderState {
47
+ const params = new URLSearchParams(window.location.search)
48
+ const parts = window.location.pathname.split('/').filter(Boolean)
49
+ const widthParam = params.get('width')
50
+ const tweaks: Record<string, string> = {}
51
+ for (const [k, v] of params) {
52
+ if (k.startsWith('t.')) tweaks[k.slice(2)] = v
53
+ }
54
+ return {
55
+ // Path shape: /render/<component>/<case>
56
+ componentId: parts[1] ?? '',
57
+ caseId: parts[2] ?? '',
58
+ theme: params.get('theme') === 'dark' ? 'dark' : 'light',
59
+ width: widthParam ? Number(widthParam) : null,
60
+ tweaks,
61
+ fit: params.get('fit') === '1',
62
+ transparent: params.get('transparent') === '1',
63
+ }
64
+ }
65
+
66
+ /** Write the active step into the address so it can be deep-linked/snapshotted. */
67
+ function pushStepUrl(state: RenderState): void {
68
+ const params = new URLSearchParams()
69
+ params.set('theme', state.theme)
70
+ if (state.width) params.set('width', String(state.width))
71
+ for (const [k, v] of Object.entries(state.tweaks)) params.set(`t.${k}`, v)
72
+ window.history.pushState(
73
+ null,
74
+ '',
75
+ `/render/${state.componentId}/${state.caseId}?${params.toString()}`,
76
+ )
77
+ }
78
+
79
+ /**
80
+ * Apply a render's document-level effects — the parts that live outside the
81
+ * React tree and so are set imperatively, not rendered. The server bakes these
82
+ * same values into the delivered document (so the first paint is correct and
83
+ * hydration matches); re-applying them here is idempotent on first load and
84
+ * carries an in-place swap (theme/width/transparent change) that the server
85
+ * never sees.
86
+ */
87
+ function applyDocEffects(state: RenderState): void {
88
+ document.documentElement.dataset.theme = state.theme
89
+ // Also set the explicit preference so a `ThemeProvider` used inside app chrome
90
+ // (e.g. Navbar's ThemeToggle) initializes to the harness theme
91
+ // rather than re-resolving from the OS and fighting the `?theme=` selection.
92
+ document.documentElement.dataset.themePref = state.theme
93
+
94
+ // Shrink-wrap the mount to the case's natural width when asked, so a
95
+ // block/flex-rooted component (which would otherwise fill the full-width
96
+ // frame) renders at its intrinsic size. The chrome measures the result and
97
+ // hugs the stage to it. Cleared (full width) for pages/flows and presets.
98
+ const mount = document.getElementById('root')
99
+ if (mount) mount.style.width = state.fit ? 'fit-content' : ''
100
+
101
+ // Drop the document background so a decorated component sits directly on the
102
+ // stage's dotted grid (the chrome's stage frame paints the surface + grid
103
+ // behind the transparent iframe). Cleared back to the token bg otherwise.
104
+ document.body.style.background = state.transparent ? 'transparent' : ''
105
+
106
+ // Mark decorated exhibits (atoms…templates — never pages/flows, which lay out
107
+ // their own full-bleed structure) so the render doc can center the exhibit's
108
+ // content in the frame by default. Keyed off the same flag as `transparent`.
109
+ document.body.toggleAttribute('data-decorated', state.transparent)
110
+ }
111
+
112
+ /** Build the case's React tree — the identical tree the server pre-rendered —
113
+ * wiring a flow step's `goto` to drive an in-place transition. */
114
+ function treeFor(
115
+ modules: CaseModule[],
116
+ config: DisplayCaseConfig,
117
+ state: RenderState,
118
+ navigate: (next: RenderState, opts?: NavOptions) => void,
119
+ ) {
120
+ const goto: GotoFn = (target, overrides) => {
121
+ // A transition makes the target step active in place; the new step gets its
122
+ // own address (overrides become its preset state) and the chrome is told to
123
+ // follow.
124
+ navigate(
125
+ { ...state, caseId: slugify(target), tweaks: encodeOverrides(overrides) },
126
+ { pushUrl: true, notifyParent: true },
127
+ )
128
+ }
129
+ return caseTree(
130
+ modules,
131
+ config,
132
+ {
133
+ componentId: state.componentId,
134
+ caseId: state.caseId,
135
+ width: state.width,
136
+ tweaks: state.tweaks,
137
+ },
138
+ goto,
139
+ )
140
+ }
141
+
142
+ /**
143
+ * Neutralize anchor clicks that would unload the render frame. A case or flow
144
+ * step can legitimately render a real `<a href="/dashboard">` (the route would
145
+ * supply a router `<Link>`), but in this isolated frame there is no router, so a
146
+ * click does a full-document navigation to a non-render path. The server then
147
+ * serves the browse shell *into the frame*, nesting a shell and severing the
148
+ * parent↔frame handshake — every case/flow appears broken until a manual reload.
149
+ * Same-document hash links (in-page scroll) and `target=_blank` (new context)
150
+ * are harmless and left alone.
151
+ */
152
+ function blockFrameNavigation(): void {
153
+ document.addEventListener(
154
+ 'click',
155
+ (e) => {
156
+ if (
157
+ e.defaultPrevented ||
158
+ e.button !== 0 ||
159
+ e.metaKey ||
160
+ e.ctrlKey ||
161
+ e.shiftKey ||
162
+ e.altKey
163
+ ) {
164
+ return
165
+ }
166
+ const anchor = (e.target as HTMLElement | null)?.closest?.('a')
167
+ const href = anchor?.getAttribute('href')
168
+ if (!anchor || !href) return
169
+ if (anchor.target && anchor.target !== '_self') return // new tab/window
170
+ const url = new URL(href, window.location.href)
171
+ const sameDocumentHash =
172
+ url.pathname === window.location.pathname &&
173
+ url.search === window.location.search &&
174
+ url.hash !== ''
175
+ if (sameDocumentHash) return // in-page scroll, doesn't unload the frame
176
+ e.preventDefault()
177
+ },
178
+ true,
179
+ )
180
+ }
181
+
182
+ export function mountRender(
183
+ modules: CaseModule[],
184
+ config: DisplayCaseConfig,
185
+ ): void {
186
+ blockFrameNavigation()
187
+ const rootEl = document.getElementById('root') as HTMLElement
188
+ // The server pre-rendered the case into #root and flagged it `data-ssr="1"`;
189
+ // adopt that markup instead of mounting from scratch. A browser-only case is
190
+ // delivered empty (`data-ssr="0"`) and mounted fresh on the client.
191
+ const ssr = rootEl.dataset.ssr === '1'
192
+ let root: Root | null = null
193
+ let state = stateFromUrl()
194
+ const embedded = !!(window.parent && window.parent !== window)
195
+
196
+ // Report the rendered content's natural size to the browse chrome so it can
197
+ // shrink the iframe to the component (instead of stretching it to fill the
198
+ // panel) and center it on the stage. Measured off #root: its block height is
199
+ // the content height regardless of the iframe's (possibly taller) viewport,
200
+ // which `documentElement.scrollHeight` would clamp to. Hoisted above navigate
201
+ // so every swap re-announces the size — the ResizeObserver below only fires on
202
+ // an actual size *change*, so a swap between two same-size cases would never
203
+ // re-report, leaving the chrome (which hides the stage until the new size
204
+ // lands) waiting forever.
205
+ const postSize = (): void => {
206
+ if (!embedded) return
207
+ window.parent.postMessage(
208
+ {
209
+ type: 'dc-size',
210
+ size: {
211
+ height: Math.ceil(rootEl.scrollHeight),
212
+ width: Math.ceil(rootEl.scrollWidth),
213
+ },
214
+ },
215
+ '*',
216
+ )
217
+ }
218
+
219
+ // Single entry point for every state change — initial render, parent-driven
220
+ // `dc-render` swaps, and in-flow `goto` transitions all flow through here.
221
+ const navigate = (next: RenderState, opts: NavOptions = {}): void => {
222
+ state = next
223
+ if (opts.pushUrl) pushStepUrl(state)
224
+ if (opts.notifyParent && embedded) {
225
+ window.parent.postMessage(
226
+ {
227
+ type: 'dc-step-changed',
228
+ state: {
229
+ componentId: state.componentId,
230
+ caseId: state.caseId,
231
+ tweaks: state.tweaks,
232
+ },
233
+ },
234
+ '*',
235
+ )
236
+ }
237
+ applyDocEffects(state)
238
+ const tree = treeFor(modules, config, state, navigate)
239
+ if (!root) {
240
+ // First commit: adopt the server markup (hydrate) when present, otherwise
241
+ // mount fresh. Hydration is its own commit, so it isn't wrapped in
242
+ // flushSync; a recoverable mismatch (a non-deterministic case) is logged
243
+ // and React re-renders that subtree on the client.
244
+ if (ssr) {
245
+ root = hydrateRoot(rootEl, tree, {
246
+ onRecoverableError: (err) =>
247
+ console.warn(
248
+ '[display-case] adopt mismatch; client re-rendered:',
249
+ err,
250
+ ),
251
+ })
252
+ } else {
253
+ root = createRoot(rootEl)
254
+ flushSync(() => (root as Root).render(tree))
255
+ }
256
+ } else {
257
+ // Commit synchronously so the measurement reads this case's layout (not the
258
+ // previous one's), then re-announce the size for this navigation.
259
+ flushSync(() => (root as Root).render(tree))
260
+ }
261
+ postSize()
262
+ }
263
+
264
+ navigate(state)
265
+
266
+ // Async layout changes (late images/fonts, content that resizes after mount)
267
+ // re-announce via the observer; navigate() covers the synchronous swaps.
268
+ if (embedded) new ResizeObserver(postSize).observe(rootEl)
269
+
270
+ // Embedded mode: accept in-place updates from the browse chrome so switching
271
+ // case / theme / width / tweaks never reloads the iframe.
272
+ window.addEventListener('message', (e: MessageEvent) => {
273
+ if (e.source !== window.parent) return
274
+ const data = e.data as { type?: string; state?: Partial<RenderState> }
275
+ if (data?.type !== 'dc-render' || !data.state) return
276
+ navigate({ ...state, ...data.state })
277
+ })
278
+
279
+ // Announce readiness so the parent can push the current selection. Harmless
280
+ // when standalone (parent is self; no dc-render messages are sent).
281
+ if (window.parent && window.parent !== window) {
282
+ window.parent.postMessage({ type: 'dc-ready' }, '*')
283
+ }
284
+ }