@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,194 @@
1
+ import { afterEach, describe, expect, test } from 'bun:test'
2
+ import { rm } from 'node:fs/promises'
3
+ import { join } from 'node:path'
4
+ import type { A11yViolation } from '../index'
5
+ import { makeTempDir, writeFiles } from '../testing/test-helpers'
6
+ import { a11yDetailLines, runChecks } from './check'
7
+
8
+ const CLI = join(import.meta.dir, '..', 'cli.ts')
9
+
10
+ /**
11
+ * `a11yDetailLines` turns a violation's per-node detail into the indented lines
12
+ * the CLI prints — the colour pair / element that makes a finding fixable. It's
13
+ * pure, so it's the one piece of the a11y output testable without a browser.
14
+ */
15
+ describe('check: a11y detail formatting', () => {
16
+ test('color-contrast node renders the measured pair and threshold', () => {
17
+ const v: A11yViolation = {
18
+ id: 'color-contrast',
19
+ help: 'Elements must meet minimum color contrast ratio thresholds',
20
+ nodes: 1,
21
+ impact: 'serious',
22
+ details: [
23
+ {
24
+ target: 'span.dcui-eyebrow',
25
+ html: '<span class="dcui-eyebrow">Tweaks</span>',
26
+ contrast: {
27
+ foreground: '#8a8073',
28
+ background: '#ffffff',
29
+ ratio: 3.71,
30
+ required: 4.5,
31
+ fontSize: '12.0pt',
32
+ fontWeight: '400',
33
+ },
34
+ },
35
+ ],
36
+ }
37
+ expect(a11yDetailLines(v)).toEqual([
38
+ ' ↳ span.dcui-eyebrow #8a8073 on #ffffff = 3.71:1 (need 4.5:1) [12.0pt 400]',
39
+ ])
40
+ })
41
+
42
+ test('non-contrast node renders the element and first summary line', () => {
43
+ const v: A11yViolation = {
44
+ id: 'select-name',
45
+ help: 'Select element must have an accessible name',
46
+ nodes: 1,
47
+ impact: 'critical',
48
+ details: [
49
+ {
50
+ target: 'select.dcui-select-el',
51
+ html: '<select class="dcui-select-el">',
52
+ failureSummary: 'Fix any of the following:\n Element has no name',
53
+ },
54
+ ],
55
+ }
56
+ expect(a11yDetailLines(v)).toEqual([
57
+ ' ↳ select.dcui-select-el Fix any of the following:',
58
+ ])
59
+ })
60
+
61
+ test('caps the inline list and notes the remainder', () => {
62
+ const details = Array.from({ length: 10 }, (_, i) => ({
63
+ target: `x${i}`,
64
+ html: '',
65
+ contrast: {
66
+ foreground: '#000',
67
+ background: '#fff',
68
+ ratio: 1,
69
+ required: 4.5,
70
+ },
71
+ }))
72
+ const lines = a11yDetailLines({
73
+ id: 'color-contrast',
74
+ help: 'h',
75
+ nodes: 10,
76
+ impact: 'serious',
77
+ details,
78
+ })
79
+ expect(lines).toHaveLength(9)
80
+ expect(lines.at(-1)).toBe(' ↳ … +2 more node(s)')
81
+ })
82
+
83
+ test('a violation with no detail yields no lines', () => {
84
+ expect(
85
+ a11yDetailLines({
86
+ id: 'x',
87
+ help: 'h',
88
+ nodes: 1,
89
+ impact: null,
90
+ }),
91
+ ).toEqual([])
92
+ })
93
+ })
94
+
95
+ /**
96
+ * The structure (and token) phases are static: `runChecks` must not start the
97
+ * dev server for them, and the CLI must honor `check.defaultPhases`. These are
98
+ * the only render-free assertions we can make without booting a browser, so the
99
+ * a11y/visual phases are exercised by the e2e suite, not here.
100
+ */
101
+ describe('check: structure phase wiring', () => {
102
+ const dirs: string[] = []
103
+ const setup = async (files: Record<string, string>) => {
104
+ const dir = await makeTempDir()
105
+ dirs.push(dir)
106
+ await writeFiles(dir, files)
107
+ return dir
108
+ }
109
+ afterEach(async () => {
110
+ while (dirs.length)
111
+ await rm(dirs.pop() as string, { recursive: true, force: true })
112
+ })
113
+
114
+ const AllRules = [
115
+ 'case-placard-coverage',
116
+ 'no-orphaned-placard-doc',
117
+ 'primer-present-and-used',
118
+ 'setup-present',
119
+ 'config-paths-exist',
120
+ 'levels-classified',
121
+ 'cases-load',
122
+ 'flow-transitions-resolve',
123
+ 'flow-multi-step',
124
+ 'unique-slugs',
125
+ 'tweak-defaults-valid',
126
+ 'atom-purity',
127
+ 'no-downward-dependency',
128
+ 'composes-lower-level',
129
+ 'level-fit',
130
+ ]
131
+ // A config that enables only the named rules (others disabled), so the static
132
+ // run is deterministic and free of the noisy default-on rules.
133
+ const cfg = (enabled: string[]) => {
134
+ const rules = AllRules.map(
135
+ (id) => `'${id}': ${enabled.includes(id) ? '{}' : 'false'}`,
136
+ ).join(', ')
137
+ return `export default { title:'F', roots:['**/*.case.tsx'], check:{ structure:{ rules:{ ${rules} } } } }\n`
138
+ }
139
+ const runStructure = (dir: string) =>
140
+ runChecks(dir, {
141
+ structure: true,
142
+ tokens: false,
143
+ a11y: false,
144
+ visual: false,
145
+ ssr: false,
146
+ update: false,
147
+ })
148
+
149
+ test('structure-only run resolves without starting a server', async () => {
150
+ const dir = await setup({
151
+ 'display-case.config.ts': cfg(['levels-classified']),
152
+ 'X.case.tsx': `export default { component:'X', cases:{}, isFlow:false, level:'atom' }\n`,
153
+ })
154
+ // No port is passed; a structure-only run must not need or start the server.
155
+ expect(await runStructure(dir)).toBe(true)
156
+ })
157
+
158
+ test('structure-only run returns false on an error finding', async () => {
159
+ const dir = await setup({
160
+ 'display-case.config.ts': cfg(['levels-classified']),
161
+ // Unclassified (no level) ⇒ an error-severity finding.
162
+ 'X.case.tsx': `export default { component:'X', cases:{}, isFlow:false }\n`,
163
+ })
164
+ expect(await runStructure(dir)).toBe(false)
165
+ })
166
+
167
+ const cli = async (dir: string, args: string[]) => {
168
+ const proc = Bun.spawn(['bun', CLI, 'check', dir, ...args], {
169
+ stdout: 'pipe',
170
+ stderr: 'pipe',
171
+ })
172
+ const [out, err] = await Promise.all([
173
+ new Response(proc.stdout).text(),
174
+ new Response(proc.stderr).text(),
175
+ ])
176
+ await proc.exited
177
+ return out + err
178
+ }
179
+
180
+ test('defaultPhases opts a phase out of the bare run but not the explicit one', async () => {
181
+ // Every phase opted out of the default run, so the bare `check` runs nothing
182
+ // (and never boots a browser). X is unclassified ⇒ structure errors when run.
183
+ const dir = await setup({
184
+ 'display-case.config.ts':
185
+ `export default { title:'F', roots:['**/*.case.tsx'], ` +
186
+ `check:{ defaultPhases:{ tokens:false, a11y:false, visual:false, structure:false } } }\n`,
187
+ 'X.case.tsx': `export default { component:'X', cases:{}, isFlow:false }\n`,
188
+ })
189
+ const bare = await cli(dir, [])
190
+ expect(bare).not.toContain('structure ✗')
191
+ const explicit = await cli(dir, ['--structure'])
192
+ expect(explicit).toContain('structure ✗')
193
+ })
194
+ })
@@ -0,0 +1,473 @@
1
+ import { mkdir } from 'node:fs/promises'
2
+ import { dirname, extname, join, relative, resolve, sep } from 'node:path'
3
+ import { Glob } from 'bun'
4
+ import { componentClosures } from '../core/affected'
5
+ import { baselineDir, cacheDir, resolveConfig } from '../core/discovery'
6
+ import type { ManifestComponent } from '../core/manifest'
7
+ import type {
8
+ A11yViolation,
9
+ CaseContext,
10
+ DiffFn,
11
+ DisplayCaseConfig,
12
+ RenderDriver,
13
+ } from '../index'
14
+ import { startDisplayCase } from '../server/server'
15
+ import { checkSsr } from './ssr-check'
16
+ import { checkStructure } from './structure-check'
17
+ import { checkTokens } from './tokens-check'
18
+
19
+ /**
20
+ * Headless a11y + visual-regression runner. The capture/audit driver and the
21
+ * image diff are pluggable (config `providers`); when unset, the built-in
22
+ * Playwright/axe + pixelmatch/pngjs defaults are imported lazily — so those
23
+ * packages are needed only when a default-backed check actually runs.
24
+ */
25
+
26
+ const THEMES = ['light', 'dark'] as const
27
+ const VIEWPORT_WIDTH = 1024
28
+
29
+ const INSTALL_HINT =
30
+ 'Visual/a11y checks need the default toolchain. Install it with ' +
31
+ '`bun add -d playwright @axe-core/playwright pixelmatch pngjs && bunx playwright install chromium` ' +
32
+ '(or run `display-case init --with-visual`), or set `providers.driver`/`providers.diff` in display-case.config.ts.'
33
+
34
+ export interface CheckOptions {
35
+ a11y: boolean
36
+ visual: boolean
37
+ tokens: boolean
38
+ structure: boolean
39
+ /** Server-render every case and fail on any that can't pre-render. */
40
+ ssr: boolean
41
+ update: boolean
42
+ /** Treat structure warnings as errors (CLI `--strict`). */
43
+ strict?: boolean
44
+ /** Restrict the render phases (a11y/visual) to these component ids or globs. */
45
+ only?: string[]
46
+ /** Restrict the render phases to components whose import closure touches a
47
+ * file changed since this git ref (CLI `--changed[=ref]`). */
48
+ changedRef?: string
49
+ port?: number
50
+ }
51
+
52
+ interface Target {
53
+ componentId: string
54
+ caseId: string
55
+ theme: (typeof THEMES)[number]
56
+ renderUrl: string
57
+ }
58
+
59
+ /** One scanned variant in the written a11y report (only failing variants). */
60
+ interface A11yReportEntry {
61
+ component: string
62
+ case: string
63
+ theme: string
64
+ violations: A11yViolation[]
65
+ }
66
+
67
+ /** Cap on per-violation detail lines printed inline; the full set is always in
68
+ * the written report. Keeps a noisy run's console readable. */
69
+ const A11Y_DETAIL_CAP = 8
70
+
71
+ /**
72
+ * Indented, human-readable detail lines for one violation: the failing element
73
+ * and, for colour-contrast, the exact measured pair and threshold — the data
74
+ * that makes a finding fixable without re-running a browser. Pure (no I/O) so
75
+ * the formatting is unit-tested.
76
+ */
77
+ export function a11yDetailLines(v: A11yViolation): string[] {
78
+ const details = v.details ?? []
79
+ const lines = details.slice(0, A11Y_DETAIL_CAP).map((d) => {
80
+ const where = d.target || '(element)'
81
+ if (d.contrast) {
82
+ const c = d.contrast
83
+ const font = c.fontSize
84
+ ? ` [${c.fontSize}${c.fontWeight ? ` ${c.fontWeight}` : ''}]`
85
+ : ''
86
+ return ` ↳ ${where} ${c.foreground} on ${c.background} = ${c.ratio}:1 (need ${c.required}:1)${font}`
87
+ }
88
+ const why = (d.failureSummary ?? '').split('\n')[0].trim()
89
+ return ` ↳ ${where}${why ? ` ${why}` : ''}`
90
+ })
91
+ if (details.length > A11Y_DETAIL_CAP) {
92
+ lines.push(` ↳ … +${details.length - A11Y_DETAIL_CAP} more node(s)`)
93
+ }
94
+ return lines
95
+ }
96
+
97
+ async function resolveDriver(config: DisplayCaseConfig): Promise<RenderDriver> {
98
+ if (config.providers?.driver) return await config.providers.driver()
99
+ try {
100
+ const { createPlaywrightDriver } = await import(
101
+ './providers/playwright-driver'
102
+ )
103
+ return await createPlaywrightDriver()
104
+ } catch (err) {
105
+ throw new Error(
106
+ `${INSTALL_HINT}\n (${err instanceof Error ? err.message : String(err)})`,
107
+ )
108
+ }
109
+ }
110
+
111
+ async function resolveDiff(config: DisplayCaseConfig): Promise<DiffFn> {
112
+ if (config.providers?.diff) return config.providers.diff
113
+ try {
114
+ const { pixelmatchDiff } = await import('./providers/pixelmatch-diff')
115
+ return pixelmatchDiff
116
+ } catch (err) {
117
+ throw new Error(
118
+ `${INSTALL_HINT}\n (${err instanceof Error ? err.message : String(err)})`,
119
+ )
120
+ }
121
+ }
122
+
123
+ /**
124
+ * Absolute paths of files changed since `ref`, for `--changed` scoping. Unions
125
+ * the committed diff since the merge-base (`ref...HEAD`) with the working tree
126
+ * (`HEAD`), so a local run also sees staged/unstaged edits and CI sees the PR's
127
+ * commits. Returns an empty list when git is unavailable or `ref` can't be
128
+ * resolved (e.g. an over-shallow clone) — the caller treats "no changes" as
129
+ * "nothing affected".
130
+ */
131
+ async function changedSince(pkgDir: string, ref: string): Promise<string[]> {
132
+ const top = await Bun.$`git -C ${pkgDir} rev-parse --show-toplevel`
133
+ .quiet()
134
+ .nothrow()
135
+ if (top.exitCode !== 0) return []
136
+ const root = top.stdout.toString().trim()
137
+ const names = new Set<string>()
138
+ for (const range of [`${ref}...HEAD`, 'HEAD']) {
139
+ const out = await Bun.$`git -C ${root} diff --name-only ${range}`
140
+ .quiet()
141
+ .nothrow()
142
+ if (out.exitCode !== 0) continue
143
+ for (const line of out.stdout.toString().split('\n')) {
144
+ if (line.trim()) names.add(resolve(root, line.trim()))
145
+ }
146
+ }
147
+ return [...names]
148
+ }
149
+
150
+ // Extensions whose change can alter a rendered case (markup, behaviour, style).
151
+ const RENDER_EXTS = new Set(['.tsx', '.ts', '.jsx', '.js', '.css', '.mdx'])
152
+ // Trees under the package that never feed a render (so a change there scopes to
153
+ // nothing): docs, specs, the e2e suite, agent skills, build/CI tooling.
154
+ const NON_RENDER_DIRS =
155
+ /^(\.github|\.claude|contributing|docs|e2e|skills|scripts|tools|node_modules)(\/|$)/
156
+
157
+ /** Whether a changed file (absolute) can affect a rendered case. */
158
+ function isRenderRelevant(file: string, pkgRoot: string): boolean {
159
+ if (file !== pkgRoot && !file.startsWith(pkgRoot + sep)) return false
160
+ const rel = relative(pkgRoot, file)
161
+ if (NON_RENDER_DIRS.test(rel)) return false
162
+ if (/\.(test|spec)\.[tj]sx?$/.test(rel) || /\.test-d\.ts$/.test(rel))
163
+ return false
164
+ if (rel.endsWith('.d.ts')) return false
165
+ return RENDER_EXTS.has(extname(file))
166
+ }
167
+
168
+ /**
169
+ * The components affected by the changes since `ref`. Render-irrelevant changes
170
+ * (docs, specs, tests, tooling) scope to nothing. A render-relevant change is
171
+ * attributed to a component when it lies in that component's import closure; a
172
+ * render-relevant change that *no* closure claims — globally-inlined component
173
+ * CSS, the render pipeline, shared source — conservatively affects every
174
+ * component, so a regression is never silently skipped.
175
+ */
176
+ async function changedScope(
177
+ pkgDir: string,
178
+ ref: string,
179
+ comps: { id: string; caseFile: string }[],
180
+ ): Promise<Set<string>> {
181
+ const pkgRoot = resolve(pkgDir)
182
+ const changed = (await changedSince(pkgDir, ref)).filter((f) =>
183
+ isRenderRelevant(f, pkgRoot),
184
+ )
185
+ if (changed.length === 0) return new Set()
186
+ const closures = await componentClosures(comps)
187
+ const claimed = new Set<string>()
188
+ for (const files of closures.values()) for (const f of files) claimed.add(f)
189
+ // Any render-relevant change outside every closure ⇒ a global input changed.
190
+ if (changed.some((f) => !claimed.has(f))) {
191
+ return new Set(comps.map((c) => c.id))
192
+ }
193
+ const changedSet = new Set(changed)
194
+ const affected = new Set<string>()
195
+ for (const [id, files] of closures) {
196
+ for (const f of files) {
197
+ if (changedSet.has(f)) {
198
+ affected.add(id)
199
+ break
200
+ }
201
+ }
202
+ }
203
+ return affected
204
+ }
205
+
206
+ export async function runChecks(
207
+ pkgDir: string,
208
+ opts: CheckOptions,
209
+ ): Promise<boolean> {
210
+ const { config } = await resolveConfig(pkgDir)
211
+ const baselines = baselineDir(pkgDir, config)
212
+
213
+ // Token conformance is a static parse — run it first, with no browser/server.
214
+ let tokenViolations = 0
215
+ if (opts.tokens) {
216
+ const { violations } = await checkTokens(pkgDir)
217
+ tokenViolations = violations.length
218
+ for (const v of violations) {
219
+ const rel = v.file.startsWith(`${pkgDir}/`)
220
+ ? v.file.slice(pkgDir.length + 1)
221
+ : v.file
222
+ console.error(
223
+ ` tokens ✗ ${rel}:${v.line}:${v.column} unknown token ${v.token}${v.hadFallback ? ' (fallback does not excuse it)' : ''}`,
224
+ )
225
+ }
226
+ }
227
+
228
+ // Structure best-practice checks are also static — no browser/server.
229
+ let structureErrors = 0
230
+ let structureWarnings = 0
231
+ if (opts.structure) {
232
+ const { findings } = await checkStructure(pkgDir, { strict: opts.strict })
233
+ for (const f of findings) {
234
+ const rel = f.file.startsWith(`${pkgDir}/`)
235
+ ? f.file.slice(pkgDir.length + 1)
236
+ : f.file
237
+ const line = ` structure ${f.severity === 'error' ? '✗' : '⚠'} ${rel}: ${f.message} (${f.rule})`
238
+ if (f.severity === 'error') {
239
+ structureErrors++
240
+ console.error(line)
241
+ } else {
242
+ structureWarnings++
243
+ console.warn(line)
244
+ }
245
+ }
246
+ }
247
+
248
+ // SSR-safety is a static check too: render every case with `renderToString`
249
+ // (no browser, no server) and flag any that can't pre-render — a case that
250
+ // touches a browser-only API during render. Declared-`browserOnly` components
251
+ // are expected and skipped.
252
+ let ssrErrors = 0
253
+ if (opts.ssr) {
254
+ const { findings, declared } = await checkSsr(pkgDir)
255
+ for (const f of findings) {
256
+ ssrErrors++
257
+ const rel = f.file.startsWith(`${pkgDir}/`)
258
+ ? f.file.slice(pkgDir.length + 1)
259
+ : f.file
260
+ console.error(
261
+ ` ssr ✗ ${rel}: ${f.component}/${f.case} can't render before scripts (${f.error}). ` +
262
+ 'Move browser APIs into effects/handlers, or declare the component browserOnly.',
263
+ )
264
+ }
265
+ if (declared)
266
+ console.log(` ssr: ${declared} case(s) declared browser-only`)
267
+ }
268
+
269
+ const staticErrors = tokenViolations + structureErrors + ssrErrors
270
+
271
+ // The browser phases (a11y + visual) are the only ones needing a live render.
272
+ if (!opts.a11y && !opts.visual) {
273
+ const ok = staticErrors === 0
274
+ const warn = structureWarnings ? `, ${structureWarnings} warning(s)` : ''
275
+ console.log(
276
+ ok
277
+ ? `\n ✓ checks passed${warn}`
278
+ : `\n ✗ ${tokenViolations} token violation(s), ${structureErrors} structure error(s), ${ssrErrors} ssr error(s)${warn}`,
279
+ )
280
+ return ok
281
+ }
282
+
283
+ const server = await startDisplayCase(pkgDir, { port: opts.port ?? 0 })
284
+ const base = String(server.url).replace(/\/$/, '')
285
+ const manifest = await fetch(`${base}/manifest.json`).then((r) => r.json())
286
+
287
+ // Resolve the change-scope for the render phases. `null` means no scoping —
288
+ // every component is checked (the default). Otherwise it is the set of
289
+ // component ids to check; an empty set short-circuits before any browser work.
290
+ let scope: Set<string> | null = null
291
+ if (opts.only || opts.changedRef) {
292
+ const comps = (manifest.components as ManifestComponent[]).map((c) => ({
293
+ id: c.id,
294
+ caseFile: resolve(pkgDir, c.caseFile),
295
+ }))
296
+ const sets: Set<string>[] = []
297
+ if (opts.only) {
298
+ const globs = opts.only.map((g) => new Glob(g))
299
+ sets.push(
300
+ new Set(
301
+ comps
302
+ .filter((c) => globs.some((g) => g.match(c.id)))
303
+ .map((c) => c.id),
304
+ ),
305
+ )
306
+ }
307
+ if (opts.changedRef) {
308
+ sets.push(await changedScope(pkgDir, opts.changedRef, comps))
309
+ }
310
+ // Both flags present ⇒ a component must satisfy both (intersection).
311
+ scope = sets[0]
312
+ for (const s of sets.slice(1)) {
313
+ const next = new Set<string>()
314
+ for (const id of scope) if (s.has(id)) next.add(id)
315
+ scope = next
316
+ }
317
+ const basis = [
318
+ opts.only ? '--only' : null,
319
+ opts.changedRef ? `--changed=${opts.changedRef}` : null,
320
+ ]
321
+ .filter(Boolean)
322
+ .join(' + ')
323
+ console.log(
324
+ ` scope: ${scope.size} of ${comps.length} component(s) (${basis})`,
325
+ )
326
+ if (scope.size === 0) {
327
+ server.stop(true)
328
+ const ok = staticErrors === 0
329
+ const warn = structureWarnings ? `, ${structureWarnings} warning(s)` : ''
330
+ console.log(
331
+ ok
332
+ ? `\n ✓ checks passed — no affected components${warn}`
333
+ : `\n ✗ ${tokenViolations} token violation(s), ${structureErrors} structure error(s), ${ssrErrors} ssr error(s)${warn}`,
334
+ )
335
+ return ok
336
+ }
337
+ }
338
+
339
+ const targets: Target[] = []
340
+ for (const c of manifest.components) {
341
+ if (scope && !scope.has(c.id)) continue
342
+ for (const cs of c.cases) {
343
+ for (const theme of THEMES) {
344
+ targets.push({
345
+ componentId: c.id,
346
+ caseId: cs.id,
347
+ theme,
348
+ renderUrl: `${base}${cs.renderUrl}?theme=${theme}`,
349
+ })
350
+ }
351
+ }
352
+ }
353
+
354
+ // Shared scan parameters (also honored by the live in-app surface) so the
355
+ // panel and this gate agree on what counts as a violation. `enabled` is NOT
356
+ // consulted here — the gate runs whenever invoked.
357
+ const a11yThemes = config.a11y?.themes ?? THEMES
358
+ const a11yExclude = config.a11y?.exclude
359
+
360
+ const driver = await resolveDriver(config)
361
+ const diff = opts.visual ? await resolveDiff(config) : null
362
+
363
+ let a11yViolations = 0
364
+ let visualChanges = 0
365
+ let recorded = 0
366
+ const a11yReport: A11yReportEntry[] = []
367
+
368
+ try {
369
+ for (const t of targets) {
370
+ const ctx: CaseContext = {
371
+ componentId: t.componentId,
372
+ caseId: t.caseId,
373
+ theme: t.theme,
374
+ width: VIEWPORT_WIDTH,
375
+ }
376
+ const page = await driver.open(t.renderUrl, ctx)
377
+
378
+ if (opts.a11y && a11yThemes.includes(t.theme)) {
379
+ const violations = await page.audit({ exclude: a11yExclude })
380
+ if (violations.length) {
381
+ a11yReport.push({
382
+ component: t.componentId,
383
+ case: t.caseId,
384
+ theme: t.theme,
385
+ violations,
386
+ })
387
+ }
388
+ for (const v of violations) {
389
+ a11yViolations++
390
+ const sev = v.impact ? `${v.impact} ` : ''
391
+ console.error(
392
+ ` a11y ✗ ${t.componentId}/${t.caseId} [${t.theme}] ${sev}${v.id}: ${v.help} (${v.nodes} node(s))`,
393
+ )
394
+ for (const line of a11yDetailLines(v)) console.error(line)
395
+ }
396
+ }
397
+
398
+ if (opts.visual && diff) {
399
+ const shot = await page.screenshot()
400
+ const file = join(
401
+ baselines,
402
+ t.componentId,
403
+ `${t.caseId}.${t.theme}.png`,
404
+ )
405
+ if (opts.update || !(await Bun.file(file).exists())) {
406
+ await mkdir(dirname(file), { recursive: true })
407
+ await Bun.write(file, shot)
408
+ recorded++
409
+ } else {
410
+ const baseline = new Uint8Array(await Bun.file(file).arrayBuffer())
411
+ const res = await diff(
412
+ { baseline, actual: shot },
413
+ { ...ctx, baselinePath: file },
414
+ )
415
+ if (res.changed) {
416
+ visualChanges++
417
+ if (res.diffImage) {
418
+ await Bun.write(
419
+ file.replace(/\.png$/, '.diff.png'),
420
+ res.diffImage,
421
+ )
422
+ }
423
+ console.error(
424
+ ` visual ✗ ${t.componentId}/${t.caseId} [${t.theme}] differs from baseline`,
425
+ )
426
+ }
427
+ }
428
+ }
429
+
430
+ await page.dispose()
431
+ }
432
+ } finally {
433
+ await driver.close()
434
+ server.stop(true)
435
+ }
436
+
437
+ if (opts.visual && recorded) console.log(` recorded ${recorded} baseline(s)`)
438
+
439
+ // Persist the full run (every failing variant, with per-node detail) so an
440
+ // agent or human can read the exact failing colours/elements later without
441
+ // re-running the browser. Written under the gitignored cache dir, overwriting
442
+ // the prior run; a clean run leaves an empty `results` so the file is current.
443
+ if (opts.a11y) {
444
+ const reportPath = join(cacheDir(pkgDir), 'a11y', 'last-check.json')
445
+ await mkdir(dirname(reportPath), { recursive: true })
446
+ await Bun.write(
447
+ reportPath,
448
+ `${JSON.stringify(
449
+ { scannedAt: Date.now(), total: a11yViolations, results: a11yReport },
450
+ null,
451
+ 2,
452
+ )}\n`,
453
+ )
454
+ const rel = reportPath.startsWith(`${pkgDir}/`)
455
+ ? reportPath.slice(pkgDir.length + 1)
456
+ : reportPath
457
+ console.log(` a11y detail → ${rel}${a11yViolations ? '' : ' (clean run)'}`)
458
+ }
459
+
460
+ const ok =
461
+ a11yViolations === 0 &&
462
+ visualChanges === 0 &&
463
+ tokenViolations === 0 &&
464
+ structureErrors === 0 &&
465
+ ssrErrors === 0
466
+ const warn = structureWarnings ? `, ${structureWarnings} warning(s)` : ''
467
+ console.log(
468
+ ok
469
+ ? `\n ✓ checks passed${warn}`
470
+ : `\n ✗ ${a11yViolations} a11y violation(s), ${visualChanges} visual change(s), ${tokenViolations} token violation(s), ${structureErrors} structure error(s), ${ssrErrors} ssr error(s)${warn}`,
471
+ )
472
+ return ok
473
+ }