@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,72 @@
1
+ import { describe, expect, test } from 'bun:test'
2
+ import type { StyleEngine } from '../index'
3
+ import { renderWithStyles } from './collect-styles'
4
+
5
+ /** A stub engine that wraps the tree in a marker element and emits a tagged
6
+ * `<style>` whose body echoes a per-instance counter — so a fresh instance per
7
+ * render is observable (isolation), as is the wrap actually being applied. */
8
+ function markerEngine(key: string, counter: { n: number }): StyleEngine {
9
+ return () => {
10
+ const id = ++counter.n
11
+ return {
12
+ wrap: (node) => (
13
+ <div data-wrapped={key} data-instance={id}>
14
+ {node}
15
+ </div>
16
+ ),
17
+ collect: (html) =>
18
+ `<style data-engine="${key}" data-instance="${id}" data-saw-wrap="${html.includes(
19
+ `data-wrapped="${key}"`,
20
+ )}"></style>`,
21
+ }
22
+ }
23
+ }
24
+
25
+ describe('renderWithStyles', () => {
26
+ test('no engines: plain render, empty headStyles (inert when unused)', () => {
27
+ const { html, headStyles } = renderWithStyles(<p>hi</p>, undefined)
28
+ expect(html).toBe('<p>hi</p>')
29
+ expect(headStyles).toBe('')
30
+ })
31
+
32
+ test('empty engine array is also inert', () => {
33
+ const { html, headStyles } = renderWithStyles(<p>hi</p>, [])
34
+ expect(html).toBe('<p>hi</p>')
35
+ expect(headStyles).toBe('')
36
+ })
37
+
38
+ test('applies wrap and captures collect output (which saw the wrapped markup)', () => {
39
+ const counter = { n: 0 }
40
+ const { html, headStyles } = renderWithStyles(<p>hi</p>, [
41
+ markerEngine('emotion', counter),
42
+ ])
43
+ expect(html).toContain('data-wrapped="emotion"')
44
+ expect(headStyles).toContain('data-engine="emotion"')
45
+ // collect() received the rendered markup including the wrapper.
46
+ expect(headStyles).toContain('data-saw-wrap="true"')
47
+ })
48
+
49
+ test('multiple engines nest in array order and concatenate their output', () => {
50
+ const counter = { n: 0 }
51
+ const { html, headStyles } = renderWithStyles(<p>hi</p>, [
52
+ markerEngine('outer', counter),
53
+ markerEngine('inner', counter),
54
+ ])
55
+ // First engine is outermost: <div outer><div inner><p/></div></div>.
56
+ expect(html.indexOf('data-wrapped="outer"')).toBeLessThan(
57
+ html.indexOf('data-wrapped="inner"'),
58
+ )
59
+ expect(headStyles).toContain('data-engine="outer"')
60
+ expect(headStyles).toContain('data-engine="inner"')
61
+ })
62
+
63
+ test('factory runs once per render — a fresh, isolated store each time', () => {
64
+ const counter = { n: 0 }
65
+ const engine = markerEngine('x', counter)
66
+ const a = renderWithStyles(<p>a</p>, [engine])
67
+ const b = renderWithStyles(<p>b</p>, [engine])
68
+ // Distinct instance ids prove the factory was re-invoked (not a shared store).
69
+ expect(a.headStyles).toContain('data-instance="1"')
70
+ expect(b.headStyles).toContain('data-instance="2"')
71
+ })
72
+ })
@@ -0,0 +1,33 @@
1
+ import type { ReactNode } from 'react'
2
+ import { renderToString } from 'react-dom/server'
3
+ import type { StyleEngine } from '../index'
4
+
5
+ /**
6
+ * Render a tree to markup, applying any configured style engines so render-time
7
+ * (CSS-in-JS) styling is collected and delivered before scripting.
8
+ *
9
+ * Each engine is a factory invoked **once per render** (see {@link StyleEngine}),
10
+ * giving every render an isolated style store — one case's styling can never leak
11
+ * into another's document. Engines wrap the tree in array order (the first is
12
+ * outermost); after `renderToString`, each collector's `collect(html)` output is
13
+ * concatenated into a single `headStyles` string for the document `<head>`.
14
+ *
15
+ * With no engines configured, the tree is rendered unwrapped and `headStyles` is
16
+ * `''`, so the resulting document is byte-identical to its engine-free form.
17
+ */
18
+ export function renderWithStyles(
19
+ tree: ReactNode,
20
+ engines: StyleEngine[] | undefined,
21
+ ): { html: string; headStyles: string } {
22
+ const collectors = (engines ?? []).map((make) => make())
23
+ let wrapped = tree
24
+ // Apply from last to first so the first engine ends up outermost.
25
+ for (let i = collectors.length - 1; i >= 0; i--) {
26
+ wrapped = collectors[i].wrap(wrapped)
27
+ }
28
+ const html = renderToString(wrapped)
29
+ const headStyles = collectors
30
+ .map((collector) => collector.collect(html))
31
+ .join('')
32
+ return { html, headStyles }
33
+ }
@@ -0,0 +1,184 @@
1
+ import { describe, expect, test } from 'bun:test'
2
+ import type { Manifest } from '../core/manifest'
3
+ import { type DocAssets, primerDoc, renderDoc, shellDoc } from './documents'
4
+
5
+ const assets: DocAssets = {
6
+ browser: '/assets/browser-abc123.js',
7
+ render: '/assets/render-def456.js',
8
+ primer: '/assets/primer-ghi789.js',
9
+ }
10
+
11
+ const manifest: Manifest = {
12
+ title: 'My Showcase',
13
+ components: [],
14
+ primer: true,
15
+ landing: 'primer',
16
+ }
17
+
18
+ describe('shellDoc', () => {
19
+ const doc = () =>
20
+ shellDoc({
21
+ title: 'My Showcase',
22
+ tokensCss: '.tok{}',
23
+ globalCss: '.glob{}',
24
+ vitrineCss: '.vit{}',
25
+ theme: 'dark',
26
+ markup: '<header>chrome</header>',
27
+ ssr: true,
28
+ manifest,
29
+ a11y: false,
30
+ assets,
31
+ })
32
+
33
+ test('is a themed HTML document carrying the title and pre-rendered markup', () => {
34
+ const html = doc()
35
+ expect(html.startsWith('<!doctype html>')).toBe(true)
36
+ expect(html).toContain('data-theme="dark"')
37
+ expect(html).toContain('<title>My Showcase</title>')
38
+ expect(html).toContain('<header>chrome</header>')
39
+ })
40
+
41
+ test('inlines all of the supplied CSS layers', () => {
42
+ const html = doc()
43
+ expect(html).toContain('.tok{}')
44
+ expect(html).toContain('.glob{}')
45
+ expect(html).toContain('.vit{}')
46
+ })
47
+
48
+ test('seeds the manifest, theme, and a11y flag for hydration', () => {
49
+ const html = doc()
50
+ expect(html).toContain('window.__dcSeed=')
51
+ expect(html).toContain('"My Showcase"')
52
+ expect(html).toContain('"theme":"dark"')
53
+ expect(html).toContain('"a11y":false')
54
+ })
55
+
56
+ test('marks the root server-rendered and references the browser entry', () => {
57
+ expect(doc()).toContain('data-ssr="1"')
58
+ expect(doc()).toContain('src="/assets/browser-abc123.js"')
59
+ })
60
+
61
+ test('marks the root as client-only when ssr is false', () => {
62
+ const html = shellDoc({
63
+ title: 'X',
64
+ tokensCss: '',
65
+ globalCss: '',
66
+ vitrineCss: '',
67
+ theme: 'light',
68
+ markup: '',
69
+ ssr: false,
70
+ manifest,
71
+ a11y: false,
72
+ assets,
73
+ })
74
+ expect(html).toContain('data-ssr="0"')
75
+ })
76
+
77
+ test('carries no dev live-reload machinery', () => {
78
+ expect(doc()).not.toContain('__livereload')
79
+ expect(doc().toLowerCase()).not.toContain('eventsource')
80
+ })
81
+ })
82
+
83
+ describe('renderDoc', () => {
84
+ const doc = (over: Partial<Parameters<typeof renderDoc>[0]> = {}) =>
85
+ renderDoc({
86
+ globalCss: '.g{}',
87
+ vitrineCss: '.vit{}',
88
+ theme: 'light',
89
+ transparent: false,
90
+ fit: false,
91
+ markup: '<button>x</button>',
92
+ ssr: true,
93
+ assets,
94
+ ...over,
95
+ })
96
+
97
+ test('renders the isolated case markup with both theme attributes', () => {
98
+ const html = doc()
99
+ expect(html).toContain('data-theme="light"')
100
+ expect(html).toContain('data-theme-pref="light"')
101
+ expect(html).toContain('<button>x</button>')
102
+ expect(html).toContain('src="/assets/render-def456.js"')
103
+ })
104
+
105
+ test('inlines the global and Vitrine CSS so a dogfooded case is styled pre-script', () => {
106
+ const html = doc()
107
+ expect(html).toContain('.g{}')
108
+ expect(html).toContain('.vit{}')
109
+ })
110
+
111
+ test('a transparent exhibit decorates the body and clears its background', () => {
112
+ const html = doc({ transparent: true })
113
+ expect(html).toContain('data-decorated')
114
+ expect(html).toContain('background:transparent')
115
+ })
116
+
117
+ test('a fitted exhibit shrink-wraps the root to its content width', () => {
118
+ expect(doc({ fit: true })).toContain('width:fit-content')
119
+ expect(doc({ fit: false })).not.toContain('width:fit-content')
120
+ })
121
+
122
+ test('reflects the ssr flag on the root', () => {
123
+ expect(doc({ ssr: true })).toContain('data-ssr="1"')
124
+ expect(doc({ ssr: false })).toContain('data-ssr="0"')
125
+ })
126
+
127
+ test('omitting headStyles is byte-identical to passing empty (inert when unused)', () => {
128
+ expect(doc({})).toBe(doc({ headStyles: '' }))
129
+ })
130
+
131
+ test('style-engine output is a discrete tag after the static <style> block', () => {
132
+ const tag = '<style data-emotion="css 1ab2">.x{}</style>'
133
+ const html = doc({ headStyles: tag })
134
+ expect(html).toContain(tag)
135
+ // Placed after the base block closes, before the head closes — not folded
136
+ // into the static <style> (so emotion's data-emotion adoption markers survive).
137
+ expect(html).toContain(`</style>${tag}</head>`)
138
+ })
139
+ })
140
+
141
+ describe('primerDoc', () => {
142
+ const doc = () =>
143
+ primerDoc({
144
+ tokensCss: '.tok{}',
145
+ globalCss: '.glob{}',
146
+ vitrineCss: '.vit{}',
147
+ theme: 'dark',
148
+ markup: '<article>primer</article>',
149
+ ssr: true,
150
+ assets,
151
+ })
152
+
153
+ test('is the themed primer reading page with its markup and entry', () => {
154
+ const html = doc()
155
+ expect(html).toContain('<title>Primer</title>')
156
+ expect(html).toContain('data-theme="dark"')
157
+ expect(html).toContain('data-theme-pref="dark"')
158
+ expect(html).toContain('<article>primer</article>')
159
+ expect(html).toContain('src="/assets/primer-ghi789.js"')
160
+ })
161
+
162
+ test('inlines the token, global, and Vitrine CSS and marks the ssr root', () => {
163
+ const html = doc()
164
+ expect(html).toContain('.tok{}')
165
+ expect(html).toContain('.glob{}')
166
+ expect(html).toContain('.vit{}')
167
+ expect(html).toContain('data-ssr="1"')
168
+ })
169
+
170
+ test('style-engine output is a discrete tag after the static <style> block', () => {
171
+ const tag = '<style data-emotion="css 9zz">.y{}</style>'
172
+ const html = primerDoc({
173
+ tokensCss: '.tok{}',
174
+ globalCss: '.glob{}',
175
+ vitrineCss: '.vit{}',
176
+ theme: 'dark',
177
+ markup: '<article>primer</article>',
178
+ ssr: true,
179
+ headStyles: tag,
180
+ assets,
181
+ })
182
+ expect(html).toContain(`</style>${tag}</head>`)
183
+ })
184
+ })
@@ -0,0 +1,88 @@
1
+ import type { Manifest } from '../core/manifest'
2
+ import type { Theme } from '../ui/shell-core'
3
+
4
+ /**
5
+ * Production HTML document templates for a published build. They mirror the dev
6
+ * server's documents but drop every development-only inject — no live-reload SSE
7
+ * client, no `process/Bun is not defined` error overlay — and reference the
8
+ * content-hashed asset URLs the production bundle emits (so a host can cache them
9
+ * indefinitely). The React trees themselves are produced by the *shared*
10
+ * renderers (`ssr-shell`, `ssr-render`, `ssr-primer`); only the envelope here
11
+ * differs from dev, by necessity (hashed assets vs. a fixed dev path).
12
+ */
13
+
14
+ const FONT_LINKS =
15
+ '<link rel="preconnect" href="https://fonts.googleapis.com"/>' +
16
+ '<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin/>' +
17
+ '<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"/>'
18
+
19
+ /** Content-hashed entry URLs the production build emitted (already base-prefixed). */
20
+ export interface DocAssets {
21
+ browser: string
22
+ render: string
23
+ primer: string
24
+ }
25
+
26
+ /** The browse shell document: pre-rendered chrome + inlined seed, hydrated by the
27
+ * browser entry. No dev injects. */
28
+ export function shellDoc(opts: {
29
+ title: string
30
+ tokensCss: string
31
+ globalCss: string
32
+ vitrineCss: string
33
+ theme: Theme
34
+ markup: string
35
+ ssr: boolean
36
+ manifest: Manifest
37
+ a11y: boolean
38
+ assets: DocAssets
39
+ }): string {
40
+ const reset = 'html,body{margin:0;height:100%;background:var(--dc-bg)}'
41
+ const seed = JSON.stringify({
42
+ manifest: opts.manifest,
43
+ theme: opts.theme,
44
+ a11y: opts.a11y,
45
+ })
46
+ return `<!doctype html><html lang="en" data-theme="${opts.theme}"><head><meta charset="utf-8"/><meta name="viewport" content="width=device-width, initial-scale=1"/><title>${opts.title}</title>${FONT_LINKS}<style>${opts.tokensCss}\n${opts.globalCss}\n${reset}\n${opts.vitrineCss}</style></head><body><div id="root" data-ssr="${opts.ssr ? '1' : '0'}">${opts.markup}</div><script>window.__dcSeed=${seed}</script><script type="module" src="${opts.assets.browser}"></script></body></html>`
47
+ }
48
+
49
+ /** The isolated case render document. */
50
+ export function renderDoc(opts: {
51
+ globalCss: string
52
+ vitrineCss: string
53
+ theme: Theme
54
+ transparent: boolean
55
+ fit: boolean
56
+ markup: string
57
+ ssr: boolean
58
+ /** Style-engine output, placed after the static <style> block. `''` when none. */
59
+ headStyles?: string
60
+ assets: DocAssets
61
+ }): string {
62
+ const exhibitCenter =
63
+ 'body[data-decorated] #root>*{justify-content:center;align-content:center}'
64
+ const bodyAttrs = opts.transparent
65
+ ? ' data-decorated style="background:transparent"'
66
+ : ''
67
+ const rootAttrs = `${opts.fit ? ' style="width:fit-content"' : ''} data-ssr="${opts.ssr ? '1' : '0'}"`
68
+ // The Vitrine stylesheet follows globalCss so a dogfooded design-system case
69
+ // paints before scripts; for a non-dogfooding consumer these are inert chrome
70
+ // rules in a dev-time-only preview document (see server.ts renderHtml).
71
+ return `<!doctype html><html lang="en" data-theme="${opts.theme}" data-theme-pref="${opts.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}${opts.globalCss}\n${opts.vitrineCss}</style>${opts.headStyles ?? ''}</head><body${bodyAttrs}><main id="root"${rootAttrs}>${opts.markup}</main><script type="module" src="${opts.assets.render}"></script></body></html>`
72
+ }
73
+
74
+ /** The primer reading-page document. */
75
+ export function primerDoc(opts: {
76
+ tokensCss: string
77
+ globalCss: string
78
+ vitrineCss: string
79
+ theme: Theme
80
+ markup: string
81
+ ssr: boolean
82
+ /** Style-engine output, placed after the static <style> block. `''` when none. */
83
+ headStyles?: string
84
+ assets: DocAssets
85
+ }): string {
86
+ const reset = 'html,body{margin:0;height:100%;background:var(--dc-bg)}'
87
+ return `<!doctype html><html lang="en" data-theme="${opts.theme}" data-theme-pref="${opts.theme}"><head><meta charset="utf-8"/><meta name="viewport" content="width=device-width, initial-scale=1"/><title>Primer</title>${FONT_LINKS}<style>${opts.tokensCss}\n${opts.globalCss}\n${reset}\n${opts.vitrineCss}</style>${opts.headStyles ?? ''}</head><body><main id="root" data-ssr="${opts.ssr ? '1' : '0'}">${opts.markup}</main><script type="module" src="${opts.assets.primer}"></script></body></html>`
88
+ }
@@ -0,0 +1,160 @@
1
+ import { describe, expect, test } from 'bun:test'
2
+ import type { ReactNode } from 'react'
3
+ import { renderToStaticMarkup } from 'react-dom/server'
4
+ import { type DisplayCaseConfig, defineCases, tweak } from '../index'
5
+ import {
6
+ type CaseTreeState,
7
+ caseTree,
8
+ encodeOverrides,
9
+ resolveTweaks,
10
+ } from './render-node'
11
+
12
+ const schema = {
13
+ label: tweak.text('Save'),
14
+ size: tweak.choice(['sm', 'lg'], 'sm'),
15
+ count: tweak.number(3),
16
+ on: tweak.boolean(false),
17
+ }
18
+
19
+ // `resolveTweaks` is typed over the wide `TweakSchema`, so its return widens to
20
+ // an imprecise mapped type; assert against the concrete value shape this schema
21
+ // produces.
22
+ interface Resolved {
23
+ label: string
24
+ size: string
25
+ count: number
26
+ on: boolean
27
+ }
28
+ const resolve = (tweaks: Record<string, string>): Resolved =>
29
+ resolveTweaks(schema, tweaks) as unknown as Resolved
30
+
31
+ const state = (over: Partial<CaseTreeState>): CaseTreeState => ({
32
+ componentId: 'button',
33
+ caseId: 'default',
34
+ width: null,
35
+ tweaks: {},
36
+ ...over,
37
+ })
38
+
39
+ const NO_CONFIG: DisplayCaseConfig = {} as DisplayCaseConfig
40
+
41
+ describe('resolveTweaks', () => {
42
+ test('falls back to each descriptor default when the key is absent', () => {
43
+ expect(resolve({})).toEqual({
44
+ label: 'Save',
45
+ size: 'sm',
46
+ count: 3,
47
+ on: false,
48
+ })
49
+ })
50
+
51
+ test('decodes booleans from "1"/"true" and treats anything else as false', () => {
52
+ expect(resolve({ on: '1' }).on).toBe(true)
53
+ expect(resolve({ on: 'true' }).on).toBe(true)
54
+ expect(resolve({ on: '0' }).on).toBe(false)
55
+ expect(resolve({ on: 'nope' }).on).toBe(false)
56
+ })
57
+
58
+ test('coerces numbers and passes text/choice through verbatim', () => {
59
+ const v = resolve({ count: '42', label: 'Go', size: 'lg' })
60
+ expect(v.count).toBe(42)
61
+ expect(v.label).toBe('Go')
62
+ expect(v.size).toBe('lg')
63
+ })
64
+ })
65
+
66
+ describe('encodeOverrides', () => {
67
+ test('returns an empty map for no overrides', () => {
68
+ expect(encodeOverrides()).toEqual({})
69
+ expect(encodeOverrides(undefined)).toEqual({})
70
+ })
71
+
72
+ test('serializes booleans to "1"/"0" and stringifies the rest', () => {
73
+ expect(
74
+ encodeOverrides({ on: true, off: false, count: 7, label: 'Go' }),
75
+ ).toEqual({
76
+ on: '1',
77
+ off: '0',
78
+ count: '7',
79
+ label: 'Go',
80
+ })
81
+ })
82
+
83
+ test('round-trips numbers and booleans back through resolveTweaks', () => {
84
+ const decoded = resolve(encodeOverrides({ count: 42, on: true }))
85
+ expect(decoded.count).toBe(42)
86
+ expect(decoded.on).toBe(true)
87
+ })
88
+ })
89
+
90
+ describe('caseTree', () => {
91
+ const Noop = () => {}
92
+
93
+ test('renders the not-found node when no such case exists', () => {
94
+ const html = renderToStaticMarkup(
95
+ caseTree(
96
+ [],
97
+ NO_CONFIG,
98
+ state({ componentId: 'ghost', caseId: 'x' }),
99
+ Noop,
100
+ ),
101
+ )
102
+ expect(html).toContain('dc-render-missing')
103
+ expect(html).toContain('No such case: ghost/x')
104
+ })
105
+
106
+ test('invokes a simple thunk case', () => {
107
+ const modules = [
108
+ defineCases('Button', {
109
+ Default: () => <button type="button">Hi</button>,
110
+ }),
111
+ ]
112
+ const html = renderToStaticMarkup(
113
+ caseTree(modules, NO_CONFIG, state({}), Noop),
114
+ )
115
+ expect(html).toContain('<button')
116
+ expect(html).toContain('Hi')
117
+ })
118
+
119
+ test('passes resolved tweak values into a tweaked case render', () => {
120
+ const modules = [
121
+ defineCases('Button', {
122
+ Default: {
123
+ tweaks: { label: tweak.text('Save') },
124
+ render: (v) => <span>{v.label}</span>,
125
+ },
126
+ }),
127
+ ]
128
+ const html = renderToStaticMarkup(
129
+ caseTree(
130
+ modules,
131
+ NO_CONFIG,
132
+ state({ tweaks: { label: 'Custom' } }),
133
+ Noop,
134
+ ),
135
+ )
136
+ expect(html).toContain('<span>Custom</span>')
137
+ })
138
+
139
+ test('constrains the case to a max-width wrapper when width is set', () => {
140
+ const modules = [defineCases('Button', { Default: () => <i>x</i> })]
141
+ const html = renderToStaticMarkup(
142
+ caseTree(modules, NO_CONFIG, state({ width: 320 }), Noop),
143
+ )
144
+ expect(html).toContain('max-width:320px')
145
+ })
146
+
147
+ test('wraps the case in the configured decorator', () => {
148
+ const modules = [defineCases('Button', { Default: () => <i>x</i> })]
149
+ const config = {
150
+ decorator: ({ children }: { children: ReactNode }) => (
151
+ <div className="deco">{children}</div>
152
+ ),
153
+ } as DisplayCaseConfig
154
+ const html = renderToStaticMarkup(
155
+ caseTree(modules, config, state({}), Noop),
156
+ )
157
+ expect(html).toContain('class="deco"')
158
+ expect(html).toContain('<i>x</i>')
159
+ })
160
+ })
@@ -0,0 +1,133 @@
1
+ import type { ReactNode } from 'react'
2
+ import { StrictMode } from 'react'
3
+ import { findCase, slugify } from '../core/catalog'
4
+ import type {
5
+ CaseModule,
6
+ DisplayCaseConfig,
7
+ FlowStep,
8
+ GotoFn,
9
+ TweakedCase,
10
+ TweakSchema,
11
+ TweakValues,
12
+ } from '../index'
13
+
14
+ /**
15
+ * The pure, DOM-free construction of a single case's React tree. Both the
16
+ * server (pre-rendering the isolated `/render` document to markup) and the
17
+ * client (hydrating, then driving in-place swaps/tweaks) build the tree through
18
+ * this one function, so the two can never disagree on markup — the prerequisite
19
+ * for hydration without mismatch. It touches no `window`/`document`: the
20
+ * document-level effects of a render (the theme on `<html>`, the body
21
+ * background, the mount width) are applied by the caller, not here.
22
+ */
23
+
24
+ /** The slice of render state the React tree depends on (no theme/fit/transparent —
25
+ * those are document-level, applied outside the tree). */
26
+ export interface CaseTreeState {
27
+ componentId: string
28
+ caseId: string
29
+ width: number | null
30
+ tweaks: Record<string, string>
31
+ }
32
+
33
+ /** Decode the string tweak map (from the address) into typed render values. */
34
+ export function resolveTweaks(
35
+ schema: TweakSchema,
36
+ tweaks: Record<string, string>,
37
+ ): TweakValues<TweakSchema> {
38
+ const values: Record<string, string | number | boolean> = {}
39
+ for (const [key, desc] of Object.entries(schema)) {
40
+ const raw = tweaks[key]
41
+ if (raw === undefined) {
42
+ values[key] = desc.default
43
+ continue
44
+ }
45
+ switch (desc.kind) {
46
+ case 'boolean':
47
+ values[key] = raw === '1' || raw === 'true'
48
+ break
49
+ case 'number':
50
+ values[key] = Number(raw)
51
+ break
52
+ default:
53
+ values[key] = raw
54
+ }
55
+ }
56
+ return values as TweakValues<TweakSchema>
57
+ }
58
+
59
+ /** Encode a step's `goto` overrides into the string tweak map used in URLs. */
60
+ export function encodeOverrides(
61
+ overrides?: Record<string, string | number | boolean>,
62
+ ): Record<string, string> {
63
+ const out: Record<string, string> = {}
64
+ if (!overrides) return out
65
+ for (const [k, v] of Object.entries(overrides)) {
66
+ if (typeof v === 'boolean') out[k] = v ? '1' : '0'
67
+ else out[k] = String(v)
68
+ }
69
+ return out
70
+ }
71
+
72
+ /** A `goto` that does nothing — used when pre-rendering on the server, where a
73
+ * flow step's initial paint is rendered but no interaction can occur. */
74
+ export const NOOP_GOTO: GotoFn = () => {}
75
+
76
+ /**
77
+ * Build the full React tree for a case: the case's node (simple thunk, tweaked
78
+ * render, or flow step), optionally width-constrained, wrapped in the configured
79
+ * decorator and `StrictMode`. A missing case yields the not-found node verbatim
80
+ * (no `StrictMode` wrapper), matching what the catalog reports. `goto` is wired
81
+ * into flow steps; pass {@link NOOP_GOTO} on the server.
82
+ */
83
+ export function caseTree(
84
+ modules: CaseModule[],
85
+ config: DisplayCaseConfig,
86
+ state: CaseTreeState,
87
+ goto: GotoFn,
88
+ ): ReactNode {
89
+ const found = findCase(modules, state.componentId, state.caseId)
90
+ if (!found) {
91
+ return (
92
+ <div className="dc-render-missing">
93
+ No such case: {state.componentId}/{state.caseId}
94
+ </div>
95
+ )
96
+ }
97
+
98
+ let node: ReactNode
99
+ if (typeof found.case === 'function') {
100
+ node = found.case()
101
+ } else if (found.module.isFlow) {
102
+ const step = found.case as FlowStep
103
+ const values = resolveTweaks(step.tweaks ?? {}, state.tweaks)
104
+ node = step.render({ values, goto })
105
+ } else {
106
+ const tweaked = found.case as TweakedCase
107
+ node = tweaked.render(resolveTweaks(tweaked.tweaks, state.tweaks))
108
+ }
109
+
110
+ const wrapped = state.width ? (
111
+ <div style={{ maxWidth: `${state.width}px`, margin: '0 auto' }}>{node}</div>
112
+ ) : (
113
+ node
114
+ )
115
+
116
+ const Decorator = config.decorator
117
+ return (
118
+ <StrictMode>
119
+ {Decorator ? (
120
+ <Decorator
121
+ level={found.module.level}
122
+ sourcePath={found.module.sourcePath}
123
+ area={found.module.area}>
124
+ {wrapped}
125
+ </Decorator>
126
+ ) : (
127
+ wrapped
128
+ )}
129
+ </StrictMode>
130
+ )
131
+ }
132
+
133
+ export { slugify }