@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,1039 @@
1
+ import { existsSync } from 'node:fs'
2
+ import { createServer } from 'node:net'
3
+ import { join, relative, resolve } from 'node:path'
4
+ import {
5
+ type A11yScanner,
6
+ type A11yScanStatus,
7
+ createA11yScanner,
8
+ } from '../checks/a11y-scanner'
9
+ import { buildCatalog, slugify } from '../core/catalog'
10
+ import type { LoadedModule } from '../core/discovery'
11
+ import {
12
+ cacheDir,
13
+ codegenPrimerEntry,
14
+ codegenRenderEntry,
15
+ codegenSsrEntry,
16
+ codegenSsrPrimerEntry,
17
+ discoverCaseFiles,
18
+ loadModules,
19
+ resolveConfig,
20
+ } from '../core/discovery'
21
+ import type { Manifest } from '../core/manifest'
22
+ import { mdxPlugin } from '../core/mdx-plugin'
23
+ import type { DisplayCaseConfig } from '../index'
24
+ import type { PrimerHtmlResult } from '../render/ssr-primer'
25
+ import type { CaseRenderer } from '../render/ssr-render'
26
+ import { renderShellToHtml } from '../render/ssr-shell'
27
+ import type { Theme } from '../ui/shell-core'
28
+
29
+ const HERE = resolve(import.meta.dir, '..')
30
+ const BROWSER_ENTRY = join(HERE, 'ui', 'browser-entry.tsx')
31
+ const CHROME_CSS = join(HERE, 'ui', 'chrome.css')
32
+ const CLI = join(HERE, 'cli.ts')
33
+
34
+ // The package's own design system — "The Vitrine". Display Case dogfoods it:
35
+ // the browse chrome is styled entirely from these `--dc-*` tokens. The token
36
+ // files are inlined (in @import order, fonts excluded) ahead of chrome.css; the
37
+ // webfonts load via the <link>s below so the declaration leads the document and
38
+ // never fights a consumer's stylesheet @imports. See ui/design-system/.
39
+ const DS_DIR = join(HERE, 'ui', 'design-system', 'tokens')
40
+ const DS_TOKEN_FILES = ['colors.css', 'typography.css', 'spacing.css']
41
+ const FONT_LINKS =
42
+ '<link rel="preconnect" href="https://fonts.googleapis.com"/>' +
43
+ '<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin/>' +
44
+ '<link rel="stylesheet" href="https://fonts.googleapis.com/css2?family=Hanken+Grotesk:wght@400;500;600;700&family=JetBrains+Mono:wght@400;500;600&display=swap"/>'
45
+
46
+ async function readDesignTokens(): Promise<string> {
47
+ const parts = await Promise.all(
48
+ DS_TOKEN_FILES.map((f) => Bun.file(join(DS_DIR, f)).text()),
49
+ )
50
+ return parts.join('\n')
51
+ }
52
+
53
+ // The Vitrine's own chrome stylesheet, assembled by reading and concatenating
54
+ // (in path-sorted order) the shell layout (chrome.css), every design-system
55
+ // component's co-located CSS, and the primer chrome's CSS. The design-system
56
+ // components no longer inject their CSS at runtime; this blob is inlined into
57
+ // every document head so the chrome paints before scripts run. Mirrors
58
+ // readDesignTokens (read N files, join) — no bundler step, no JS-graph import.
59
+ const COMPONENTS_DIR = join(HERE, 'ui', 'design-system', 'components')
60
+ const PRIMER_CSS = join(HERE, 'ui', 'primer.css')
61
+
62
+ async function readVitrineCss(): Promise<string> {
63
+ const componentFiles: string[] = []
64
+ for await (const f of new Bun.Glob('**/*.css').scan({
65
+ cwd: COMPONENTS_DIR,
66
+ absolute: true,
67
+ })) {
68
+ componentFiles.push(f)
69
+ }
70
+ componentFiles.sort()
71
+ const files = [CHROME_CSS, ...componentFiles]
72
+ if (existsSync(PRIMER_CSS)) files.push(PRIMER_CSS)
73
+ const parts = await Promise.all(files.map((f) => Bun.file(f).text()))
74
+ return parts.join('\n')
75
+ }
76
+
77
+ /** Walk up from this package to find the repo root (nearest dir with `.git`). */
78
+ function findRepoRoot(): string {
79
+ let dir = HERE
80
+ for (let i = 0; i < 12; i++) {
81
+ if (existsSync(join(dir, '.git'))) return dir
82
+ const parent = resolve(dir, '..')
83
+ if (parent === dir) break
84
+ dir = parent
85
+ }
86
+ return process.cwd()
87
+ }
88
+
89
+ const REPO_ROOT = findRepoRoot()
90
+
91
+ interface BuiltState {
92
+ manifest: Manifest
93
+ /** component id → absolute placard-doc path (only present when a doc exists). */
94
+ placardById: Map<string, string>
95
+ /** Concatenated consumer global stylesheet contents. */
96
+ globalCss: string
97
+ /** Pre-render a case to markup for the isolated `/render` document. Rebuilt
98
+ * fresh each rebuild so its case modules track edits (see `rebuild`). */
99
+ renderCase: CaseRenderer
100
+ /** Pre-render the primer to markup, or null when no primer is configured. */
101
+ renderPrimer: (() => PrimerHtmlResult) | null
102
+ }
103
+
104
+ // Monotonic suffix for the SSR bundle's filename. Bun caches `import()` by
105
+ // resolved path and ignores `?v=` busting, so each rebuild must write — and
106
+ // import — a uniquely-named bundle to pick up edited case source. (The browser
107
+ // render bundle is always fresh because `Bun.build` re-reads from disk; this is
108
+ // the in-process import equivalent of that freshness.)
109
+ let ssrBuildSeq = 0
110
+
111
+ function relPath(p: string): string {
112
+ return relative(REPO_ROOT, p)
113
+ }
114
+
115
+ /** Absolute path of the configured primer `.mdx`, or null if none/missing. */
116
+ function primerFile(pkgDir: string, config: DisplayCaseConfig): string | null {
117
+ if (!config.primer) return null
118
+ const abs = resolve(pkgDir, config.primer)
119
+ return existsSync(abs) ? abs : null
120
+ }
121
+
122
+ function buildManifest(
123
+ modules: LoadedModule[],
124
+ config: DisplayCaseConfig,
125
+ hasPrimer: boolean,
126
+ ): { manifest: Manifest; placardById: Map<string, string> } {
127
+ const fileByComponent = new Map(
128
+ modules.map((m) => [m.module.component, m.file]),
129
+ )
130
+ const placardById = new Map<string, string>()
131
+ const catalog = buildCatalog(modules.map((m) => m.module))
132
+
133
+ const components = catalog.map((c) => {
134
+ const file = fileByComponent.get(c.name) as string
135
+ const placardAbs = file.replace(/\.case\.tsx?$/, '.placard.md')
136
+ const hasDoc = existsSync(placardAbs)
137
+ if (hasDoc) placardById.set(c.id, placardAbs)
138
+ return {
139
+ id: c.id,
140
+ name: c.name,
141
+ level: c.level,
142
+ isFlow: c.isFlow,
143
+ caseFile: relPath(file),
144
+ placardDoc: hasDoc ? relPath(placardAbs) : null,
145
+ cases: c.cases.map((cs) => ({
146
+ id: cs.id,
147
+ name: cs.name,
148
+ browseUrl: `/c/${c.id}/${cs.id}`,
149
+ renderUrl: `/render/${c.id}/${cs.id}`,
150
+ tweaks: cs.tweaks,
151
+ transitions: cs.transitions,
152
+ })),
153
+ }
154
+ })
155
+
156
+ // Land on the Primer by default when one exists; `landing: 'cases'` opts the
157
+ // root view back to the library even with a Primer configured. With no
158
+ // Primer there's only the library to land on.
159
+ const landing = hasPrimer && config.landing !== 'cases' ? 'primer' : 'library'
160
+
161
+ return {
162
+ manifest: { title: config.title, components, primer: hasPrimer, landing },
163
+ placardById,
164
+ }
165
+ }
166
+
167
+ async function readGlobalCss(
168
+ pkgDir: string,
169
+ config: DisplayCaseConfig,
170
+ ): Promise<string> {
171
+ const parts: string[] = []
172
+ for (const rel of config.globalStyles ?? []) {
173
+ const abs = resolve(pkgDir, rel)
174
+ if (await Bun.file(abs).exists()) parts.push(await Bun.file(abs).text())
175
+ }
176
+ return parts.join('\n')
177
+ }
178
+
179
+ /**
180
+ * Build the manifest in a fresh subprocess. Bun caches ES modules by resolved
181
+ * path for the life of a process (a `?v=` query does not bust it), so an
182
+ * in-process re-import after an edit would return the stale module — the
183
+ * manifest shape (case order/names, level, tweak schema) would never update on
184
+ * a watch rebuild. Spawning `--print-manifest` gives a clean module graph each
185
+ * time; the child's stderr (load errors) is relayed to ours.
186
+ */
187
+ async function loadManifestFresh(pkgDir: string): Promise<Manifest> {
188
+ const proc = Bun.spawn(['bun', CLI, pkgDir, '--print-manifest'], {
189
+ stdout: 'pipe',
190
+ stderr: 'pipe',
191
+ })
192
+ const [out, err, code] = await Promise.all([
193
+ new Response(proc.stdout).text(),
194
+ new Response(proc.stderr).text(),
195
+ proc.exited,
196
+ ])
197
+ if (err.trim()) process.stderr.write(err.endsWith('\n') ? err : `${err}\n`)
198
+ if (code !== 0) {
199
+ throw new Error(`manifest build subprocess exited with code ${code}`)
200
+ }
201
+ return JSON.parse(out) as Manifest
202
+ }
203
+
204
+ /** Discover, codegen, bundle, and assemble the served state. */
205
+ async function rebuild(
206
+ pkgDir: string,
207
+ config: DisplayCaseConfig,
208
+ configPath: string,
209
+ ): Promise<BuiltState> {
210
+ const files = await discoverCaseFiles(pkgDir, config)
211
+
212
+ const renderEntry = await codegenRenderEntry(pkgDir, files, configPath)
213
+ const outdir = join(cacheDir(pkgDir), 'dist')
214
+ // The Primer is its own isolated document (like /render), so it's a separate
215
+ // bundle entry — keeping the consumer's `.mdx` and the arbitrary components it
216
+ // imports out of the browse chrome's bundle.
217
+ const primerSrc = primerFile(pkgDir, config)
218
+ const primerEntry = primerSrc
219
+ ? await codegenPrimerEntry(pkgDir, config.primer as string)
220
+ : null
221
+ const entrypoints = [BROWSER_ENTRY, renderEntry]
222
+ if (primerEntry) entrypoints.push(primerEntry)
223
+ const result = await Bun.build({
224
+ entrypoints,
225
+ outdir,
226
+ target: 'browser',
227
+ // The MDX plugin compiles the primer's `.mdx` (and any `.mdx` it imports)
228
+ // to JS on load; it's a no-op for builds without a primer entry.
229
+ plugins: [mdxPlugin()],
230
+ // Inline the consumer's public env (BUN_PUBLIC_*) so a `process.env.*` read
231
+ // in bundled code (e.g. the API base URL) doesn't survive as a literal that
232
+ // throws `process is not defined` in the browser. See publicEnvDefines.
233
+ define: await publicEnvDefines(pkgDir),
234
+ naming: {
235
+ entry: '[name].[ext]',
236
+ chunk: '[name]-[hash].[ext]',
237
+ asset: '[name]-[hash].[ext]',
238
+ },
239
+ })
240
+ if (!result.success) {
241
+ for (const log of result.logs) console.error(log)
242
+ throw new Error('Display Case bundle failed')
243
+ }
244
+
245
+ // Pre-render bundle: the same case import list as the browser render bundle,
246
+ // but built for Bun and imported in-process so the server can render a case to
247
+ // markup before delivering its document. Built to a fresh, sequence-named file
248
+ // each rebuild because Bun caches imports by resolved path — a stable name
249
+ // would return the stale renderer after an edit (the same staleness that forces
250
+ // the manifest into a subprocess). The bundle inlines case source from disk, so
251
+ // importing the fresh file yields current modules. React stays external so the
252
+ // server resolves it from node_modules at import time.
253
+ const ssrEntry = await codegenSsrEntry(pkgDir, files, configPath)
254
+ const ssrOutDir = join(cacheDir(pkgDir), 'ssr')
255
+ const ssrName = `ssr-entry-${++ssrBuildSeq}`
256
+ const ssrResult = await Bun.build({
257
+ entrypoints: [ssrEntry],
258
+ outdir: ssrOutDir,
259
+ target: 'bun',
260
+ plugins: [mdxPlugin()],
261
+ define: await publicEnvDefines(pkgDir),
262
+ external: [
263
+ 'react',
264
+ 'react-dom',
265
+ 'react-dom/server',
266
+ 'react/jsx-runtime',
267
+ 'react/jsx-dev-runtime',
268
+ ],
269
+ naming: {
270
+ entry: `${ssrName}.[ext]`,
271
+ chunk: '[name]-[hash].[ext]',
272
+ asset: '[name]-[hash].[ext]',
273
+ },
274
+ })
275
+ if (!ssrResult.success) {
276
+ for (const log of ssrResult.logs) console.error(log)
277
+ throw new Error('Display Case SSR bundle failed')
278
+ }
279
+ const ssrModule = (await import(join(ssrOutDir, `${ssrName}.js`))) as {
280
+ renderCaseToHtml: CaseRenderer
281
+ }
282
+ const renderCase = ssrModule.renderCaseToHtml
283
+
284
+ // Pre-render bundle for the primer, built and imported the same way (and only
285
+ // when a primer is configured). Its specimens are real consumer components, so
286
+ // — like a case — one may be browser-only; the renderer reports that and the
287
+ // server falls back to client-rendering the whole primer.
288
+ let renderPrimer: (() => PrimerHtmlResult) | null = null
289
+ if (primerSrc) {
290
+ const ssrPrimerEntry = await codegenSsrPrimerEntry(
291
+ pkgDir,
292
+ config.primer as string,
293
+ configPath,
294
+ )
295
+ const ssrPrimerName = `ssr-primer-entry-${ssrBuildSeq}`
296
+ const ssrPrimerResult = await Bun.build({
297
+ entrypoints: [ssrPrimerEntry],
298
+ outdir: ssrOutDir,
299
+ target: 'bun',
300
+ plugins: [mdxPlugin()],
301
+ define: await publicEnvDefines(pkgDir),
302
+ external: [
303
+ 'react',
304
+ 'react-dom',
305
+ 'react-dom/server',
306
+ 'react/jsx-runtime',
307
+ 'react/jsx-dev-runtime',
308
+ ],
309
+ naming: {
310
+ entry: `${ssrPrimerName}.[ext]`,
311
+ chunk: '[name]-[hash].[ext]',
312
+ asset: '[name]-[hash].[ext]',
313
+ },
314
+ })
315
+ if (!ssrPrimerResult.success) {
316
+ for (const log of ssrPrimerResult.logs) console.error(log)
317
+ throw new Error('Display Case SSR primer bundle failed')
318
+ }
319
+ const ssrPrimerModule = (await import(
320
+ join(ssrOutDir, `${ssrPrimerName}.js`)
321
+ )) as { renderPrimerToHtml: () => PrimerHtmlResult }
322
+ renderPrimer = ssrPrimerModule.renderPrimerToHtml
323
+ }
324
+
325
+ // The render bundle above is rebuilt fresh from disk by Bun.build; the
326
+ // manifest comes from a fresh subprocess for the same reason (see above).
327
+ const manifest = await loadManifestFresh(pkgDir)
328
+ const placardById = new Map<string, string>()
329
+ for (const c of manifest.components) {
330
+ if (c.placardDoc) placardById.set(c.id, resolve(REPO_ROOT, c.placardDoc))
331
+ }
332
+ const globalCss = await readGlobalCss(pkgDir, config)
333
+ console.log(
334
+ ` ${manifest.components.length} component(s), ${manifest.components.reduce((n, c) => n + c.cases.length, 0)} case(s)`,
335
+ )
336
+ return { manifest, placardById, globalCss, renderCase, renderPrimer }
337
+ }
338
+
339
+ /**
340
+ * A classic (non-module) inline script that runs *before* the deferred module
341
+ * bundle. If a bundled module references a Node/Bun runtime global that is
342
+ * undefined in the browser (`process`/`Bun`), it throws during module
343
+ * evaluation — before React mounts, and before any error boundary or module-level
344
+ * handler exists — so every case would otherwise blank *silently*. This catches
345
+ * that uncaught error and paints a visible, explained banner instead. The same
346
+ * impurity is caught statically by the `page-component-purity` lint; this is the
347
+ * runtime backstop for anything that slips through (or a non-app package).
348
+ */
349
+ const ERROR_OVERLAY_SCRIPT = `<script>
350
+ window.addEventListener('error', function (e) {
351
+ var m = (e && e.message) || '';
352
+ if (!/\\b(process|Bun) is not defined\\b/.test(m)) return;
353
+ var root = document.getElementById('root');
354
+ if (root && !root.firstChild) {
355
+ root.innerHTML =
356
+ '<div style="margin:2rem;padding:1rem 1.25rem;border:1px solid #c00;border-radius:8px;font-family:ui-monospace,monospace;font-size:13px;line-height:1.5;color:#c00;background:#fff5f5">' +
357
+ '<strong>Display Case bundle error</strong><br>' +
358
+ 'A showcased component (or a module it imports) references <code>' + m.replace(/ is not defined.*/, '') + '</code>, ' +
359
+ 'which is undefined in the browser bundle. It threw on load, which blanks every case.<br>' +
360
+ 'Read env/runtime values in the route (or a config module) and pass them in as props. (' + m + ')' +
361
+ '</div>';
362
+ }
363
+ console.error('[display-case] runtime-global reference broke the bundle:', m);
364
+ });
365
+ </script>`
366
+
367
+ /**
368
+ * Dev-only live-reload client. Subscribes to the `/__livereload` SSE stream and
369
+ * reloads the page on a `reload` event (an in-process rebuild after editing the
370
+ * Display Case app — chrome, components, primer). It also reloads when the
371
+ * stream *reconnects* after dropping, which is how it picks up a backend change:
372
+ * `bun --watch` restarts the server process, the stream errors, and the reload
373
+ * fires on the fresh connection. Injected only when the server runs with `dev`.
374
+ */
375
+ const LIVERELOAD_SCRIPT = `<script>
376
+ (function () {
377
+ var seen = false;
378
+ function connect() {
379
+ var es = new EventSource('/__livereload');
380
+ es.onopen = function () { if (seen) location.reload(); seen = true; };
381
+ es.addEventListener('reload', function () { location.reload(); });
382
+ es.onerror = function () { es.close(); setTimeout(connect, 400); };
383
+ }
384
+ connect();
385
+ })();
386
+ </script>`
387
+
388
+ /**
389
+ * Runtime config the browse chrome reads to wire its own event stream: whether
390
+ * live reload is on (so it refetches the manifest + reloads the iframe on a
391
+ * rebuild — in non-dev, where there's no inline full-page reload), and whether
392
+ * a11y surfacing is configured (so it requests + receives scan results).
393
+ */
394
+ function clientConfigScript(cfg: {
395
+ reload: boolean
396
+ a11y: boolean
397
+ dev: boolean
398
+ }): string {
399
+ return `<script>window.__displayCase=${JSON.stringify(cfg)}</script>`
400
+ }
401
+
402
+ function shellHtml(
403
+ title: string,
404
+ globalCss: string,
405
+ vitrineCss: string,
406
+ tokensCss: string,
407
+ liveReload: boolean,
408
+ clientConfig: string,
409
+ doc: { theme: Theme; markup: string; ssr: boolean; seedScript: string },
410
+ ): string {
411
+ // Reset html/body and paint the themed surface on them. The theme is baked
412
+ // into <html> so the token background reaches the body edges from first paint
413
+ // (and the client's hydration finds a matching theme); the shell still tracks
414
+ // later theme toggles on the client. Background is the chrome's own `--dc-bg`
415
+ // (the Vitrine canvas), not a consumer token. Design-system tokens lead the
416
+ // <style> so chrome.css (last) can rely on them; the consumer's globalCss
417
+ // styles the isolated exhibit. `data-ssr` tells the client whether to adopt
418
+ // the rendered shell (1) or mount fresh (0). The seed (manifest/theme/a11y)
419
+ // is inlined before the module so the client hydrates from the same data.
420
+ const reset = 'html,body{margin:0;height:100%;background:var(--dc-bg)}'
421
+ return `<!doctype html><html lang="en" data-theme="${doc.theme}"><head><meta charset="utf-8"/><meta name="viewport" content="width=device-width, initial-scale=1"/><title>${title}</title>${FONT_LINKS}<style>${tokensCss}\n${globalCss}\n${reset}\n${vitrineCss}</style></head><body><div id="root" data-ssr="${doc.ssr ? '1' : '0'}">${doc.markup}</div>${ERROR_OVERLAY_SCRIPT}${doc.seedScript}${clientConfig}${liveReload ? LIVERELOAD_SCRIPT : ''}<script type="module" src="/dist/browser-entry.js"></script></body></html>`
422
+ }
423
+
424
+ /** The document-level state the render template bakes in, mirroring what the
425
+ * client otherwise sets imperatively on load (theme, the decorated/transparent
426
+ * surface, the fit-to-content mount), plus the pre-rendered case markup. */
427
+ interface RenderDoc {
428
+ theme: 'light' | 'dark'
429
+ /** Drop the document background (decorated exhibit on the stage grid). */
430
+ transparent: boolean
431
+ /** Shrink-wrap the mount to the case's natural width. */
432
+ fit: boolean
433
+ /** Pre-rendered `#root` inner markup (`''` for a browser-only case). */
434
+ markup: string
435
+ /** Whether `markup` is present, so the client adopts instead of mounting. */
436
+ ssr: boolean
437
+ /** Render-time (CSS-in-JS) styling collected by the style engines, as `<head>`
438
+ * markup placed after the static `<style>` block. `''` when none. */
439
+ headStyles?: string
440
+ }
441
+
442
+ /** The render state the server decodes from a `/render/...` address — the same
443
+ * shape `render-mount`'s `stateFromUrl` reads on the client, so the server's
444
+ * initial render and the client's hydration agree. */
445
+ interface ParsedRenderState extends RenderDoc {
446
+ componentId: string
447
+ caseId: string
448
+ width: number | null
449
+ tweaks: Record<string, string>
450
+ }
451
+
452
+ function parseRenderState(url: URL): ParsedRenderState {
453
+ const parts = url.pathname.split('/').filter(Boolean) // ['render', comp, case]
454
+ const p = url.searchParams
455
+ const tweaks: Record<string, string> = {}
456
+ for (const [k, v] of p) if (k.startsWith('t.')) tweaks[k.slice(2)] = v
457
+ const widthParam = p.get('width')
458
+ return {
459
+ componentId: parts[1] ?? '',
460
+ caseId: parts[2] ?? '',
461
+ theme: p.get('theme') === 'dark' ? 'dark' : 'light',
462
+ width: widthParam ? Number(widthParam) : null,
463
+ tweaks,
464
+ fit: p.get('fit') === '1',
465
+ transparent: p.get('transparent') === '1',
466
+ markup: '',
467
+ ssr: false,
468
+ }
469
+ }
470
+
471
+ function renderHtml(
472
+ globalCss: string,
473
+ vitrineCss: string,
474
+ liveReload: boolean,
475
+ doc: RenderDoc,
476
+ ): string {
477
+ // A complete document (title, lang, single <main> landmark) so the a11y runner
478
+ // reports only real component issues, not isolated-harness chrome violations.
479
+ // The body paints the theme surface (bg + fg) so a case renders on the same
480
+ // background the app gives it — without this, light dark-theme text would sit
481
+ // on a default-white body and fail contrast checks.
482
+ // Decorated exhibits (atoms…templates, marked `data-decorated` by render-mount)
483
+ // center their content in the frame: when the exhibit wraps or is narrower than
484
+ // the frame, its rows sit centered rather than top-left. Inline styles on a
485
+ // case still win, so an author can opt back to `flex-start`. Pages/flows are
486
+ // excluded — they own their full-bleed layout and must not be re-centered.
487
+ const exhibitCenter =
488
+ 'body[data-decorated] #root>*{justify-content:center;align-content:center}'
489
+ // The theme, decorated surface, and fit mount are baked in so the first paint
490
+ // is already correct (no flash, and the client's hydration finds a matching
491
+ // tree). The client still sets them on load — idempotent — and updates them on
492
+ // an in-place swap. `data-ssr` tells the client whether to adopt the markup
493
+ // (1) or mount fresh (0, a browser-only case that didn't render server-side).
494
+ const bodyAttrs = doc.transparent
495
+ ? ' data-decorated style="background:transparent"'
496
+ : ''
497
+ const rootAttrs = `${doc.fit ? ' style="width:fit-content"' : ''} data-ssr="${doc.ssr ? '1' : '0'}"`
498
+ // The Vitrine stylesheet follows globalCss so a dogfooded design-system case
499
+ // (the showcase's own `dcui-*`/`dcpl-*`/shell components) paints before
500
+ // scripts; its `--dc-*` tokens come from globalCss (the showcase lists the
501
+ // token files in globalStyles). For a non-dogfooding consumer these rules are
502
+ // inert chrome CSS — harmless in this dev-time-only preview document.
503
+ // The style engines' collected styling (if any) follows the static <style>
504
+ // block as its own discrete markup — emotion/styled-components tag their output
505
+ // with attributes the client runtime keys on to adopt it, so it must not be
506
+ // folded into the block above. Empty string when no engine is configured.
507
+ return `<!doctype html><html lang="en" data-theme="${doc.theme}" data-theme-pref="${doc.theme}"><head><meta charset="utf-8"/><meta name="viewport" content="width=device-width, initial-scale=1"/><title>Display Case render</title><style>html,body{margin:0}body{background:var(--color-bg);color:var(--color-fg);font-family:var(--font-sans, ui-sans-serif, system-ui, sans-serif)}${exhibitCenter}${globalCss}\n${vitrineCss}</style>${doc.headStyles ?? ''}</head><body${bodyAttrs}><main id="root"${rootAttrs}>${doc.markup}</main>${ERROR_OVERLAY_SCRIPT}${liveReload ? LIVERELOAD_SCRIPT : ''}<script type="module" src="/dist/render-entry.js"></script></body></html>`
508
+ }
509
+
510
+ function primerHtml(
511
+ globalCss: string,
512
+ tokensCss: string,
513
+ vitrineCss: string,
514
+ liveReload: boolean,
515
+ doc: {
516
+ theme: 'light' | 'dark'
517
+ markup: string
518
+ ssr: boolean
519
+ headStyles?: string
520
+ },
521
+ ): string {
522
+ // The Primer's own document. It needs the Vitrine `--dc-*` tokens (the
523
+ // reading-page + Display-card chrome paints from them), the consumer's
524
+ // globalCss (the embedded specimens are real consumer components), and the
525
+ // Vitrine stylesheet (the specimen + card chrome CSS, inlined server-side so
526
+ // it paints before scripts). A single <main> landmark keeps the a11y runner
527
+ // honest. The theme is baked into <html> so the first paint is correct; the
528
+ // mount re-applies it (idempotent) and accepts later theme messages.
529
+ // `data-ssr` tells the client whether to adopt the markup or mount fresh.
530
+ const reset = 'html,body{margin:0;height:100%;background:var(--dc-bg)}'
531
+ const rootAttrs = ` data-ssr="${doc.ssr ? '1' : '0'}"`
532
+ // Style-engine output follows the static <style> block as discrete markup (see
533
+ // renderHtml). `''` when no engine is configured.
534
+ return `<!doctype html><html lang="en" data-theme="${doc.theme}" data-theme-pref="${doc.theme}"><head><meta charset="utf-8"/><meta name="viewport" content="width=device-width, initial-scale=1"/><title>Primer</title>${FONT_LINKS}<style>${tokensCss}\n${globalCss}\n${reset}\n${vitrineCss}</style>${doc.headStyles ?? ''}</head><body><main id="root"${rootAttrs}>${doc.markup}</main>${ERROR_OVERLAY_SCRIPT}${liveReload ? LIVERELOAD_SCRIPT : ''}<script type="module" src="/dist/primer-entry.js"></script></body></html>`
535
+ }
536
+
537
+ /**
538
+ * Build a `Bun.build` `define` map that inlines the consumer's public env
539
+ * (`BUN_PUBLIC_*`) into the browser bundle — the same values the app's own
540
+ * production build inlines (`bun build … --env='BUN_PUBLIC_*'`).
541
+ *
542
+ * Why `define` and not `Bun.build({ env: 'BUN_PUBLIC_*' })`: the `env` option
543
+ * only inlines vars present in the environment Bun snapshotted at *process*
544
+ * startup (plus the CWD-relative `.env` Bun auto-loads). Display Case runs from
545
+ * the repo root, not the consumer package, so a public var defined only in
546
+ * `<pkg>/.env` (e.g. the API base URL) is absent at build time — and mutating
547
+ * `process.env` at runtime does not influence it. Left unreplaced, a
548
+ * `process.env.BUN_PUBLIC_*` read survives as a literal that throws
549
+ * `process is not defined` in the browser, blanking the whole single-bundle
550
+ * showcase (not just the case that reached it). `define` replaces the literal
551
+ * unconditionally, independent of env timing.
552
+ *
553
+ * Scoped strictly to the public prefix so non-public env (secrets, NODE_ENV,
554
+ * ports) never enters the bundle. A real exported `process.env` value wins over
555
+ * the file so local overrides still apply.
556
+ */
557
+ async function publicEnvDefines(
558
+ pkgDir: string,
559
+ ): Promise<Record<string, string>> {
560
+ const values = new Map<string, string>()
561
+ for (const name of ['.env', '.env.local']) {
562
+ const file = Bun.file(join(pkgDir, name))
563
+ if (!(await file.exists())) continue
564
+ for (const raw of (await file.text()).split('\n')) {
565
+ const line = raw.trim()
566
+ if (!line || line.startsWith('#')) continue
567
+ const eq = line.indexOf('=')
568
+ if (eq === -1) continue
569
+ const key = line
570
+ .slice(0, eq)
571
+ .replace(/^export\s+/, '')
572
+ .trim()
573
+ if (!key.startsWith('BUN_PUBLIC_')) continue
574
+ let value = line.slice(eq + 1).trim()
575
+ if (
576
+ (value.startsWith('"') && value.endsWith('"')) ||
577
+ (value.startsWith("'") && value.endsWith("'"))
578
+ ) {
579
+ value = value.slice(1, -1)
580
+ }
581
+ values.set(key, value)
582
+ }
583
+ }
584
+ // A real exported env value overrides the file (local override wins).
585
+ for (const [key, value] of Object.entries(process.env)) {
586
+ if (key.startsWith('BUN_PUBLIC_') && value !== undefined) {
587
+ values.set(key, value)
588
+ }
589
+ }
590
+ const defines: Record<string, string> = {}
591
+ for (const [key, value] of values) {
592
+ defines[`process.env.${key}`] = JSON.stringify(value)
593
+ }
594
+ return defines
595
+ }
596
+
597
+ export interface StartOptions {
598
+ port?: number
599
+ /**
600
+ * Developing Display Case *itself* (not just authoring cases). Enables live
601
+ * reload: the served documents subscribe to an SSE stream, the watcher also
602
+ * covers the app's own source (chrome, components, primer) and re-reads the
603
+ * inlined CSS, and a rebuild pushes a browser reload — so editing the chrome
604
+ * hot-reloads the open page. (We don't run under `bun --watch`: re-invoking
605
+ * `Bun.build` inside a watch process corrupts module resolution. Backend edits
606
+ * to this server need a manual restart; the client auto-reloads on the SSE
607
+ * reconnect that follows.) Off for normal case authoring and for `check`.
608
+ */
609
+ dev?: boolean
610
+ }
611
+
612
+ // Probe whether a port is bindable on localhost, without disturbing whatever
613
+ // might currently hold it.
614
+ const isPortFree = (port: number): Promise<boolean> =>
615
+ new Promise((res) => {
616
+ const srv = createServer()
617
+ srv.once('error', () => res(false))
618
+ srv.once('listening', () => srv.close(() => res(true)))
619
+ srv.listen(port, '127.0.0.1')
620
+ })
621
+
622
+ // Treat a requested port as *preferred*: if it is busy — e.g. another git
623
+ // worktree is already running Display Case on it — bump to the next free port so
624
+ // concurrent checkouts don't fight over one port. Falls back to the original if
625
+ // nothing nearby is free (Bun.serve then surfaces the bind error).
626
+ const firstFreePort = async (start: number): Promise<number> => {
627
+ for (let p = start; p < start + 100; p++) {
628
+ if (await isPortFree(p)) return p
629
+ }
630
+ return start
631
+ }
632
+
633
+ export async function startDisplayCase(
634
+ pkgDir: string,
635
+ opts: StartOptions = {},
636
+ ) {
637
+ const dev = opts.dev ?? false
638
+ // The headless check harness runs on port 0 and wants none of the live-server
639
+ // behaviors (watch, SSE, on-demand a11y) — it does its own one-shot scan.
640
+ const interactive = opts.port !== 0
641
+ const { config, configPath } = await resolveConfig(pkgDir)
642
+ let state = await rebuild(pkgDir, config, configPath)
643
+ // `let` so dev mode can re-read them when the chrome's CSS/tokens change.
644
+ let vitrineCss = await readVitrineCss()
645
+ let tokensCss = await readDesignTokens()
646
+ const outdir = join(cacheDir(pkgDir), 'dist')
647
+
648
+ // Live reload is the default for the interactive server (not just `--dev`): a
649
+ // rebuild reloads the stage iframe and refetches the manifest. In `--dev`
650
+ // (developing the chrome itself) the shell additionally does a full reload.
651
+ const reload = interactive
652
+
653
+ // Cases that threw under `renderToString` (browser-only). Once recorded, the
654
+ // server skips the server-render attempt and serves an adopt-free document
655
+ // that the client mounts. Cleared on rebuild so a fixed case recovers.
656
+ const browserOnly = new Set<string>()
657
+
658
+ // SSE fan-out: open streams (one per browser tab) that a rebuild pushes a
659
+ // `reload` event to, and that completed a11y scans push an `a11y` event to.
660
+ const encoder = new TextEncoder()
661
+ const reloadClients = new Set<ReadableStreamDefaultController>()
662
+ const broadcast = (chunk: Uint8Array) => {
663
+ for (const c of reloadClients) {
664
+ try {
665
+ c.enqueue(chunk)
666
+ } catch {
667
+ reloadClients.delete(c)
668
+ }
669
+ }
670
+ }
671
+ // A rebuild reloads the open tabs, but how depends on *what* changed: a change
672
+ // to the shell bundle itself (the chrome — its layout, the design-system
673
+ // components it composes) needs a full page reload, while a change that only
674
+ // affects rendered case/component content can reload just the stage iframe and
675
+ // refetch the manifest, preserving nav state. The event payload tells the
676
+ // client which; we detect a shell change by hashing the browser-entry bundle.
677
+ const shellBundleHash = async (): Promise<string> => {
678
+ const f = Bun.file(join(outdir, 'browser-entry.js'))
679
+ return (await f.exists())
680
+ ? Bun.hash(await f.arrayBuffer()).toString(16)
681
+ : ''
682
+ }
683
+ let shellHash = await shellBundleHash()
684
+ const triggerReload = (kind: 'shell' | 'content') =>
685
+ broadcast(encoder.encode(`event: reload\ndata: ${kind}\n\n`))
686
+
687
+ // On-demand a11y scanner (only when configured + interactive). Completed scans
688
+ // are pushed to open browsers over the SSE stream so the panel updates in place.
689
+ let scanner: A11yScanner | null = null
690
+
691
+ // Latest known verdict per `${component}__${case}__${theme}`, recorded as
692
+ // results flow through `onResult`. SSE only reaches tabs open at emit time, so
693
+ // start-up population (and any earlier scan) would be invisible to a tab that
694
+ // connects later; `/a11y/known` replays these so a fresh client seeds its nav.
695
+ const lastA11y = new Map<
696
+ string,
697
+ {
698
+ component: string
699
+ case: string
700
+ theme: 'light' | 'dark'
701
+ } & A11yScanStatus
702
+ >()
703
+
704
+ // Port 0 (the headless check harness) means "let the OS pick" — leave it. Any
705
+ // other port is preferred-not-mandatory, so two worktrees never collide.
706
+ const port = opts.port === 0 ? 0 : await firstFreePort(opts.port ?? 3100)
707
+
708
+ const server = Bun.serve({
709
+ port,
710
+ // The `/__livereload` SSE stream is long-lived; the default 10s idle timeout
711
+ // would close it and churn reconnects. Disable it for the interactive server.
712
+ // (0 = no timeout.) The check harness (non-interactive) keeps the default.
713
+ idleTimeout: interactive ? 0 : 10,
714
+ async fetch(req) {
715
+ const url = new URL(req.url)
716
+ const path = url.pathname
717
+
718
+ if (path === '/health') return new Response('ok')
719
+
720
+ if (interactive && path === '/__livereload') {
721
+ let self: ReadableStreamDefaultController | null = null
722
+ const stream = new ReadableStream({
723
+ start(controller) {
724
+ self = controller
725
+ reloadClients.add(controller)
726
+ controller.enqueue(encoder.encode(': connected\n\n'))
727
+ },
728
+ cancel() {
729
+ if (self) reloadClients.delete(self)
730
+ },
731
+ })
732
+ return new Response(stream, {
733
+ headers: {
734
+ 'content-type': 'text/event-stream',
735
+ 'cache-control': 'no-cache',
736
+ connection: 'keep-alive',
737
+ },
738
+ })
739
+ }
740
+
741
+ if (path === '/manifest.json') {
742
+ return Response.json(state.manifest)
743
+ }
744
+
745
+ // Every verdict known so far (start-up population + completed scans), so a
746
+ // client connecting after those SSE events still seeds its nav markers.
747
+ if (scanner && path === '/a11y/known') {
748
+ return Response.json([...lastA11y.values()])
749
+ }
750
+
751
+ // On-demand a11y for the viewed variant: cached → result, miss → enqueue a
752
+ // scan and report `pending` (the result later arrives over the SSE stream).
753
+ if (scanner && path === '/a11y') {
754
+ const component = url.searchParams.get('component')
755
+ const caseId = url.searchParams.get('case')
756
+ const theme = url.searchParams.get('theme')
757
+ if (!component || !caseId || (theme !== 'light' && theme !== 'dark')) {
758
+ return new Response('bad request', { status: 400 })
759
+ }
760
+ const force = url.searchParams.get('rescan') === '1'
761
+ return Response.json(
762
+ await scanner.request(component, caseId, theme, force),
763
+ )
764
+ }
765
+
766
+ if (path.startsWith('/dist/')) {
767
+ const file = Bun.file(join(outdir, path.slice('/dist/'.length)))
768
+ return (await file.exists())
769
+ ? new Response(file)
770
+ : new Response('not found', { status: 404 })
771
+ }
772
+
773
+ if (path.startsWith('/doc/')) {
774
+ const id = path.slice('/doc/'.length)
775
+ const docPath = state.placardById.get(id)
776
+ if (!docPath) return new Response('no doc', { status: 404 })
777
+ return new Response(Bun.file(docPath), {
778
+ headers: { 'content-type': 'text/markdown; charset=utf-8' },
779
+ })
780
+ }
781
+
782
+ // The Primer's chrome-free document lives under the reserved
783
+ // `/render/primer` name — a sibling of the other `/render/*` snapshots,
784
+ // not the SPA's `/primer` browse route (handled by the shell fallthrough
785
+ // below). Matched before the generic `/render/*` so it's served as the
786
+ // Primer, never mistaken for a component render of an id `primer`.
787
+ if (path === '/render/primer') {
788
+ const scanning = url.searchParams.has('dcscan')
789
+ const theme =
790
+ url.searchParams.get('theme') === 'dark' ? 'dark' : 'light'
791
+ // Pre-render the primer (prose + live specimens) into the document so it
792
+ // reads without scripting. A browser-only specimen makes the renderer
793
+ // report `browserOnly`; the whole primer then falls back to client
794
+ // rendering (delivered empty for the client to mount).
795
+ let markup = ''
796
+ let ssr = false
797
+ let headStyles: string | undefined
798
+ if (state.renderPrimer) {
799
+ const result = state.renderPrimer()
800
+ if (result.browserOnly) {
801
+ console.warn(
802
+ ` ⚠ primer can't render server-side (${result.error ?? 'threw'}); the client will render it`,
803
+ )
804
+ } else {
805
+ markup = result.html
806
+ ssr = true
807
+ headStyles = result.headStyles
808
+ }
809
+ }
810
+ return new Response(
811
+ primerHtml(
812
+ state.globalCss,
813
+ tokensCss,
814
+ vitrineCss,
815
+ reload && !scanning,
816
+ {
817
+ theme,
818
+ markup,
819
+ ssr,
820
+ headStyles,
821
+ },
822
+ ),
823
+ {
824
+ headers: { 'content-type': 'text/html; charset=utf-8' },
825
+ },
826
+ )
827
+ }
828
+
829
+ if (path === '/render' || path.startsWith('/render/')) {
830
+ // The a11y scanner appends `?dcscan=1` and waits for network idle, which
831
+ // an open live-reload SSE would never reach — so omit it for that fetch.
832
+ const scanning = url.searchParams.has('dcscan')
833
+ const rs = parseRenderState(url)
834
+ const key = `${rs.componentId}/${rs.caseId}`
835
+ // Pre-render the case to markup, baked into the document so it's present
836
+ // before the page's scripts run. A browser-only case (or one already
837
+ // recorded as such) is served adopt-free for the client to mount.
838
+ if (rs.componentId && rs.caseId && !browserOnly.has(key)) {
839
+ const result = state.renderCase({
840
+ componentId: rs.componentId,
841
+ caseId: rs.caseId,
842
+ width: rs.width,
843
+ tweaks: rs.tweaks,
844
+ })
845
+ if (result.browserOnly) {
846
+ browserOnly.add(key)
847
+ console.warn(
848
+ ` ⚠ ${key} can't render server-side (${result.error ?? 'threw'}); the client will render it`,
849
+ )
850
+ } else {
851
+ rs.markup = result.html
852
+ rs.ssr = true
853
+ rs.headStyles = result.headStyles
854
+ }
855
+ }
856
+ return new Response(
857
+ renderHtml(state.globalCss, vitrineCss, reload && !scanning, rs),
858
+ {
859
+ headers: { 'content-type': 'text/html; charset=utf-8' },
860
+ },
861
+ )
862
+ }
863
+
864
+ // Shell handles `/`, `/primer`, and all `/c/...` browse routes. The server
865
+ // pre-renders the shell from the in-memory manifest + this request's route
866
+ // so the landing surface and every deep link arrive painted; the client
867
+ // adopts it. The shell does a full reload only in `--dev` (chrome may have
868
+ // changed); the runtime config drives the non-dev iframe + manifest refresh.
869
+ const theme: Theme =
870
+ url.searchParams.get('theme') === 'dark' ? 'dark' : 'light'
871
+ const a11y = scanner !== null
872
+ const shell = renderShellToHtml({
873
+ manifest: state.manifest,
874
+ pathname: path,
875
+ search: url.search,
876
+ theme,
877
+ a11y,
878
+ })
879
+ const seedScript = `<script>window.__dcSeed=${JSON.stringify({ manifest: state.manifest, theme, a11y })}</script>`
880
+ return new Response(
881
+ shellHtml(
882
+ state.manifest.title,
883
+ state.globalCss,
884
+ vitrineCss,
885
+ tokensCss,
886
+ dev,
887
+ interactive ? clientConfigScript({ reload, a11y, dev }) : '',
888
+ { theme, markup: shell.html, ssr: shell.ssr, seedScript },
889
+ ),
890
+ {
891
+ headers: { 'content-type': 'text/html; charset=utf-8' },
892
+ },
893
+ )
894
+ },
895
+ })
896
+
897
+ // Build the on-demand scanner now that the server has a URL to scan against.
898
+ if (interactive && config.a11y?.enabled) {
899
+ const base = String(server.url).replace(/\/$/, '')
900
+ scanner = createA11yScanner({
901
+ pkgDir,
902
+ config,
903
+ baseUrl: () => base,
904
+ caseFileAbs: (id) => {
905
+ const c = state.manifest.components.find((x) => x.id === id)
906
+ return c ? resolve(REPO_ROOT, c.caseFile) : null
907
+ },
908
+ onResult: (component, caseId, theme, status) => {
909
+ // Remember the latest verdict so late-joining tabs can replay it, then
910
+ // push it to the tabs already listening.
911
+ lastA11y.set(`${component}__${caseId}__${theme}`, {
912
+ component,
913
+ case: caseId,
914
+ theme,
915
+ ...status,
916
+ })
917
+ broadcast(
918
+ encoder.encode(
919
+ `event: a11y\ndata: ${JSON.stringify({ component, case: caseId, theme, ...status })}\n\n`,
920
+ ),
921
+ )
922
+ },
923
+ })
924
+
925
+ // Populate the nav at start-up per the configured mode (default 'off' — a
926
+ // no-op). Detached: scanning must never delay the server becoming reachable,
927
+ // and `refresh` work rides the scanner's own bounded queue.
928
+ const startupMode = config.a11y?.startup ?? 'off'
929
+ if (startupMode !== 'off') {
930
+ const themes = config.a11y?.themes ?? ['light', 'dark']
931
+ const variants = state.manifest.components.flatMap((c) =>
932
+ c.cases.flatMap((cs) =>
933
+ themes.map((theme) => ({
934
+ componentId: c.id,
935
+ caseId: cs.id,
936
+ theme,
937
+ })),
938
+ ),
939
+ )
940
+ void scanner.populateAtStartup(variants, startupMode)
941
+ }
942
+ }
943
+
944
+ // Debounced rebuild. Refreshes the manifest + bundle, drops the a11y cache's
945
+ // in-flight bookkeeping (the on-disk hashes still decide what re-scans), and —
946
+ // when reload is on — pushes a reload so the iframe + manifest refresh. In dev
947
+ // it also re-reads the inlined chrome CSS/tokens (the shell full-reloads).
948
+ let timer: ReturnType<typeof setTimeout> | null = null
949
+ const scheduleRebuild = (label: string) => {
950
+ if (timer) clearTimeout(timer)
951
+ timer = setTimeout(async () => {
952
+ try {
953
+ console.log(`↻ ${label}, rebuilding…`)
954
+ if (dev) {
955
+ vitrineCss = await readVitrineCss()
956
+ tokensCss = await readDesignTokens()
957
+ }
958
+ state = await rebuild(pkgDir, config, configPath)
959
+ browserOnly.clear()
960
+ scanner?.invalidateAll()
961
+ if (reload) {
962
+ // Full-reload the tab when the chrome bundle changed; otherwise just
963
+ // refresh the rendered content (iframe + manifest), keeping nav state.
964
+ const nextHash = await shellBundleHash()
965
+ const kind = nextHash !== shellHash ? 'shell' : 'content'
966
+ shellHash = nextHash
967
+ triggerReload(kind)
968
+ }
969
+ } catch (err) {
970
+ console.error(err)
971
+ }
972
+ }, 150)
973
+ }
974
+
975
+ // Watch the consumer package's source. Any app-relevant source change rebuilds
976
+ // — component implementations and styles, not just case/doc/primer files — so
977
+ // editing a component hot-reloads its rendered case (and re-evaluates a11y).
978
+ //
979
+ // We watch via @parcel/watcher (native FSEvents / inotify / ReadDirectoryChangesW)
980
+ // rather than node's `fs.watch`: recursive `fs.watch` on macOS drops events for
981
+ // atomic/rename writes (most editor saves) and coalesces rapid changes, so an
982
+ // edit would silently fail to rebuild and the open page would serve stale code.
983
+ // @parcel/watcher delivers reliable, absolute-path events across platforms.
984
+ const srcDir = join(pkgDir, 'src')
985
+ const watchSrc = interactive && existsSync(srcDir)
986
+ const watchHere = dev && resolve(srcDir) !== resolve(HERE)
987
+ // Load the native watcher only on the paths that actually watch — `check` and
988
+ // the per-rebuild `--print-manifest` subprocess import this module too, and
989
+ // shouldn't pay to dlopen the binding.
990
+ const { subscribe } =
991
+ watchSrc || watchHere
992
+ ? await import('@parcel/watcher')
993
+ : { subscribe: undefined }
994
+ const watched = /\.(tsx?|css|mdx)$|\.placard\.md$/
995
+ const ignore = ['node_modules', '.git', '.display-case', 'dist']
996
+ if (subscribe && watchSrc) {
997
+ await subscribe(
998
+ srcDir,
999
+ (err, events) => {
1000
+ if (err) return
1001
+ if (events.some((e) => watched.test(e.path)))
1002
+ scheduleRebuild('change detected')
1003
+ },
1004
+ { ignore },
1005
+ )
1006
+ }
1007
+
1008
+ // Dev, showcasing a *different* package: also watch Display Case's own UI
1009
+ // source so editing the chrome hot-reloads even when `pkgDir` is elsewhere.
1010
+ if (subscribe && watchHere) {
1011
+ await subscribe(
1012
+ HERE,
1013
+ (err, events) => {
1014
+ if (err) return
1015
+ if (events.some((e) => /\.(tsx?|css)$/.test(e.path)))
1016
+ scheduleRebuild('app source changed')
1017
+ },
1018
+ { ignore },
1019
+ )
1020
+ }
1021
+
1022
+ return server
1023
+ }
1024
+
1025
+ /**
1026
+ * Build the manifest once and return it (used by `--print-manifest`, and by the
1027
+ * dev server's per-rebuild subprocess). Load errors are written to stderr so the
1028
+ * JSON stays the sole thing on stdout; a spawning parent can relay them.
1029
+ */
1030
+ export async function getManifest(pkgDir: string): Promise<Manifest> {
1031
+ const { config } = await resolveConfig(pkgDir)
1032
+ const files = await discoverCaseFiles(pkgDir, config)
1033
+ const { modules, errors } = await loadModules(files)
1034
+ for (const e of errors) console.error(` ✗ ${relPath(e.file)}: ${e.error}`)
1035
+ const hasPrimer = primerFile(pkgDir, config) !== null
1036
+ return buildManifest(modules, config, hasPrimer).manifest
1037
+ }
1038
+
1039
+ export { slugify }