@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,79 @@
1
+ import { describe, expect, test } from 'bun:test'
2
+ import { PNG } from 'pngjs'
3
+ import type { CaseContext, DiffResult } from '../../index'
4
+ import { pixelmatchDiff } from './pixelmatch-diff'
5
+
6
+ // The built-in diff is identity-agnostic, but DiffFn requires the case context;
7
+ // pass a stub and treat the (synchronous) result as a resolved DiffResult.
8
+ const CTX: CaseContext & { baselinePath: string } = {
9
+ componentId: 'button',
10
+ caseId: 'default',
11
+ theme: 'light',
12
+ width: 320,
13
+ baselinePath: 'baseline.png',
14
+ }
15
+ const diff = (baseline: Uint8Array, actual: Uint8Array): DiffResult =>
16
+ pixelmatchDiff({ baseline, actual }, CTX) as DiffResult
17
+
18
+ /** A solid-colour PNG of the given size, encoded to file bytes. */
19
+ function solidPng(
20
+ width: number,
21
+ height: number,
22
+ rgba: [number, number, number, number],
23
+ ): Buffer {
24
+ const png = new PNG({ width, height })
25
+ for (let i = 0; i < png.data.length; i += 4) {
26
+ png.data[i] = rgba[0]
27
+ png.data[i + 1] = rgba[1]
28
+ png.data[i + 2] = rgba[2]
29
+ png.data[i + 3] = rgba[3]
30
+ }
31
+ return PNG.sync.write(png)
32
+ }
33
+
34
+ /** A white PNG with one pixel (0,0) flipped to black. */
35
+ function spottedPng(width: number, height: number): Buffer {
36
+ const png = new PNG({ width, height })
37
+ for (let i = 0; i < png.data.length; i += 4) {
38
+ png.data[i] = 255
39
+ png.data[i + 1] = 255
40
+ png.data[i + 2] = 255
41
+ png.data[i + 3] = 255
42
+ }
43
+ png.data[0] = 0
44
+ png.data[1] = 0
45
+ png.data[2] = 0
46
+ return PNG.sync.write(png)
47
+ }
48
+
49
+ describe('pixelmatchDiff', () => {
50
+ test('reports no change for identical images', () => {
51
+ const result = diff(
52
+ solidPng(4, 4, [255, 255, 255, 255]),
53
+ solidPng(4, 4, [255, 255, 255, 255]),
54
+ )
55
+ expect(result.changed).toBe(false)
56
+ expect(result.mismatch).toBe(0)
57
+ })
58
+
59
+ test('reports a change with a count and a diff image when pixels differ', () => {
60
+ const result = diff(solidPng(4, 4, [255, 255, 255, 255]), spottedPng(4, 4))
61
+ expect(result.changed).toBe(true)
62
+ expect(result.mismatch).toBeGreaterThan(0)
63
+ expect(result.diffImage).toBeDefined()
64
+ // The diff image is itself a valid PNG.
65
+ expect(() =>
66
+ PNG.sync.read(Buffer.from(result.diffImage as Uint8Array)),
67
+ ).not.toThrow()
68
+ })
69
+
70
+ test('treats a size mismatch as changed without diffing pixels', () => {
71
+ const result = diff(
72
+ solidPng(4, 4, [255, 255, 255, 255]),
73
+ solidPng(8, 8, [255, 255, 255, 255]),
74
+ )
75
+ expect(result.changed).toBe(true)
76
+ expect(result.mismatch).toBeUndefined()
77
+ expect(result.diffImage).toBeUndefined()
78
+ })
79
+ })
@@ -0,0 +1,30 @@
1
+ import pixelmatch from 'pixelmatch'
2
+ import { PNG } from 'pngjs'
3
+ import type { DiffFn } from '../../index'
4
+
5
+ /**
6
+ * Built-in image diff: pixelmatch + pngjs. Imported lazily by the check runner
7
+ * only when no custom diff is configured, so `pixelmatch` and `pngjs` stay
8
+ * optional dependencies. A size mismatch counts as changed; otherwise any
9
+ * differing pixel (above a small per-pixel threshold) counts as changed, and a
10
+ * diff image is returned for the runner to write next to the baseline.
11
+ */
12
+
13
+ const PER_PIXEL_THRESHOLD = 0.1
14
+ const DIFF_THRESHOLD = 0 // allowed differing pixels before a case counts as changed
15
+
16
+ export const pixelmatchDiff: DiffFn = ({ baseline, actual }) => {
17
+ const a = PNG.sync.read(Buffer.from(baseline))
18
+ const b = PNG.sync.read(Buffer.from(actual))
19
+ if (a.width !== b.width || a.height !== b.height) {
20
+ return { changed: true }
21
+ }
22
+ const diff = new PNG({ width: a.width, height: a.height })
23
+ const mismatch = pixelmatch(a.data, b.data, diff.data, a.width, a.height, {
24
+ threshold: PER_PIXEL_THRESHOLD,
25
+ })
26
+ if (mismatch > DIFF_THRESHOLD) {
27
+ return { changed: true, mismatch, diffImage: PNG.sync.write(diff) }
28
+ }
29
+ return { changed: false, mismatch }
30
+ }
@@ -0,0 +1,104 @@
1
+ import { AxeBuilder } from '@axe-core/playwright'
2
+ import { chromium } from 'playwright'
3
+ import type {
4
+ A11yImpact,
5
+ A11yNodeDetail,
6
+ AuditOptions,
7
+ CaseContext,
8
+ RenderDriver,
9
+ RenderedPage,
10
+ } from '../../index'
11
+
12
+ /** The first numeric run in axe's `expectedContrastRatio` (e.g. `"4.5:1"` → 4.5). */
13
+ function parseRatio(value: unknown): number {
14
+ const m = /[\d.]+/.exec(String(value ?? ''))
15
+ return m ? Number(m[0]) : 0
16
+ }
17
+
18
+ /** Flatten one axe node into our serializable detail: the element + (for
19
+ * colour-contrast) the measured pair, so the cache/CLI carry actionable data. */
20
+ function nodeDetail(node: {
21
+ target?: unknown[]
22
+ html?: string
23
+ failureSummary?: string
24
+ any?: { id: string; data?: unknown }[]
25
+ all?: { id: string; data?: unknown }[]
26
+ none?: { id: string; data?: unknown }[]
27
+ }): A11yNodeDetail {
28
+ const detail: A11yNodeDetail = {
29
+ target: (node.target ?? []).map((t) => String(t)).join(' '),
30
+ html: (node.html ?? '').slice(0, 200),
31
+ }
32
+ if (node.failureSummary) detail.failureSummary = node.failureSummary
33
+ const cc = [
34
+ ...(node.any ?? []),
35
+ ...(node.all ?? []),
36
+ ...(node.none ?? []),
37
+ ].find((c) => c.id === 'color-contrast')
38
+ const d = cc?.data as Record<string, unknown> | undefined
39
+ if (d && (d.fgColor || d.bgColor)) {
40
+ detail.contrast = {
41
+ foreground: String(d.fgColor),
42
+ background: String(d.bgColor),
43
+ ratio: Number(d.contrastRatio),
44
+ required: parseRatio(d.expectedContrastRatio),
45
+ }
46
+ if (d.fontSize) detail.contrast.fontSize = String(d.fontSize)
47
+ if (d.fontWeight) detail.contrast.fontWeight = String(d.fontWeight)
48
+ }
49
+ return detail
50
+ }
51
+
52
+ /**
53
+ * Built-in render driver: Playwright Chromium + axe-core. Imported lazily by the
54
+ * check runner only when no custom driver is configured, so `playwright` and
55
+ * `@axe-core/playwright` stay optional dependencies.
56
+ *
57
+ * Reproduces the previous runner behavior: a fixed 1024×768 viewport, reduced
58
+ * motion, a wait for the network to settle and fonts to load, and a WCAG 2 A/AA
59
+ * audit (page-structure best-practice rules are out of scope for fragments).
60
+ */
61
+
62
+ const VIEWPORT = { width: 1024, height: 768 }
63
+ const WCAG_TAGS = ['wcag2a', 'wcag2aa', 'wcag21a', 'wcag21aa']
64
+
65
+ export async function createPlaywrightDriver(): Promise<RenderDriver> {
66
+ const browser = await chromium.launch()
67
+ const context = await browser.newContext({
68
+ viewport: VIEWPORT,
69
+ reducedMotion: 'reduce',
70
+ })
71
+
72
+ return {
73
+ async open(url: string, _ctx: CaseContext): Promise<RenderedPage> {
74
+ const page = await context.newPage()
75
+ await page.goto(url, { waitUntil: 'networkidle' })
76
+ await page.evaluate(() => document.fonts.ready)
77
+ return {
78
+ async screenshot() {
79
+ return page.screenshot()
80
+ },
81
+ async audit(opts?: AuditOptions) {
82
+ let builder = new AxeBuilder({ page }).withTags(WCAG_TAGS)
83
+ if (opts?.exclude?.length)
84
+ builder = builder.disableRules(opts.exclude)
85
+ const { violations } = await builder.analyze()
86
+ return violations.map((v) => ({
87
+ id: v.id,
88
+ help: v.help,
89
+ nodes: v.nodes.length,
90
+ // axe's impact is a string union or null/undefined; normalize.
91
+ impact: (v.impact ?? null) as A11yImpact | null,
92
+ details: v.nodes.map(nodeDetail),
93
+ }))
94
+ },
95
+ async dispose() {
96
+ await page.close()
97
+ },
98
+ }
99
+ },
100
+ async close() {
101
+ await browser.close()
102
+ },
103
+ }
104
+ }
@@ -0,0 +1,73 @@
1
+ import { afterEach, describe, expect, test } from 'bun:test'
2
+ import { rm } from 'node:fs/promises'
3
+ import { join } from 'node:path'
4
+ import { makeTempDir, writeFiles } from '../testing/test-helpers'
5
+ import { checkSsr } from './ssr-check'
6
+
7
+ // Case fixtures import the real authoring helpers by absolute path so they load
8
+ // from a temp dir outside the workspace's module resolution.
9
+ const DC = join(import.meta.dir, '..', 'index.ts')
10
+ const caseFile = (body: string) =>
11
+ `import { defineCases } from '${DC}'\nexport default ${body}\n`
12
+
13
+ const CONFIG = `export default { title: 'F', roots: ['**/*.case.tsx'] }\n`
14
+
15
+ describe('checkSsr', () => {
16
+ const dirs: string[] = []
17
+ const setup = async (files: Record<string, string>) => {
18
+ const dir = await makeTempDir()
19
+ dirs.push(dir)
20
+ await writeFiles(dir, { 'display-case.config.ts': CONFIG, ...files })
21
+ return dir
22
+ }
23
+ afterEach(async () => {
24
+ while (dirs.length)
25
+ await rm(dirs.pop() as string, { recursive: true, force: true })
26
+ })
27
+
28
+ test('a case that renders purely passes', async () => {
29
+ const dir = await setup({
30
+ 'Plain.case.tsx': caseFile(
31
+ `defineCases('Plain', { Default: () => 'hi' }, { level: 'atom' })`,
32
+ ),
33
+ })
34
+ const { findings, rendered, declared } = await checkSsr(dir)
35
+ expect(findings).toHaveLength(0)
36
+ expect(rendered).toBe(1)
37
+ expect(declared).toBe(0)
38
+ })
39
+
40
+ test('a case that touches a browser API during render is flagged', async () => {
41
+ const dir = await setup({
42
+ 'Widget.case.tsx': caseFile(
43
+ `defineCases('Widget', { Win: () => String(window.innerWidth) }, { level: 'atom' })`,
44
+ ),
45
+ })
46
+ const { findings } = await checkSsr(dir)
47
+ expect(findings).toHaveLength(1)
48
+ expect(findings[0].component).toBe('Widget')
49
+ expect(findings[0].case).toBe('Win')
50
+ expect(findings[0].error).toMatch(/window/)
51
+ expect(findings[0].file).toMatch(/Widget\.case\.tsx$/)
52
+ })
53
+
54
+ // The complementary property — a browser API used in an *effect* or *handler*
55
+ // (which never runs on the server) must NOT be flagged — is covered by the
56
+ // dogfood `ssr` check passing: SelectMenu/TweaksPanel/RenderAddress all reach
57
+ // for `window`/`getComputedStyle`/`navigator` inside effects and handlers, and
58
+ // every dogfood case renders server-side cleanly. (It can't be unit-fixtured
59
+ // here: a hook-using component needs a `react` import, which a temp-dir fixture
60
+ // outside the workspace can't resolve.)
61
+
62
+ test('a component declared browserOnly is skipped, not flagged', async () => {
63
+ const dir = await setup({
64
+ 'Widget.case.tsx': caseFile(
65
+ `defineCases('Widget', { Win: () => String(window.innerWidth) }, { level: 'atom', browserOnly: true })`,
66
+ ),
67
+ })
68
+ const { findings, rendered, declared } = await checkSsr(dir)
69
+ expect(findings).toHaveLength(0)
70
+ expect(rendered).toBe(0)
71
+ expect(declared).toBe(1)
72
+ })
73
+ })
@@ -0,0 +1,96 @@
1
+ import { renderToString } from 'react-dom/server'
2
+ import { buildCatalog } from '../core/catalog'
3
+ import {
4
+ discoverCaseFiles,
5
+ loadModules,
6
+ resolveConfig,
7
+ } from '../core/discovery'
8
+ import { caseTree, NOOP_GOTO } from '../render/render-node'
9
+
10
+ /**
11
+ * The `ssr` check: render every case on the server (`renderToString`, no
12
+ * browser) and flag any that can't — i.e. that touch a browser-only API
13
+ * *during render* and throw. This enforces the pre-scripting-rendering best
14
+ * practice: keep a case's render pure (browser APIs belong in effects and
15
+ * handlers, which never run on the server). It is the precise, zero-false-
16
+ * positive counterpart to a static "no browser APIs" lint, because it tests the
17
+ * one thing that matters — does this case pre-render? — rather than guessing
18
+ * from syntax whether a `window` reference sits in render or in an effect.
19
+ *
20
+ * A component declared `browserOnly` (in its case meta) is expected to need a
21
+ * browser; its cases are counted as declared, not flagged. The check is static
22
+ * in spirit (no server, no browser) and runs in the one-shot check process, so
23
+ * a bare module import is always current.
24
+ */
25
+
26
+ export interface SsrFinding {
27
+ /** Absolute path of the case file the finding is attributed to. */
28
+ file: string
29
+ /** Showcased component display name. */
30
+ component: string
31
+ /** Case display name. */
32
+ case: string
33
+ /** The throw's message (what browser API the render reached). */
34
+ error: string
35
+ }
36
+
37
+ export interface SsrCheckResult {
38
+ findings: SsrFinding[]
39
+ /** Cases that rendered on the server cleanly. */
40
+ rendered: number
41
+ /** Cases skipped because their component is declared `browserOnly`. */
42
+ declared: number
43
+ }
44
+
45
+ export async function checkSsr(pkgDir: string): Promise<SsrCheckResult> {
46
+ const { config } = await resolveConfig(pkgDir)
47
+ const files = await discoverCaseFiles(pkgDir, config)
48
+ const { modules } = await loadModules(files)
49
+
50
+ // Map a component's display name to its source file and its declared
51
+ // browser-only flag, so a finding can point at the file and declared
52
+ // components can be skipped.
53
+ const byName = new Map(modules.map((m) => [m.module.component, m]))
54
+ const caseModules = modules.map((m) => m.module)
55
+ const catalog = buildCatalog(caseModules)
56
+
57
+ const findings: SsrFinding[] = []
58
+ let rendered = 0
59
+ let declared = 0
60
+
61
+ for (const component of catalog) {
62
+ const loaded = byName.get(component.name)
63
+ if (!loaded) continue
64
+ if (loaded.module.browserOnly) {
65
+ declared += component.cases.length
66
+ continue
67
+ }
68
+ for (const cs of component.cases) {
69
+ try {
70
+ renderToString(
71
+ caseTree(
72
+ caseModules,
73
+ config,
74
+ {
75
+ componentId: component.id,
76
+ caseId: cs.id,
77
+ width: null,
78
+ tweaks: {},
79
+ },
80
+ NOOP_GOTO,
81
+ ),
82
+ )
83
+ rendered++
84
+ } catch (err) {
85
+ findings.push({
86
+ file: loaded.file,
87
+ component: component.name,
88
+ case: cs.name,
89
+ error: err instanceof Error ? err.message : String(err),
90
+ })
91
+ }
92
+ }
93
+ }
94
+
95
+ return { findings, rendered, declared }
96
+ }
@@ -0,0 +1,165 @@
1
+ import { afterEach, describe, expect, test } from 'bun:test'
2
+ import { mkdir, rm, symlink } from 'node:fs/promises'
3
+ import { join } from 'node:path'
4
+ import type { HierarchyLevel, StructureRuleId } from '../index'
5
+ import { makeTempDir, writeFiles } from '../testing/test-helpers'
6
+ import { checkStructure } from './structure-check'
7
+
8
+ /**
9
+ * Full cross-package coverage for the composition level-resolver.
10
+ *
11
+ * Each test builds two dummy Display-Case "repositories" in a temp dir — a
12
+ * shared library and a consumer app — and links the library into the app's
13
+ * `node_modules` so the bare specifier `@dc-fixture/lib` resolves exactly as it
14
+ * would in a real workspace. The app's component imports from the library, so
15
+ * the resolver must (1) resolve the bare specifier to the library package, (2)
16
+ * recognize it as a showcase, (3) follow its barrel re-export to the component
17
+ * file, and (4) read that component's declared level. Raw case-module objects
18
+ * are used so the fixtures need no `display-case` import to load.
19
+ */
20
+
21
+ const ALL_RULES: StructureRuleId[] = [
22
+ 'case-placard-coverage',
23
+ 'no-orphaned-placard-doc',
24
+ 'primer-present-and-used',
25
+ 'setup-present',
26
+ 'config-paths-exist',
27
+ 'levels-classified',
28
+ 'cases-load',
29
+ 'flow-transitions-resolve',
30
+ 'flow-multi-step',
31
+ 'unique-slugs',
32
+ 'tweak-defaults-valid',
33
+ 'atom-purity',
34
+ 'no-downward-dependency',
35
+ 'composes-lower-level',
36
+ 'level-fit',
37
+ ]
38
+
39
+ // App config enabling only the rule under test (so cross-package findings are
40
+ // isolated from the default-on rules the bare fixtures would otherwise trip).
41
+ const appConfig = (rule: StructureRuleId) => {
42
+ const rules = ALL_RULES.map(
43
+ (id) => `'${id}': ${id === rule ? '{}' : 'false'}`,
44
+ ).join(', ')
45
+ return `export default { title:'App', roots:['src/**/*.case.tsx'], check:{ structure:{ rules:{ ${rules} } } } }\n`
46
+ }
47
+
48
+ const caseModule = (component: string, level: HierarchyLevel) =>
49
+ `export default { component:'${component}', cases:{}, isFlow:false, level:'${level}' }\n`
50
+
51
+ interface ReposOpts {
52
+ libLevel: HierarchyLevel
53
+ appLevel: HierarchyLevel
54
+ /** 'named' ⇒ followable `export { X } from`; 'star' ⇒ unfollowable `export *`. */
55
+ barrel: 'named' | 'star'
56
+ rule: StructureRuleId
57
+ }
58
+
59
+ describe('checkStructure cross-package resolution', () => {
60
+ const dirs: string[] = []
61
+
62
+ // Build the lib + app repos and link lib into app/node_modules. Returns appDir.
63
+ const setupRepos = async (o: ReposOpts): Promise<string> => {
64
+ const root = await makeTempDir()
65
+ dirs.push(root)
66
+ const libDir = join(root, 'lib')
67
+ const appDir = join(root, 'app')
68
+
69
+ const barrel =
70
+ o.barrel === 'named'
71
+ ? `export { LibThing } from './thing'\n`
72
+ : `export * from './thing'\n`
73
+
74
+ await writeFiles(libDir, {
75
+ 'package.json': JSON.stringify({
76
+ name: '@dc-fixture/lib',
77
+ type: 'module',
78
+ main: './src/index.ts',
79
+ exports: { '.': './src/index.ts' },
80
+ }),
81
+ 'display-case.config.ts': `export default { title:'Lib', roots:['src/**/*.case.tsx'] }\n`,
82
+ 'src/index.ts': barrel,
83
+ 'src/thing.tsx': 'export const LibThing = () => null\n',
84
+ 'src/thing.case.tsx': caseModule('LibThing', o.libLevel),
85
+ })
86
+
87
+ await writeFiles(appDir, {
88
+ 'package.json': JSON.stringify({
89
+ name: '@dc-fixture/app',
90
+ type: 'module',
91
+ }),
92
+ 'display-case.config.ts': appConfig(o.rule),
93
+ 'src/cmp.tsx': `import { LibThing } from '@dc-fixture/lib'\nexport const Cmp = () => LibThing\n`,
94
+ 'src/cmp.case.tsx': caseModule('Cmp', o.appLevel),
95
+ })
96
+
97
+ // Link the library into the app's node_modules so the bare specifier resolves.
98
+ await mkdir(join(appDir, 'node_modules', '@dc-fixture'), {
99
+ recursive: true,
100
+ })
101
+ await symlink(
102
+ libDir,
103
+ join(appDir, 'node_modules', '@dc-fixture', 'lib'),
104
+ 'dir',
105
+ )
106
+
107
+ return appDir
108
+ }
109
+
110
+ const findings = async (appDir: string, rule: StructureRuleId) =>
111
+ (await checkStructure(appDir)).findings.filter((f) => f.rule === rule)
112
+
113
+ afterEach(async () => {
114
+ while (dirs.length)
115
+ await rm(dirs.pop() as string, { recursive: true, force: true })
116
+ })
117
+
118
+ test('composes-lower-level is satisfied by a lower-level component from another showcase', async () => {
119
+ const app = await setupRepos({
120
+ libLevel: 'atom',
121
+ appLevel: 'molecule',
122
+ barrel: 'named',
123
+ rule: 'composes-lower-level',
124
+ })
125
+ // The molecule composes nothing locally; only the cross-package atom can
126
+ // satisfy the rule, so zero findings proves the resolver followed it.
127
+ expect(await findings(app, 'composes-lower-level')).toHaveLength(0)
128
+ })
129
+
130
+ test('an organism composed only of a foreign atom passes', async () => {
131
+ const app = await setupRepos({
132
+ libLevel: 'atom',
133
+ appLevel: 'organism',
134
+ barrel: 'named',
135
+ rule: 'composes-lower-level',
136
+ })
137
+ expect(await findings(app, 'composes-lower-level')).toHaveLength(0)
138
+ })
139
+
140
+ test('no-downward-dependency catches a higher-level component from another showcase', async () => {
141
+ const app = await setupRepos({
142
+ libLevel: 'organism',
143
+ appLevel: 'atom',
144
+ barrel: 'named',
145
+ rule: 'no-downward-dependency',
146
+ })
147
+ const f = await findings(app, 'no-downward-dependency')
148
+ expect(f).toHaveLength(1)
149
+ expect(f[0].severity).toBe('error')
150
+ expect(f[0].message).toContain('organism')
151
+ })
152
+
153
+ test('an unfollowable workspace-showcase import warns, not errors', async () => {
154
+ const app = await setupRepos({
155
+ libLevel: 'atom',
156
+ appLevel: 'molecule',
157
+ barrel: 'star', // `export *` — the resolver cannot bind the name to a file
158
+ rule: 'composes-lower-level',
159
+ })
160
+ const f = await findings(app, 'composes-lower-level')
161
+ expect(f).toHaveLength(1)
162
+ expect(f[0].severity).toBe('warn')
163
+ expect(f[0].message).toContain('could not be resolved')
164
+ })
165
+ })