@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
package/src/index.ts ADDED
@@ -0,0 +1,564 @@
1
+ import type { ComponentType, ReactNode } from 'react'
2
+
3
+ /**
4
+ * Public authoring API for Display Case.
5
+ *
6
+ * Case files import only from here. Everything in this module is pure data +
7
+ * thin helpers — no DOM access, no server imports — so a case module is safe to
8
+ * import both in the browser bundle (to render) and in the Bun server process
9
+ * (to build the manifest, where render functions are never called).
10
+ */
11
+
12
+ // ── Hierarchy ───────────────────────────────────────────────────────────────
13
+
14
+ /** Atomic Design levels, ordered by increasing composition. */
15
+ export const HIERARCHY_LEVELS = [
16
+ 'atom',
17
+ 'molecule',
18
+ 'organism',
19
+ 'template',
20
+ 'page',
21
+ 'flow',
22
+ ] as const
23
+
24
+ export type HierarchyLevel = (typeof HIERARCHY_LEVELS)[number]
25
+
26
+ // ── Tweaks (typed controls) ───────────────────────────────────────────────────
27
+
28
+ export interface TextTweak {
29
+ kind: 'text'
30
+ default: string
31
+ }
32
+ export interface BooleanTweak {
33
+ kind: 'boolean'
34
+ default: boolean
35
+ }
36
+ export interface NumberTweak {
37
+ kind: 'number'
38
+ default: number
39
+ }
40
+ export interface ChoiceTweak {
41
+ kind: 'choice'
42
+ options: string[]
43
+ default: string
44
+ }
45
+
46
+ export type TweakDescriptor =
47
+ | TextTweak
48
+ | BooleanTweak
49
+ | NumberTweak
50
+ | ChoiceTweak
51
+
52
+ export type TweakSchema = Record<string, TweakDescriptor>
53
+
54
+ /** Resolve a tweak schema to the value object handed to a render function. */
55
+ export type TweakValues<T extends TweakSchema> = {
56
+ [K in keyof T]: T[K] extends ChoiceTweak
57
+ ? string
58
+ : T[K] extends TextTweak
59
+ ? string
60
+ : T[K] extends NumberTweak
61
+ ? number
62
+ : T[K] extends BooleanTweak
63
+ ? boolean
64
+ : never
65
+ }
66
+
67
+ /** Builders for the four serializable tweak kinds. */
68
+ export const tweak = {
69
+ text: (defaultValue = ''): TextTweak => ({
70
+ kind: 'text',
71
+ default: defaultValue,
72
+ }),
73
+ boolean: (defaultValue = false): BooleanTweak => ({
74
+ kind: 'boolean',
75
+ default: defaultValue,
76
+ }),
77
+ number: (defaultValue = 0): NumberTweak => ({
78
+ kind: 'number',
79
+ default: defaultValue,
80
+ }),
81
+ choice: <O extends string>(options: O[], defaultValue: O): ChoiceTweak => ({
82
+ kind: 'choice',
83
+ options,
84
+ default: defaultValue,
85
+ }),
86
+ }
87
+
88
+ // ── Cases ─────────────────────────────────────────────────────────────────────
89
+
90
+ /** A case with no tweaks: a plain thunk returning the rendered variant. */
91
+ export type SimpleCase = () => ReactNode
92
+
93
+ /** A case with declared tweaks: receives the resolved tweak values. */
94
+ export interface TweakedCase<T extends TweakSchema = TweakSchema> {
95
+ tweaks: T
96
+ render: (values: TweakValues<T>) => ReactNode
97
+ }
98
+
99
+ export type Case = SimpleCase | TweakedCase
100
+
101
+ // ── Flows (interactive multi-step flows) ────────────────────────────────────────
102
+
103
+ /**
104
+ * Advance the flow to another named step. Optional `overrides` re-enter the
105
+ * target step with specific tweak values (e.g. an error state); they are
106
+ * encoded into the step's address so the resulting state is reproducible.
107
+ */
108
+ export type GotoFn = (
109
+ step: string,
110
+ overrides?: Record<string, string | number | boolean>,
111
+ ) => void
112
+
113
+ /**
114
+ * One step of a flow. A step is a superset of a tweaked case: its `tweaks`
115
+ * defaults are the step's preset state, and `render` additionally receives a
116
+ * `goto` to wire into a presentational view's callbacks. `transitions` names the
117
+ * steps this step can advance to — the catalog's source of truth for the flow
118
+ * graph (kept declarative so the manifest builds without executing `render`).
119
+ */
120
+ export interface FlowStep<T extends TweakSchema = TweakSchema> {
121
+ tweaks?: T
122
+ transitions?: string[]
123
+ render: (ctx: { values: TweakValues<T>; goto: GotoFn }) => ReactNode
124
+ }
125
+
126
+ export interface CaseMeta {
127
+ level?: HierarchyLevel
128
+ /**
129
+ * Free-form area/layout tag passed to the decorator, for wrapping a case in
130
+ * app chrome (nav/header/footer). The decorator interprets the value (Display
131
+ * Case mandates no vocabulary). Takes precedence over folder-based detection
132
+ * via `sourcePath`; omit to fall back to that (or to render bare).
133
+ */
134
+ area?: string
135
+ /**
136
+ * Declare this component's cases as browser-only: they need a browser to
137
+ * render (they touch `window`, layout measurement, canvas… *during render*,
138
+ * not just in effects) and so cannot be server-rendered before scripts run.
139
+ * Display Case renders them on the client instead — the same fallback a case
140
+ * that *throws* under server rendering gets — and the `ssr` check treats them
141
+ * as expected rather than a failure. Prefer keeping render pure (browser APIs
142
+ * belong in effects/handlers); use this only when a component genuinely can't.
143
+ */
144
+ browserOnly?: boolean
145
+ }
146
+
147
+ /**
148
+ * A discovered case module. This is the default-export shape every `*.case.tsx`
149
+ * file produces, and the unit the server reads to build the manifest.
150
+ */
151
+ export interface CaseModule {
152
+ /** Display name of the showcased component. */
153
+ component: string
154
+ /** Place in the Atomic Design hierarchy; undeclared ⇒ "unclassified". */
155
+ level?: HierarchyLevel
156
+ /** Ordered cases (or flow steps), keyed by display name (insertion order preserved). */
157
+ cases: Record<string, Case | FlowStep>
158
+ /** True when this module is a flow: its cases are ordered, transitionable steps. */
159
+ isFlow: boolean
160
+ /** Source path relative to the package, injected by codegen (for area-aware chrome). */
161
+ sourcePath?: string
162
+ /** Free-form area/layout tag (see CaseMeta.area); overrides `sourcePath`. */
163
+ area?: string
164
+ /** Declared browser-only (see CaseMeta.browserOnly): skip server rendering and
165
+ * let the `ssr` check pass these cases instead of flagging them. */
166
+ browserOnly?: boolean
167
+ }
168
+
169
+ /**
170
+ * Declare the cases for a single component.
171
+ *
172
+ * @example
173
+ * export default defineCases('Button', {
174
+ * Default: () => <Button>Save</Button>,
175
+ * }, { level: 'atom' })
176
+ */
177
+ export function defineCases(
178
+ component: string,
179
+ cases: Record<string, Case>,
180
+ meta: CaseMeta = {},
181
+ ): CaseModule {
182
+ return {
183
+ component,
184
+ cases,
185
+ level: meta.level,
186
+ isFlow: false,
187
+ area: meta.area,
188
+ browserOnly: meta.browserOnly,
189
+ }
190
+ }
191
+
192
+ /**
193
+ * Declare an interactive flow: an ordered set of named steps demonstrating a
194
+ * behavioural page or user flow. Each step is individually addressable and
195
+ * snapshottable; a step may declare preset `tweaks`, `transitions` to other
196
+ * steps, and wire its `goto` into a presentational view's callbacks. A flow
197
+ * whose steps declare no transitions is a static, walkable page sequence.
198
+ *
199
+ * @example
200
+ * export default defineFlow('Sign-in', {
201
+ * steps: {
202
+ * 'Request link': {
203
+ * render: ({ goto }) => <RequestLink onSubmit={() => goto('Check email')} />,
204
+ * },
205
+ * 'Check email': { render: () => <CheckEmail /> },
206
+ * },
207
+ * })
208
+ */
209
+ export function defineFlow(
210
+ name: string,
211
+ // Steps may each declare a different tweak schema (e.g. via `flowStep`), so the
212
+ // record is over `FlowStep<any>` — a `FlowStep<{ error }>` is not assignable to
213
+ // the invariant `FlowStep<TweakSchema>`.
214
+ config: {
215
+ steps: Record<string, FlowStep<any>>
216
+ area?: string
217
+ browserOnly?: boolean
218
+ },
219
+ ): CaseModule {
220
+ return {
221
+ component: name,
222
+ cases: config.steps,
223
+ level: 'flow',
224
+ isFlow: true,
225
+ area: config.area,
226
+ browserOnly: config.browserOnly,
227
+ }
228
+ }
229
+
230
+ /**
231
+ * Identity helper that infers a flow step's tweak schema from its own `tweaks`,
232
+ * so `render`'s `values` is typed per step (`values.error` is `boolean`, not a
233
+ * loose union). Wrap a step that reads typed `values`:
234
+ *
235
+ * @example
236
+ * 'Check email': flowStep({
237
+ * tweaks: { error: tweak.boolean(false) },
238
+ * render: ({ values, goto }) => <CheckEmail error={values.error} … />,
239
+ * })
240
+ *
241
+ * A bare step object still works (its `values` is just loosely typed). `goto`
242
+ * and `transitions` targets are not key-checked at compile time — an unknown
243
+ * target renders the not-found step at runtime (see Case discovery).
244
+ */
245
+ export function flowStep<T extends TweakSchema = Record<never, never>>(
246
+ step: FlowStep<T>,
247
+ ): FlowStep<T> {
248
+ return step
249
+ }
250
+
251
+ // ── Snapshot providers (visual-regression backend) ──────────────────────────
252
+
253
+ /** Identity of the case being rendered, passed to snapshot providers. */
254
+ export interface CaseContext {
255
+ componentId: string
256
+ caseId: string
257
+ theme: 'light' | 'dark'
258
+ width: number
259
+ }
260
+
261
+ /** axe severity, worst → least: how seriously to take a violation. `null` when
262
+ * the driver doesn't classify it. */
263
+ export type A11yImpact = 'critical' | 'serious' | 'moderate' | 'minor'
264
+
265
+ export interface A11yViolation {
266
+ id: string
267
+ help: string
268
+ nodes: number
269
+ /** Severity, used to order results (worst first); `null` if unclassified. */
270
+ impact: A11yImpact | null
271
+ /** Per-node detail (the failing element and, for colour-contrast, the measured
272
+ * vs required values). Populated when the driver captures it; omitted by
273
+ * drivers that report only counts. Persisted in the on-disk cache and printed
274
+ * by the CLI so a finding is actionable without re-running — the live UI still
275
+ * shows only the summary above (`id`/`help`/`nodes`/`impact`). */
276
+ details?: A11yNodeDetail[]
277
+ }
278
+
279
+ /** One failing node within an {@link A11yViolation}. */
280
+ export interface A11yNodeDetail {
281
+ /** CSS selector path to the failing element (axe's `target`, joined). */
282
+ target: string
283
+ /** Truncated `outerHTML` of the element, for identification. */
284
+ html: string
285
+ /** axe's human-readable explanation for this node, when available. */
286
+ failureSummary?: string
287
+ /** Present for `color-contrast` findings: the measured pair and threshold,
288
+ * so the exact failing colours are readable without opening a browser. */
289
+ contrast?: A11yContrast
290
+ }
291
+
292
+ /** The measured colour pair behind a `color-contrast` finding. */
293
+ export interface A11yContrast {
294
+ /** Foreground (text) colour, as axe reports it (e.g. `#8a8073`). */
295
+ foreground: string
296
+ /** Background colour behind the text. */
297
+ background: string
298
+ /** Measured contrast ratio (e.g. `3.71`). */
299
+ ratio: number
300
+ /** Ratio the text must meet to pass (`4.5` normal, `3` large). */
301
+ required: number
302
+ /** Computed font size axe used to pick the threshold (e.g. `12.0pt`). */
303
+ fontSize?: string
304
+ /** Computed font weight axe used to pick the threshold (e.g. `400`). */
305
+ fontWeight?: string
306
+ }
307
+
308
+ /** Options for a single accessibility audit — shared by the CLI gate and the
309
+ * live in-app scanner so they agree on what counts as a violation. */
310
+ export interface AuditOptions {
311
+ /** Rule ids to exclude from the audit. */
312
+ exclude?: string[]
313
+ }
314
+
315
+ /** A page opened by a {@link RenderDriver}: capture an image, audit a11y. */
316
+ export interface RenderedPage {
317
+ screenshot(): Promise<Uint8Array>
318
+ /** Accessibility violations (WCAG A/AA); `[]` if the driver skips auditing. */
319
+ audit(opts?: AuditOptions): Promise<A11yViolation[]>
320
+ dispose(): Promise<void>
321
+ }
322
+
323
+ /** Opens case render URLs and yields pages; reused across all cases. */
324
+ export interface RenderDriver {
325
+ open(url: string, ctx: CaseContext): Promise<RenderedPage>
326
+ close(): Promise<void>
327
+ }
328
+
329
+ export interface DiffResult {
330
+ changed: boolean
331
+ mismatch?: number
332
+ /** Optional diff image the runner writes next to the baseline on a change. */
333
+ diffImage?: Uint8Array
334
+ }
335
+
336
+ /**
337
+ * Compares a rendered case against its baseline. The second argument carries the
338
+ * case identity (Option B) — pure diffs ignore it; identity-aware ones (per-case
339
+ * tolerance, name-keyed hosted services) use it.
340
+ */
341
+ export type DiffFn = (
342
+ input: { baseline: Uint8Array; actual: Uint8Array },
343
+ ctx: CaseContext & { baselinePath: string },
344
+ ) => DiffResult | Promise<DiffResult>
345
+
346
+ export interface SnapshotProviders {
347
+ /** Render-driver factory; default = built-in Playwright + axe driver. */
348
+ driver?: () => RenderDriver | Promise<RenderDriver>
349
+ /** Image comparison; default = built-in pixelmatch/pngjs diff. */
350
+ diff?: DiffFn
351
+ }
352
+
353
+ // ── Structure checks (static best-practice rules) ────────────────────────────
354
+
355
+ /** Identifier for each `--structure` best-practice rule. */
356
+ export type StructureRuleId =
357
+ // File / config rules
358
+ | 'case-placard-coverage'
359
+ | 'no-orphaned-placard-doc'
360
+ | 'primer-present-and-used'
361
+ | 'setup-present'
362
+ | 'config-paths-exist'
363
+ // Catalog-integrity rules
364
+ | 'levels-classified'
365
+ | 'cases-load'
366
+ | 'flow-transitions-resolve'
367
+ | 'flow-multi-step'
368
+ | 'unique-slugs'
369
+ | 'tweak-defaults-valid'
370
+ // Case-content rules
371
+ | 'interactive-cases-keyed'
372
+ // Composition (import-graph) rules — opt-in, default off
373
+ | 'atom-purity'
374
+ | 'no-downward-dependency'
375
+ | 'composes-lower-level'
376
+ | 'level-fit'
377
+
378
+ /** A finding either warns (reported, non-fatal) or errors (fails the run). */
379
+ export type StructureSeverity = 'warn' | 'error'
380
+
381
+ /**
382
+ * Per-rule configuration:
383
+ * - `false` ⇒ disabled
384
+ * - `'warn'` / `'error'` ⇒ enabled, overriding the rule's default severity
385
+ * - an options object ⇒ enabled with per-rule overrides
386
+ */
387
+ export type StructureRuleSetting =
388
+ | false
389
+ | StructureSeverity
390
+ | StructureRuleOptions
391
+
392
+ export interface StructureRuleOptions {
393
+ /** Override the rule's default severity. */
394
+ severity?: StructureSeverity
395
+ /** Package-relative globs whose matches this rule skips. */
396
+ ignore?: string[]
397
+ /**
398
+ * `level-fit` only: max lower-level components a level may compose before the
399
+ * rule suggests promotion. Unset levels use built-in defaults.
400
+ */
401
+ thresholds?: Partial<Record<HierarchyLevel, number>>
402
+ }
403
+
404
+ /** Phases selectable by `display-case check`. */
405
+ export type CheckPhase = 'tokens' | 'a11y' | 'visual' | 'structure' | 'ssr'
406
+
407
+ export interface CheckConfig {
408
+ /**
409
+ * Whether each phase participates in the default (no-phase-flag) run.
410
+ * Unset ⇒ included. Set `false` to opt a phase out of the default run; it can
411
+ * still be invoked explicitly by naming its flag.
412
+ */
413
+ defaultPhases?: Partial<Record<CheckPhase, boolean>>
414
+ /** Structure-phase rule configuration; each rule is on (at its default severity) unless overridden. */
415
+ structure?: {
416
+ /** Treat every structure warning as an error for the run (CI strict mode). */
417
+ strict?: boolean
418
+ rules?: Partial<Record<StructureRuleId, StructureRuleSetting>>
419
+ }
420
+ }
421
+
422
+ // ── Style engines (render-time CSS-in-JS) ───────────────────────────────────────
423
+
424
+ /**
425
+ * Collects the styling a single server render emits and returns it as document
426
+ * `<head>` markup. One collector instance serves exactly one render, so its
427
+ * store is isolated — one case's render-time styling never leaks into another's
428
+ * document. See {@link StyleEngine}.
429
+ */
430
+ export interface StyleCollector {
431
+ /**
432
+ * Wrap the tree about to be rendered in whatever provider the styling library
433
+ * needs, so its render-time styling accumulates in this collector's isolated
434
+ * store (e.g. an emotion `CacheProvider` over a fresh cache).
435
+ */
436
+ wrap(node: ReactNode): ReactNode
437
+ /**
438
+ * Given the already-rendered markup, return the `<head>` markup (e.g.
439
+ * `<style data-…>…</style>` tags) carrying the styling that render used — placed
440
+ * verbatim, after the document's static styles, before scripting. Return `''`
441
+ * when the render produced none. MUST be idempotent: the tree renders inside
442
+ * `StrictMode` and may render twice.
443
+ */
444
+ collect(renderedHtml: string): string
445
+ }
446
+
447
+ /**
448
+ * A factory invoked once per server render to produce an isolated
449
+ * {@link StyleCollector}. Configure one or more on
450
+ * {@link DisplayCaseConfig.styleEngines} to deliver render-time (CSS-in-JS)
451
+ * styling — emotion/Material UI, styled-components, and peers — before scripting.
452
+ * Pair with `decorator` for the client-side provider. See `docs/style-engines.md`.
453
+ */
454
+ export type StyleEngine = () => StyleCollector
455
+
456
+ // ── Config ─────────────────────────────────────────────────────────────────────
457
+
458
+ export interface DisplayCaseConfig {
459
+ /** Title shown in the browsing chrome and the manifest. */
460
+ title: string
461
+ /** Globs (relative to the consumer package) that locate `*.case.tsx` files. */
462
+ roots: string[]
463
+ /**
464
+ * Path (relative to the consumer package) to an `.mdx` document rendered as
465
+ * the Primer — a long-form "wall text" reading page with embedded live
466
+ * specimens. The document may import any component (case files and arbitrary
467
+ * `.tsx`) and wraps each specimen in the `<Display>` contract (re-exported
468
+ * from this package). When set, the browse chrome shows a Primer / Cases
469
+ * mode switch in the sidebar.
470
+ */
471
+ primer?: string
472
+ /**
473
+ * Which view the browse chrome lands on at the root path (`/`) when a Primer
474
+ * is configured: the Primer reading page (`'primer'`, the default) or the
475
+ * Cases library (`'cases'`). A deep link to a specific case always opens the
476
+ * library regardless. Ignored when no Primer is set — the library is then the
477
+ * only landing view.
478
+ */
479
+ landing?: 'primer' | 'cases'
480
+ /** CSS entrypoints (relative to the consumer package) injected into previews. */
481
+ globalStyles?: string[]
482
+ /**
483
+ * Optional wrapper rendered around every case (e.g. a theme provider). Receives
484
+ * the active case's `level` and `sourcePath` so it can wrap page/flow cases in
485
+ * area-appropriate app chrome (nav/header) while leaving smaller components bare.
486
+ */
487
+ decorator?: ComponentType<{
488
+ children: ReactNode
489
+ level?: HierarchyLevel
490
+ sourcePath?: string
491
+ area?: string
492
+ }>
493
+ /**
494
+ * Engines that collect render-time (CSS-in-JS) styling — emotion/Material UI,
495
+ * styled-components, and peers — during the pre-scripting server render and
496
+ * deliver it in the isolated render and primer documents before scripting, so
497
+ * those surfaces are styled without executing scripts (no flash, styled
498
+ * snapshots). Applied in array order (the first is outermost). Each is a
499
+ * factory invoked once per render for an isolated style store. Pair with
500
+ * `decorator` for the client-side provider. Omit for none (documents are then
501
+ * byte-identical to their engine-free form). See `docs/style-engines.md`.
502
+ */
503
+ styleEngines?: StyleEngine[]
504
+ /**
505
+ * Where visual-regression baselines are stored, relative to the consumer
506
+ * package. Defaults to the gitignored cache at `.display-case/baselines`.
507
+ * Point at a committed directory to opt into shared / CI-gating baselines.
508
+ */
509
+ baselineDir?: string
510
+ /** Design-token conformance options for the `--tokens` check. */
511
+ tokens?: {
512
+ /**
513
+ * Custom-property names the package may reference but does not itself
514
+ * define — e.g. tokens supplied by a host application's global stylesheet,
515
+ * or set by the browser. Listed names are treated as defined.
516
+ */
517
+ allow?: string[]
518
+ }
519
+ /**
520
+ * Override the visual-regression backend. When unset, the built-in default
521
+ * (Playwright + axe driver, pixelmatch/pngjs diff) is loaded lazily.
522
+ */
523
+ providers?: SnapshotProviders
524
+ /**
525
+ * Check-command configuration: which phases run by default, and the structure
526
+ * phase's best-practice rules. Absent ⇒ every phase runs and every non-opt-in
527
+ * rule is enabled at its default severity.
528
+ */
529
+ check?: CheckConfig
530
+ /**
531
+ * In-app accessibility surfacing on the running browse server. Off by default:
532
+ * the headless-browser + axe toolchain is an optional, lazily-loaded
533
+ * prerequisite, not an assumed dependency. `enabled` gates only the live
534
+ * surface (nav markers + Accessibility panel); the `check` CLI gate runs
535
+ * whenever invoked regardless of it. `themes` and `exclude` are shared scan
536
+ * parameters honored by BOTH the live surface and the CLI gate, so the panel
537
+ * and CI agree on what counts as a violation.
538
+ */
539
+ a11y?: {
540
+ /** Surface accessibility results in the running browse chrome. Default false. */
541
+ enabled?: boolean
542
+ /** Themes to audit (live surface + CLI gate). Default: light and dark. */
543
+ themes?: ('light' | 'dark')[]
544
+ /** axe rule ids to exclude from audits (live surface + CLI gate). */
545
+ exclude?: string[]
546
+ /**
547
+ * How the navigation's accessibility markers are populated when the server
548
+ * starts (only meaningful with `enabled`). Default `'off'`.
549
+ * - `'off'` — no start-up population; a variant's marker appears only once
550
+ * that variant is viewed (the on-demand default).
551
+ * - `'cached'` — populate markers from reusable cached results at start-up,
552
+ * running no scans; uncached/stale variants stay unmarked
553
+ * until viewed.
554
+ * - `'refresh'`— at start-up scan every uncached or stale variant, surfacing
555
+ * each verdict as it lands, while reusing fresh cached results.
556
+ */
557
+ startup?: 'off' | 'cached' | 'refresh'
558
+ }
559
+ }
560
+
561
+ /** Identity helper that gives a config file full type-checking + inference. */
562
+ export function defineConfig(config: DisplayCaseConfig): DisplayCaseConfig {
563
+ return config
564
+ }
@@ -0,0 +1,114 @@
1
+ /** @jsxImportSource @emotion/react */
2
+
3
+ import { describe, expect, test } from 'bun:test'
4
+ import createCache from '@emotion/cache'
5
+ import { CacheProvider, css } from '@emotion/react'
6
+ import createEmotionServer from '@emotion/server/create-instance'
7
+ import { type DisplayCaseConfig, defineCases, type StyleEngine } from '../index'
8
+ import { type DocAssets, renderDoc } from './documents'
9
+ import type { CaseTreeState } from './render-node'
10
+ import { makeCaseRenderer } from './ssr-render'
11
+
12
+ /**
13
+ * Real-library validation of the style-engine seam: the flagship emotion engine
14
+ * from `docs/style-engines.md`, verbatim, exercised through the actual case
15
+ * renderer and the production render document. This closes the server half of
16
+ * the spike (tasks 1.4 / 6.5) with `@emotion/react` + `@emotion/server` rather
17
+ * than a stub — proving render-time emotion styling is extracted and delivered
18
+ * in the document head before scripting. (Client adoption of the `data-emotion`
19
+ * tags is emotion's own runtime behavior, verified in a consuming repo.)
20
+ */
21
+
22
+ // ── The flagship recipe, copied from docs/style-engines.md ──────────────────
23
+ const emotionEngine: StyleEngine = () => {
24
+ const cache = createCache({ key: 'css' })
25
+ cache.compat = true
26
+ const { extractCriticalToChunks, constructStyleTagsFromChunks } =
27
+ createEmotionServer(cache)
28
+ return {
29
+ wrap: (node) => <CacheProvider value={cache}>{node}</CacheProvider>,
30
+ collect: (html) =>
31
+ constructStyleTagsFromChunks(extractCriticalToChunks(html)),
32
+ }
33
+ }
34
+
35
+ const config: DisplayCaseConfig = {
36
+ title: 'T',
37
+ roots: [],
38
+ styleEngines: [emotionEngine],
39
+ }
40
+
41
+ const state = (over: Partial<CaseTreeState>): CaseTreeState => ({
42
+ componentId: 'box',
43
+ caseId: 'default',
44
+ width: null,
45
+ tweaks: {},
46
+ ...over,
47
+ })
48
+
49
+ const assets: DocAssets = {
50
+ browser: '/b.js',
51
+ render: '/r.js',
52
+ primer: '/p.js',
53
+ }
54
+
55
+ // A component styled by emotion at render time (the `css` prop).
56
+ const Hot = () => <div css={css({ color: 'rgb(12, 34, 56)' })}>hot</div>
57
+ const Cool = () => <div css={css({ color: 'rgb(98, 76, 54)' })}>cool</div>
58
+
59
+ describe('emotion style engine (real library)', () => {
60
+ test('extracts render-time emotion CSS into headStyles', () => {
61
+ const render = makeCaseRenderer(
62
+ [defineCases('Box', { Default: () => <Hot /> })],
63
+ config,
64
+ )
65
+ const result = render(state({}))
66
+
67
+ // The markup carries an emotion-generated class…
68
+ expect(result.html).toContain('hot')
69
+ expect(result.html).toContain('css-')
70
+ // …and the head styling carries real <style data-emotion> tags with the rule.
71
+ expect(result.headStyles).toContain('data-emotion')
72
+ expect(result.headStyles).toContain('rgb(12, 34, 56)')
73
+ })
74
+
75
+ test('the extracted tags sit as a discrete block after the static <style>', () => {
76
+ const render = makeCaseRenderer(
77
+ [defineCases('Box', { Default: () => <Hot /> })],
78
+ config,
79
+ )
80
+ const result = render(state({}))
81
+ const html = renderDoc({
82
+ globalCss: '.g{}',
83
+ vitrineCss: '.v{}',
84
+ theme: 'light',
85
+ transparent: false,
86
+ fit: false,
87
+ markup: result.html,
88
+ ssr: true,
89
+ headStyles: result.headStyles,
90
+ assets,
91
+ })
92
+ // The real data-emotion tags land between the static block's close and </head>
93
+ // — verbatim, not folded into the base <style> (so client adoption works).
94
+ expect(html).toContain(`</style>${result.headStyles}</head>`)
95
+ expect(html).toContain('data-emotion')
96
+ })
97
+
98
+ test('each render is isolated — one case never carries another’s emotion CSS', () => {
99
+ const render = makeCaseRenderer(
100
+ [
101
+ defineCases('Box', { Default: () => <Hot /> }),
102
+ defineCases('Chip', { Default: () => <Cool /> }),
103
+ ],
104
+ config,
105
+ )
106
+ const hot = render(state({ componentId: 'box' }))
107
+ const cool = render(state({ componentId: 'chip' }))
108
+
109
+ expect(hot.headStyles).toContain('rgb(12, 34, 56)')
110
+ expect(hot.headStyles).not.toContain('rgb(98, 76, 54)')
111
+ expect(cool.headStyles).toContain('rgb(98, 76, 54)')
112
+ expect(cool.headStyles).not.toContain('rgb(12, 34, 56)')
113
+ })
114
+ })