@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,340 @@
1
+ import { afterEach, describe, expect, test } from 'bun:test'
2
+ import type { Manifest, ManifestComponent } from '../core/manifest'
3
+ import {
4
+ buildAddressUrl,
5
+ buildRenderSrc,
6
+ buildUrl,
7
+ gridPad,
8
+ groupByLevel,
9
+ groupPrimerSections,
10
+ initialSelectionFor,
11
+ MAX_PAD,
12
+ MIN_PAD,
13
+ type PrimerSection,
14
+ parseRoute,
15
+ primerForLocation,
16
+ resolveMode,
17
+ type Selection,
18
+ selSignature,
19
+ } from './shell-core'
20
+
21
+ // `primerForLocation` reads `window.location.pathname`; stub a minimal window
22
+ // for the duration of each case and restore it afterwards.
23
+ const realWindow = (globalThis as { window?: unknown }).window
24
+ afterEach(() => {
25
+ ;(globalThis as { window?: unknown }).window = realWindow
26
+ })
27
+ function atPath(pathname: string): void {
28
+ ;(globalThis as { window?: unknown }).window = { location: { pathname } }
29
+ }
30
+
31
+ function manifest(over: Partial<Manifest>): Manifest {
32
+ return {
33
+ title: 'T',
34
+ components: [],
35
+ primer: true,
36
+ landing: 'primer',
37
+ ...over,
38
+ }
39
+ }
40
+
41
+ describe('primerForLocation', () => {
42
+ test('the /primer route is the Primer whenever one is configured', () => {
43
+ atPath('/primer')
44
+ // Even when the landing default was overridden to the library, an explicit
45
+ // /primer link still resolves to the Primer.
46
+ expect(primerForLocation(manifest({ landing: 'library' }))).toBe(true)
47
+ })
48
+
49
+ test('the /primer route is the library when no Primer is configured', () => {
50
+ atPath('/primer')
51
+ expect(
52
+ primerForLocation(manifest({ primer: false, landing: 'library' })),
53
+ ).toBe(false)
54
+ })
55
+
56
+ test('the bare / landing honors the resolved landing', () => {
57
+ atPath('/')
58
+ expect(primerForLocation(manifest({ landing: 'primer' }))).toBe(true)
59
+ expect(primerForLocation(manifest({ landing: 'library' }))).toBe(false)
60
+ })
61
+
62
+ test('a /c/... deep link is always a library address', () => {
63
+ atPath('/c/button/default')
64
+ expect(primerForLocation(manifest({ landing: 'primer' }))).toBe(false)
65
+ })
66
+ })
67
+
68
+ function comp(over: Partial<ManifestComponent>): ManifestComponent {
69
+ return {
70
+ id: 'button',
71
+ name: 'Button',
72
+ level: 'atom',
73
+ isFlow: false,
74
+ caseFile: 'src/Button.case.tsx',
75
+ placardDoc: null,
76
+ cases: [],
77
+ ...over,
78
+ }
79
+ }
80
+
81
+ describe('parseRoute', () => {
82
+ test('extracts the component and case ids from a /c/ path', () => {
83
+ const r = parseRoute('/c/button/default', '')
84
+ expect(r.componentId).toBe('button')
85
+ expect(r.caseId).toBe('default')
86
+ expect(r.path).toBe('/c/button/default')
87
+ })
88
+
89
+ test('collects only t.* query params into tweaks, stripping the prefix', () => {
90
+ const r = parseRoute('/c/button/default', '?t.size=lg&t.disabled=1&other=x')
91
+ expect(r.tweaks).toEqual({ size: 'lg', disabled: '1' })
92
+ })
93
+
94
+ test('reads docs=1 as the open docs flag', () => {
95
+ expect(parseRoute('/c/x/y', '?docs=1').docs).toBe(true)
96
+ expect(parseRoute('/c/x/y', '').docs).toBe(false)
97
+ })
98
+
99
+ test('yields empty ids for a path with no component segment', () => {
100
+ const r = parseRoute('/', '')
101
+ expect(r.componentId).toBe('')
102
+ expect(r.caseId).toBe('')
103
+ })
104
+ })
105
+
106
+ describe('resolveMode', () => {
107
+ const route = (path: string) => parseRoute(path, '')
108
+
109
+ test('/primer resolves to the primer only when one is configured', () => {
110
+ expect(resolveMode(route('/primer'), manifest({ primer: true }))).toBe(
111
+ 'primer',
112
+ )
113
+ expect(resolveMode(route('/primer'), manifest({ primer: false }))).toBe(
114
+ 'library',
115
+ )
116
+ })
117
+
118
+ test('the bare / landing honors the resolved landing', () => {
119
+ expect(resolveMode(route('/'), manifest({ landing: 'primer' }))).toBe(
120
+ 'primer',
121
+ )
122
+ expect(resolveMode(route('/'), manifest({ landing: 'library' }))).toBe(
123
+ 'library',
124
+ )
125
+ })
126
+
127
+ test('any deep link is a library address', () => {
128
+ expect(
129
+ resolveMode(route('/c/button/default'), manifest({ landing: 'primer' })),
130
+ ).toBe('library')
131
+ })
132
+ })
133
+
134
+ describe('initialSelectionFor', () => {
135
+ test('uses the route selection when it names a component', () => {
136
+ const route = parseRoute('/c/button/primary', '?t.size=lg')
137
+ expect(initialSelectionFor(manifest({}), route)).toEqual({
138
+ componentId: 'button',
139
+ caseId: 'primary',
140
+ tweaks: { size: 'lg' },
141
+ })
142
+ })
143
+
144
+ test('falls back to the first component and its first case', () => {
145
+ const route = parseRoute('/', '')
146
+ const m = manifest({
147
+ components: [
148
+ comp({
149
+ id: 'card',
150
+ cases: [
151
+ {
152
+ id: 'a',
153
+ name: 'A',
154
+ browseUrl: '',
155
+ renderUrl: '',
156
+ tweaks: null,
157
+ transitions: [],
158
+ },
159
+ {
160
+ id: 'b',
161
+ name: 'B',
162
+ browseUrl: '',
163
+ renderUrl: '',
164
+ tweaks: null,
165
+ transitions: [],
166
+ },
167
+ ],
168
+ }),
169
+ ],
170
+ })
171
+ expect(initialSelectionFor(m, route)).toEqual({
172
+ componentId: 'card',
173
+ caseId: 'a',
174
+ tweaks: {},
175
+ })
176
+ })
177
+
178
+ test('returns an empty selection when the manifest has no components', () => {
179
+ expect(
180
+ initialSelectionFor(manifest({ components: [] }), parseRoute('/', '')),
181
+ ).toEqual({ componentId: '', caseId: '', tweaks: {} })
182
+ })
183
+ })
184
+
185
+ describe('selSignature', () => {
186
+ const sel = (over: Partial<Selection>): Selection => ({
187
+ componentId: 'button',
188
+ caseId: 'default',
189
+ tweaks: {},
190
+ ...over,
191
+ })
192
+
193
+ test('is stable for equal selections', () => {
194
+ expect(selSignature(sel({}))).toBe(selSignature(sel({})))
195
+ })
196
+
197
+ test('changes when the case or a tweak changes', () => {
198
+ const base = selSignature(sel({}))
199
+ expect(selSignature(sel({ caseId: 'primary' }))).not.toBe(base)
200
+ expect(selSignature(sel({ tweaks: { size: 'lg' } }))).not.toBe(base)
201
+ })
202
+ })
203
+
204
+ describe('buildUrl', () => {
205
+ test('builds a bare /c/ path when there are no tweaks or docs', () => {
206
+ expect(buildUrl('button', 'default', {}, false)).toBe('/c/button/default')
207
+ })
208
+
209
+ test('encodes tweaks under the t. prefix and the docs flag', () => {
210
+ expect(buildUrl('button', 'default', { size: 'lg' }, true)).toBe(
211
+ '/c/button/default?t.size=lg&docs=1',
212
+ )
213
+ })
214
+
215
+ test('round-trips through parseRoute', () => {
216
+ const url = buildUrl('button', 'primary', { size: 'lg', on: '1' }, true)
217
+ const [path, search] = url.split('?')
218
+ const r = parseRoute(path, `?${search}`)
219
+ expect(r.componentId).toBe('button')
220
+ expect(r.caseId).toBe('primary')
221
+ expect(r.tweaks).toEqual({ size: 'lg', on: '1' })
222
+ expect(r.docs).toBe(true)
223
+ })
224
+ })
225
+
226
+ describe('buildRenderSrc', () => {
227
+ test('always sets the theme and appends tweaks', () => {
228
+ const src = buildRenderSrc(
229
+ '/render/button/default',
230
+ 'dark',
231
+ { size: 'lg' },
232
+ false,
233
+ false,
234
+ )
235
+ expect(src.startsWith('/render/button/default?')).toBe(true)
236
+ expect(src).toContain('theme=dark')
237
+ expect(src).toContain('t.size=lg')
238
+ expect(src).not.toContain('fit=1')
239
+ expect(src).not.toContain('transparent=1')
240
+ })
241
+
242
+ test('adds the fit and transparent stage hints when requested', () => {
243
+ const src = buildRenderSrc('/render/x/y', 'light', {}, true, true)
244
+ expect(src).toContain('fit=1')
245
+ expect(src).toContain('transparent=1')
246
+ })
247
+ })
248
+
249
+ describe('buildAddressUrl', () => {
250
+ test('prefixes the origin and carries theme + tweaks but no stage hints', () => {
251
+ const url = buildAddressUrl(
252
+ '/render/button/default',
253
+ 'dark',
254
+ { size: 'lg' },
255
+ 'https://x.dev',
256
+ )
257
+ expect(url.startsWith('https://x.dev/render/button/default?')).toBe(true)
258
+ expect(url).toContain('theme=dark')
259
+ expect(url).toContain('t.size=lg')
260
+ expect(url).not.toContain('fit=1')
261
+ expect(url).not.toContain('transparent=1')
262
+ })
263
+
264
+ test('stays relative with an empty origin (server / first render)', () => {
265
+ expect(
266
+ buildAddressUrl('/render/x/y', 'light', {}, '').startsWith(
267
+ '/render/x/y?',
268
+ ),
269
+ ).toBe(true)
270
+ })
271
+ })
272
+
273
+ describe('gridPad', () => {
274
+ test('clamps to at least one grid cell when there is no slack', () => {
275
+ expect(gridPad(100, 100)).toBe(MIN_PAD)
276
+ })
277
+
278
+ test('clamps to at most three grid cells when there is ample slack', () => {
279
+ expect(gridPad(2000, 100)).toBe(MAX_PAD)
280
+ })
281
+
282
+ test('snaps the centered margin to a whole number of grid cells', () => {
283
+ const pad = gridPad(400, 200)
284
+ expect(pad % 16).toBe(0)
285
+ expect(pad).toBeGreaterThanOrEqual(MIN_PAD)
286
+ expect(pad).toBeLessThanOrEqual(MAX_PAD)
287
+ })
288
+ })
289
+
290
+ describe('groupByLevel', () => {
291
+ test('orders groups atoms-first and drops empty levels', () => {
292
+ const groups = groupByLevel([
293
+ comp({ id: 'page', level: 'page' }),
294
+ comp({ id: 'atom', level: 'atom' }),
295
+ comp({ id: 'mystery', level: null }),
296
+ ])
297
+ expect(groups.map((g) => g.key)).toEqual(['atom', 'page', 'unclassified'])
298
+ })
299
+
300
+ test('files a null level under the unclassified group', () => {
301
+ const groups = groupByLevel([comp({ id: 'x', level: null })])
302
+ expect(groups).toHaveLength(1)
303
+ expect(groups[0].key).toBe('unclassified')
304
+ expect(groups[0].components).toHaveLength(1)
305
+ })
306
+ })
307
+
308
+ describe('groupPrimerSections', () => {
309
+ const section = (over: Partial<PrimerSection>): PrimerSection => ({
310
+ id: 's',
311
+ title: 'S',
312
+ kind: 'display',
313
+ ...over,
314
+ })
315
+
316
+ test('folds displays under the heading that precedes them', () => {
317
+ const groups = groupPrimerSections([
318
+ section({ id: 'colors', kind: 'heading', title: 'Colors' }),
319
+ section({ id: 'ramp' }),
320
+ section({ id: 'swatches' }),
321
+ section({ id: 'type', kind: 'heading', title: 'Type' }),
322
+ section({ id: 'scale' }),
323
+ ])
324
+ expect(groups).toHaveLength(2)
325
+ expect(groups[0].heading?.id).toBe('colors')
326
+ expect(groups[0].items.map((i) => i.id)).toEqual(['ramp', 'swatches'])
327
+ expect(groups[1].heading?.id).toBe('type')
328
+ expect(groups[1].items.map((i) => i.id)).toEqual(['scale'])
329
+ })
330
+
331
+ test('puts displays before the first heading into a leading headless group', () => {
332
+ const groups = groupPrimerSections([
333
+ section({ id: 'wordmark' }),
334
+ section({ id: 'intro', kind: 'heading', title: 'Intro' }),
335
+ ])
336
+ expect(groups[0].heading).toBeNull()
337
+ expect(groups[0].items.map((i) => i.id)).toEqual(['wordmark'])
338
+ expect(groups[1].heading?.id).toBe('intro')
339
+ })
340
+ })
@@ -0,0 +1,295 @@
1
+ import type { Manifest, ManifestComponent } from '../core/manifest'
2
+ import type { HierarchyLevel } from '../index'
3
+ import { HIERARCHY_LEVELS } from '../index'
4
+
5
+ export type Theme = 'light' | 'dark'
6
+ export type Mode = 'primer' | 'library'
7
+
8
+ // Two ways to size the preview, à la Chrome DevTools' device toolbar:
9
+ // - Responsive: a width (or "full"); height fills the panel; manual zoom applies.
10
+ // - Fixed: an exact W×H (a device preset or custom); the iframe renders at that
11
+ // size and is auto-scaled to fit the panel (manual zoom is overridden).
12
+ export interface ResponsivePreset {
13
+ id: string
14
+ label: string
15
+ width: 'full' | number
16
+ }
17
+ export const RESPONSIVE: ResponsivePreset[] = [
18
+ { id: 'full', label: 'Full', width: 'full' },
19
+ { id: 'desktop', label: 'Desktop', width: 1280 },
20
+ { id: 'tablet', label: 'Tablet', width: 768 },
21
+ { id: 'mobile', label: 'Mobile', width: 375 },
22
+ ]
23
+
24
+ export interface DevicePreset {
25
+ id: string
26
+ label: string
27
+ w: number
28
+ h: number
29
+ }
30
+ export const DEVICES: DevicePreset[] = [
31
+ { id: 'tv-1080p', label: '1080p', w: 1920, h: 1080 },
32
+ { id: 'tv-4k', label: '4K', w: 3840, h: 2160 },
33
+ { id: 'laptop', label: 'Laptop', w: 1366, h: 768 },
34
+ { id: 'laptop-hidpi', label: 'Laptop HiDPI', w: 1440, h: 900 },
35
+ { id: 'ipad-pro-11', label: 'iPad Pro', w: 834, h: 1194 },
36
+ { id: 'ipad', label: 'iPad', w: 820, h: 1180 },
37
+ { id: 'iphone-14', label: 'iPhone 15', w: 390, h: 844 },
38
+ { id: 'iphone-promax', label: 'iPhone Pro Max', w: 430, h: 932 },
39
+ { id: 'iphone-se', label: 'iPhone SE', w: 375, h: 667 },
40
+ { id: 'pixel-7', label: 'Pixel 7', w: 412, h: 915 },
41
+ { id: 'galaxy-s20', label: 'Galaxy S20', w: 360, h: 800 },
42
+ ]
43
+
44
+ export const LEVEL_LABEL: Record<HierarchyLevel | 'unclassified', string> = {
45
+ atom: 'Atoms',
46
+ molecule: 'Molecules',
47
+ organism: 'Organisms',
48
+ template: 'Templates',
49
+ page: 'Pages',
50
+ flow: 'Flows',
51
+ unclassified: 'Unclassified',
52
+ }
53
+
54
+ export const GROUP_ORDER: (HierarchyLevel | 'unclassified')[] = [
55
+ ...HIERARCHY_LEVELS,
56
+ 'unclassified',
57
+ ]
58
+
59
+ // At or below this chrome width the nav starts collapsed (tablet and down).
60
+ export const NAV_COLLAPSE_MAX = 1024
61
+
62
+ export const ZOOM_MIN = 0.5
63
+ export const ZOOM_MAX = 2
64
+ export const ZOOM_STEP = 0.1
65
+
66
+ // Documentation panel width (px), adjustable by dragging its left edge.
67
+ export const DOC_MIN_W = 256
68
+ export const DOC_MAX_W = 640
69
+ export const DOC_DEFAULT_W = 352 // 22rem
70
+
71
+ // Stage crossfade duration (ms): the exhibit fades out when the selection
72
+ // changes, swaps while hidden, then fades back in once measured. Mirrors the
73
+ // CSS opacity transition on the stage.
74
+ export const STAGE_FADE_MS = 150
75
+
76
+ // Primer ↔ Cases crossfade duration (ms): on a mode switch the nav, the screen
77
+ // content, and the mode-specific header controls all fade out together, the view
78
+ // swaps while hidden, then everything fades back in. The mode-switch highlight
79
+ // box lerps across this same span (see `.dc-modeswitch-thumb`). Mirrors the CSS
80
+ // opacity transitions applied to those regions.
81
+ export const MODE_FADE_MS = 200
82
+
83
+ // The stage's dotted-grid margin around a decorated component, in px. It scales
84
+ // with the spare room — from 1 grid cell when the component is near max width up
85
+ // to 3 cells when there's plenty — and is snapped to the grid so the component's
86
+ // edges land on dot columns/rows. `GRID` is the dot spacing (matches the 16px
87
+ // `background-size` of `.dc-stage-frame`'s grid in chrome.css).
88
+ export const GRID = 16
89
+ export const MIN_PAD = GRID // at least 1 dot
90
+ export const MAX_PAD = GRID * 3 // at most 3 dots
91
+
92
+ // Center-fit, grid-snapped padding for one axis: half the spare space (after the
93
+ // 1px borders), floored to a whole number of dots, clamped to [MIN_PAD, MAX_PAD].
94
+ export function gridPad(available: number, box: number): number {
95
+ const slack = Math.floor((available - box - 2) / 2 / GRID) * GRID
96
+ return Math.max(MIN_PAD, Math.min(MAX_PAD, slack))
97
+ }
98
+
99
+ export interface Selection {
100
+ componentId: string
101
+ caseId: string
102
+ tweaks: Record<string, string>
103
+ }
104
+
105
+ export interface ParsedRoute {
106
+ componentId: string
107
+ caseId: string
108
+ tweaks: Record<string, string>
109
+ docs: boolean
110
+ /** The pathname this route was parsed from, for mode resolution. */
111
+ path: string
112
+ }
113
+
114
+ /**
115
+ * Pure route parse from an explicit path + query string. Usable on the server —
116
+ * which has only the request, not `window` — and on the client, so the shell's
117
+ * server render and its client hydration derive the same initial route and agree.
118
+ */
119
+ export function parseRoute(pathname: string, search: string): ParsedRoute {
120
+ const parts = pathname.split('/').filter(Boolean)
121
+ const tweaks: Record<string, string> = {}
122
+ const params = new URLSearchParams(search)
123
+ for (const [k, v] of params) {
124
+ if (k.startsWith('t.')) tweaks[k.slice(2)] = v
125
+ }
126
+ return {
127
+ componentId: parts[1] ?? '',
128
+ caseId: parts[2] ?? '',
129
+ tweaks,
130
+ docs: params.get('docs') === '1',
131
+ path: pathname,
132
+ }
133
+ }
134
+
135
+ /** Client convenience: parse the live address. Reads `window`, so client-only. */
136
+ export function parseLocation(): ParsedRoute {
137
+ return parseRoute(window.location.pathname, window.location.search)
138
+ }
139
+
140
+ /**
141
+ * Whether a route resolves to the Primer rather than the library. The canonical
142
+ * `/primer` route does (when a Primer exists); the bare `/` landing does unless
143
+ * the consumer opted out with `landing: 'cases'` (resolved server-side into
144
+ * `manifest.landing`). Every `/c/...` deep link — and any other path — is a
145
+ * library address. Pure (takes the route), so it runs on the server too.
146
+ */
147
+ export function resolveMode(route: ParsedRoute, m: Manifest): Mode {
148
+ if (route.path === '/primer') return m.primer ? 'primer' : 'library'
149
+ if (route.path === '/') return m.landing === 'primer' ? 'primer' : 'library'
150
+ return 'library'
151
+ }
152
+
153
+ /** Client convenience over {@link resolveMode}. Reads `window`; client-only. */
154
+ export function primerForLocation(m: Manifest): boolean {
155
+ return resolveMode(parseLocation(), m) === 'primer'
156
+ }
157
+
158
+ /** Pure initial selection from a parsed route + manifest (server + client). */
159
+ export function initialSelectionFor(
160
+ m: Manifest,
161
+ route: ParsedRoute,
162
+ ): Selection {
163
+ if (route.componentId)
164
+ return {
165
+ componentId: route.componentId,
166
+ caseId: route.caseId,
167
+ tweaks: route.tweaks,
168
+ }
169
+ const first = m.components[0]
170
+ if (first) {
171
+ return {
172
+ componentId: first.id,
173
+ caseId: first.cases[0]?.id ?? '',
174
+ tweaks: {},
175
+ }
176
+ }
177
+ return { componentId: '', caseId: '', tweaks: {} }
178
+ }
179
+
180
+ /** Client convenience over {@link initialSelectionFor}. Reads `window`. */
181
+ export function initialSelection(m: Manifest): Selection {
182
+ return initialSelectionFor(m, parseLocation())
183
+ }
184
+
185
+ // Identity of a selection: component + case + tweak overrides. Drives the
186
+ // reveal gate (a size report only counts once it matches the shown selection,
187
+ // tweaks included) and detects when a selection change is a no-op. Note: a
188
+ // tweak-only change alters this signature but is NOT a crossfade trigger — the
189
+ // crossfade controller compares component+case, so the frame retweaks in place.
190
+ export function selSignature(s: Selection): string {
191
+ return `${s.componentId}\0${s.caseId}\0${JSON.stringify(s.tweaks ?? {})}`
192
+ }
193
+
194
+ // Encode the shareable app address: which case is on the stage, its tweak
195
+ // overrides (`t.*`), and whether the docs panel is open (`docs=1`). Parsed back
196
+ // on load by `parseLocation` so any of these survive a copied/shared link.
197
+ export function buildUrl(
198
+ componentId: string,
199
+ caseId: string,
200
+ tweaks: Record<string, string>,
201
+ docsOpen: boolean,
202
+ ): string {
203
+ const params = new URLSearchParams()
204
+ for (const [k, v] of Object.entries(tweaks)) params.set(`t.${k}`, v)
205
+ if (docsOpen) params.set('docs', '1')
206
+ const qs = params.toString()
207
+ return `/c/${componentId}/${caseId}${qs ? `?${qs}` : ''}`
208
+ }
209
+
210
+ export function buildRenderSrc(
211
+ renderUrl: string,
212
+ theme: Theme,
213
+ tweaks: Record<string, string>,
214
+ fit: boolean,
215
+ transparent: boolean,
216
+ ): string {
217
+ const params = new URLSearchParams()
218
+ params.set('theme', theme)
219
+ // The preview's pixel size is controlled by the iframe element, not an inner
220
+ // max-width, so the frame always renders "full".
221
+ for (const [k, v] of Object.entries(tweaks)) params.set(`t.${k}`, v)
222
+ // `fit` asks the render frame to shrink-wrap the case to its natural width so
223
+ // a small component (e.g. a square button) doesn't stretch to fill the frame.
224
+ if (fit) params.set('fit', '1')
225
+ // `transparent` drops the render doc's background so the component sits on the
226
+ // stage's dotted grid (decorated components only — not pages/flows, and not
227
+ // the standalone /render endpoint, which keeps its opaque snapshot bg).
228
+ if (transparent) params.set('transparent', '1')
229
+ return `${renderUrl}?${params.toString()}`
230
+ }
231
+
232
+ // The shareable standalone-snapshot address shown above the stage: the render
233
+ // URL with the visible theme and any tweak overrides — none of the
234
+ // stage-internal `fit`/`transparent` hints `buildRenderSrc` adds, since those
235
+ // describe how the vitrine embeds the frame, not the case a reader would open.
236
+ // Absolute (origin-prefixed) so the address a reader copies pastes straight into
237
+ // a browser; `renderUrl` is a server-relative path like `/render/...`. `origin`
238
+ // is passed in (not read from `window`) so this stays pure: the server and the
239
+ // client's first render both use an empty origin (a relative URL), and the
240
+ // client fills the real origin in after hydration — no hydration mismatch.
241
+ export function buildAddressUrl(
242
+ renderUrl: string,
243
+ theme: Theme,
244
+ tweaks: Record<string, string>,
245
+ origin: string,
246
+ ): string {
247
+ const params = new URLSearchParams()
248
+ params.set('theme', theme)
249
+ for (const [k, v] of Object.entries(tweaks)) params.set(`t.${k}`, v)
250
+ return `${origin}${renderUrl}?${params.toString()}`
251
+ }
252
+
253
+ export function groupByLevel(components: ManifestComponent[]) {
254
+ return GROUP_ORDER.map((key) => ({
255
+ key,
256
+ components: components.filter((c) => (c.level ?? 'unclassified') === key),
257
+ })).filter((g) => g.components.length > 0)
258
+ }
259
+
260
+ // A `##`-heading group in the primer table of contents: the heading plus the
261
+ // Displays that follow it (a leading headless group holds Displays before the
262
+ // first heading).
263
+ export interface PrimerSection {
264
+ id: string
265
+ title: string
266
+ kind: 'heading' | 'display'
267
+ }
268
+ export interface PrimerGroup {
269
+ heading: PrimerSection | null
270
+ items: PrimerSection[]
271
+ }
272
+
273
+ // Fold the primer's flat, document-ordered section list into `##`-heading
274
+ // groups: each heading owns the Displays that follow it. Displays before the
275
+ // first heading (e.g. the wordmark under the H1) sit in a leading headless
276
+ // group so they still appear in the table of contents.
277
+ export function groupPrimerSections(
278
+ primerSections: PrimerSection[],
279
+ ): PrimerGroup[] {
280
+ const out: PrimerGroup[] = []
281
+ let current: PrimerGroup | null = null
282
+ for (const s of primerSections) {
283
+ if (s.kind === 'heading') {
284
+ current = { heading: s, items: [] }
285
+ out.push(current)
286
+ } else {
287
+ if (!current) {
288
+ current = { heading: null, items: [] }
289
+ out.push(current)
290
+ }
291
+ current.items.push(s)
292
+ }
293
+ }
294
+ return out
295
+ }
@@ -0,0 +1,60 @@
1
+ import { ShellView } from './design-system/components/shell/ShellView'
2
+ import { DcTestIds } from './test-ids'
3
+ import { type ShellSeed, useShell } from './use-shell'
4
+
5
+ /**
6
+ * The browse chrome's container. It runs the {@link useShell} state machine and
7
+ * hands the resulting view model to the pure {@link ShellView}, supplying the
8
+ * one thing a view model can't carry across the app/exhibit boundary: the live
9
+ * `<iframe>` elements (the render stage and the Primer reading page). In a
10
+ * page/flow exhibit those slots are static stand-ins instead — same view, no
11
+ * server. Until the manifest loads (and when it's empty) the container shows the
12
+ * loading/empty screens rather than the chrome.
13
+ */
14
+ export function Shell({ seed }: { seed: ShellSeed }) {
15
+ const vm = useShell(seed)
16
+ if (!vm.manifest) return <div className="dc-loading">Loading…</div>
17
+ if (vm.manifest.components.length === 0) {
18
+ return (
19
+ <div className="dc-empty">
20
+ <p>No cases found.</p>
21
+ <p className="dc-empty-hint">
22
+ Add a <code>*.case.tsx</code> file to get started.
23
+ </p>
24
+ </div>
25
+ )
26
+ }
27
+
28
+ // The live render stage: one iframe, loaded once at a fixed src; the hook
29
+ // pushes every later change in via postMessage so it never reloads/flickers.
30
+ const renderFrame = (
31
+ <iframe
32
+ ref={vm.frameRef}
33
+ title="preview"
34
+ data-testid={DcTestIds.stageFrame}
35
+ className="dc-frame"
36
+ style={{
37
+ width: `${vm.targetW}px`,
38
+ height: `${vm.renderH}px`,
39
+ transform: vm.scale === 1 ? undefined : `scale(${vm.scale})`,
40
+ transformOrigin: 'top left',
41
+ }}
42
+ src={vm.frameSrc ?? undefined}
43
+ />
44
+ )
45
+
46
+ // The Primer reading page: its own isolated iframe, created lazily the first
47
+ // time the Primer view is opened (so `primerSrc` is null until then).
48
+ const primerFrame = vm.primerSrc ? (
49
+ <iframe
50
+ ref={vm.primerRef}
51
+ title="Primer"
52
+ className="dc-primer-frame"
53
+ src={vm.primerSrc}
54
+ />
55
+ ) : null
56
+
57
+ return (
58
+ <ShellView {...vm} renderFrame={renderFrame} primerFrame={primerFrame} />
59
+ )
60
+ }