@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,159 @@
1
+ import { afterEach, describe, expect, test } from 'bun:test'
2
+ import { rm } from 'node:fs/promises'
3
+ import { makeTempDir, writeFiles } from '../testing/test-helpers'
4
+ import { checkTokens } from './tokens-check'
5
+
6
+ // A minimal config whose only token source of truth is `tokens.css`. `extra`
7
+ // splices additional config fields (e.g. a tokens allowlist) into the literal.
8
+ const config = (extra = '') =>
9
+ `export default { title: 'Fixture', roots: ['**/*.case.tsx'], globalStyles: ['tokens.css']${extra} }\n`
10
+
11
+ describe('checkTokens', () => {
12
+ const dirs: string[] = []
13
+ const setup = async (files: Record<string, string>) => {
14
+ const dir = await makeTempDir()
15
+ dirs.push(dir)
16
+ await writeFiles(dir, files)
17
+ return dir
18
+ }
19
+
20
+ afterEach(async () => {
21
+ while (dirs.length)
22
+ await rm(dirs.pop() as string, { recursive: true, force: true })
23
+ })
24
+
25
+ test('passes when every reference resolves to a defined token', async () => {
26
+ const dir = await setup({
27
+ 'display-case.config.ts': config(),
28
+ 'tokens.css': ':root { --color-bg: #fff; }',
29
+ 'button.css': '.b { background: var(--color-bg); }',
30
+ })
31
+ const { violations, definedCount } = await checkTokens(dir)
32
+ expect(violations).toEqual([])
33
+ expect(definedCount).toBeGreaterThanOrEqual(1)
34
+ })
35
+
36
+ test('flags a reference to an undefined token', async () => {
37
+ const dir = await setup({
38
+ 'display-case.config.ts': config(),
39
+ 'tokens.css': ':root { --color-bg: #fff; }',
40
+ 'button.css': '.b { color: var(--mystery); }', // allow: unknown-token
41
+ })
42
+ const { violations } = await checkTokens(dir)
43
+ expect(violations).toHaveLength(1)
44
+ expect(violations[0].token).toBe('--mystery')
45
+ expect(violations[0].hadFallback).toBe(false)
46
+ expect(violations[0].file.endsWith('button.css')).toBe(true)
47
+ })
48
+
49
+ test('reports the 1-based line and column of the token', async () => {
50
+ const dir = await setup({
51
+ 'display-case.config.ts': config(),
52
+ 'tokens.css': ':root{--a:1;}',
53
+ 'x.css': 'a{color:1;}\nb{fill:var(--missing);}', // allow: unknown-token
54
+ })
55
+ const { violations } = await checkTokens(dir)
56
+ expect(violations).toHaveLength(1)
57
+ expect(violations[0].line).toBe(2)
58
+ // `b{fill:` is 7 chars, `var(` opens at col 8, `--missing` begins at col 12.
59
+ expect(violations[0].column).toBe(12)
60
+ })
61
+
62
+ test('a fallback value does not excuse an undefined token', async () => {
63
+ const dir = await setup({
64
+ 'display-case.config.ts': config(),
65
+ 'tokens.css': ':root{}',
66
+ 'x.css': '.a{color:var(--ghost, #6b7280);}', // allow: unknown-token
67
+ })
68
+ const { violations } = await checkTokens(dir)
69
+ expect(violations).toHaveLength(1)
70
+ expect(violations[0].token).toBe('--ghost')
71
+ expect(violations[0].hadFallback).toBe(true)
72
+ })
73
+
74
+ test('an allow-listed token name is treated as defined', async () => {
75
+ const dir = await setup({
76
+ 'display-case.config.ts': config(`, tokens: { allow: ['--host-bg'] }`),
77
+ 'tokens.css': ':root{}',
78
+ 'x.css': '.a{background:var(--host-bg);}', // allow: unknown-token
79
+ })
80
+ const { violations, definedCount } = await checkTokens(dir)
81
+ expect(violations).toEqual([])
82
+ expect(definedCount).toBeGreaterThanOrEqual(1)
83
+ })
84
+
85
+ test('an `allow: unknown-token` comment suppresses on the same or preceding line', async () => {
86
+ const dir = await setup({
87
+ 'display-case.config.ts': config(),
88
+ 'tokens.css': ':root{}',
89
+ 'same.css': '.a{color:var(--x);} /* allow: unknown-token */',
90
+ 'above.css': '/* allow: unknown-token */\n.a{color:var(--y);}',
91
+ })
92
+ const { violations } = await checkTokens(dir)
93
+ expect(violations).toEqual([])
94
+ })
95
+
96
+ test('a var() inside a CSS comment is not treated as a reference', async () => {
97
+ const dir = await setup({
98
+ 'display-case.config.ts': config(),
99
+ 'tokens.css': ':root{}',
100
+ 'x.css': '/* var(--commented) */\n.a{color:red;}', // allow: unknown-token
101
+ })
102
+ const { violations } = await checkTokens(dir)
103
+ expect(violations).toEqual([])
104
+ })
105
+
106
+ test('a var() inside a JS string literal is a real reference', async () => {
107
+ const dir = await setup({
108
+ 'display-case.config.ts': config(),
109
+ 'tokens.css': ':root{}',
110
+ 'x.tsx': `export const s = { color: 'var(--js-token)' }\n`, // allow: unknown-token
111
+ })
112
+ const { violations } = await checkTokens(dir)
113
+ expect(violations.map((v) => v.token)).toContain('--js-token')
114
+ })
115
+
116
+ test('an inline-style object key defines a token for the whole package', async () => {
117
+ const dir = await setup({
118
+ 'display-case.config.ts': config(),
119
+ 'tokens.css': ':root{}',
120
+ 'comp.tsx': `export const x = { style: { '--ring': 'red' } }\n`,
121
+ 'comp.css': '.a{outline-color:var(--ring);}',
122
+ })
123
+ const { violations } = await checkTokens(dir)
124
+ expect(violations).toEqual([])
125
+ })
126
+
127
+ test('resolves a reference whose definition lives in a different file', async () => {
128
+ const dir = await setup({
129
+ 'display-case.config.ts': config(),
130
+ 'tokens.css': ':root{ --shared: 2px; }',
131
+ 'a.css': '.a{ gap: var(--shared); }', // allow: unknown-token
132
+ })
133
+ const { violations } = await checkTokens(dir)
134
+ expect(violations).toEqual([])
135
+ })
136
+
137
+ test('sorts violations by file, then line, then column', async () => {
138
+ const dir = await setup({
139
+ 'display-case.config.ts': config(),
140
+ 'tokens.css': ':root{}',
141
+ 'b.css': '.a{color:var(--p);}', // allow: unknown-token
142
+ 'a.css': '.a{color:var(--q);}\n.b{color:var(--r);}', // allow: unknown-token
143
+ })
144
+ const { violations } = await checkTokens(dir)
145
+ expect(violations.map((v) => v.token)).toEqual(['--q', '--r', '--p'])
146
+ })
147
+
148
+ test('reports the number of scanned files', async () => {
149
+ const dir = await setup({
150
+ 'display-case.config.ts': config(),
151
+ 'tokens.css': ':root{--a:1;}',
152
+ 'x.css': '.a{color:var(--a);}', // allow: unknown-token
153
+ })
154
+ const { scannedFiles } = await checkTokens(dir)
155
+ // config.ts + tokens.css + x.css (tokens.css is reached via both the glob
156
+ // and globalStyles, but the path set dedups it).
157
+ expect(scannedFiles).toBe(3)
158
+ })
159
+ })
@@ -0,0 +1,162 @@
1
+ import { resolve } from 'node:path'
2
+ import { Glob } from 'bun'
3
+ import { resolveConfig } from '../core/discovery'
4
+ import { blankComments } from './check-text'
5
+
6
+ /**
7
+ * Static design-token conformance check for a Display-Case-ingested package.
8
+ *
9
+ * Display Case already knows the two halves of a package's token contract:
10
+ * - DEFINITIONS — the `globalStyles` CSS it injects into every preview
11
+ * (`--name: …` declarations), plus any custom properties a component sets at
12
+ * runtime via an inline `style={{ '--name': … }}` object.
13
+ * - REFERENCES — every `var(--name…)` in the package's own source (component
14
+ * CSS/TSX and the `.case.tsx` files).
15
+ *
16
+ * This pass flags any `var(--name)` whose name is neither defined in the package
17
+ * nor explicitly allow-listed. It catches the class of bug where a component
18
+ * borrows a foreign design system's token name (e.g. a shadcn-style
19
+ * `var(--muted-foreground, #6b7280)`) that never resolves and silently falls
20
+ * back to a hardcoded value — well-formed CSS, but a vocabulary violation that
21
+ * breaks theming and contrast.
22
+ *
23
+ * Intentionally opinionated: a `var(--x, fallback)` reference is still flagged
24
+ * even though the fallback makes it valid CSS. The whole point is to require
25
+ * every reference to resolve within *this* package's declared vocabulary.
26
+ *
27
+ * Static and browser-free — pure parse, so it is cheap enough to gate on commit.
28
+ */
29
+
30
+ export interface TokenViolation {
31
+ /** Absolute path to the file containing the reference. */
32
+ file: string
33
+ /** 1-based line number. */
34
+ line: number
35
+ /** 1-based column of the `--token`. */
36
+ column: number
37
+ /** The unresolved custom-property name, e.g. `--muted-foreground`. */
38
+ token: string
39
+ /** True when the reference carried a fallback (`var(--x, …)`). */
40
+ hadFallback: boolean
41
+ }
42
+
43
+ export interface TokenCheckResult {
44
+ pkgDir: string
45
+ scannedFiles: number
46
+ definedCount: number
47
+ violations: TokenViolation[]
48
+ }
49
+
50
+ // `var(` then a custom property, capturing a following comma (⇒ has fallback).
51
+ const REF_RE = /var\(\s*(--[A-Za-z0-9_-]+)\s*(,)?/g
52
+ // A CSS custom-property *declaration*: `--name:` at a value-position boundary.
53
+ // The boundary excludes `var(--name)` (preceded by `(`) and JS object keys
54
+ // (preceded by a quote), so only genuine definitions are harvested.
55
+ const CSS_DEF_RE = /(?:^|[\s;{,])(--[A-Za-z0-9_-]+)\s*:/g
56
+ // An inline-style definition in JS/TSX: a quoted object key, e.g.
57
+ // `style={{ '--ring': color }}`. These set the property at runtime.
58
+ const JS_DEF_RE = /['"`](--[A-Za-z0-9_-]+)['"`]\s*:/g
59
+ // Per-reference escape hatch (mirrors the repo's `allow: <reason>` convention).
60
+ const ESCAPE_RE = /allow:\s*unknown-token/
61
+
62
+ interface ScannedFile {
63
+ path: string
64
+ /** Comment-blanked text (offsets + line breaks preserved). */
65
+ clean: string
66
+ /** Original, unmodified lines — used only for the escape-comment lookup. */
67
+ rawLines: string[]
68
+ isCss: boolean
69
+ }
70
+
71
+ function collectDefs(file: ScannedFile, into: Set<string>): void {
72
+ const re = file.isCss ? CSS_DEF_RE : JS_DEF_RE
73
+ re.lastIndex = 0
74
+ for (let m = re.exec(file.clean); m; m = re.exec(file.clean)) {
75
+ into.add(m[1])
76
+ }
77
+ }
78
+
79
+ function collectRefs(
80
+ file: ScannedFile,
81
+ defined: Set<string>,
82
+ out: TokenViolation[],
83
+ ): void {
84
+ const cleanLines = file.clean.split('\n')
85
+ cleanLines.forEach((line, idx) => {
86
+ REF_RE.lastIndex = 0
87
+ for (let m = REF_RE.exec(line); m; m = REF_RE.exec(line)) {
88
+ const token = m[1]
89
+ if (defined.has(token)) continue
90
+ // Escape may sit on the reference line or the line directly above it.
91
+ const raw = file.rawLines[idx] ?? ''
92
+ const above = idx > 0 ? (file.rawLines[idx - 1] ?? '') : ''
93
+ if (ESCAPE_RE.test(raw) || ESCAPE_RE.test(above)) continue
94
+ out.push({
95
+ file: file.path,
96
+ line: idx + 1,
97
+ column: m.index + m[0].indexOf(token) + 1,
98
+ token,
99
+ hadFallback: m[2] === ',',
100
+ })
101
+ }
102
+ })
103
+ }
104
+
105
+ /**
106
+ * Run the token-reference conformance pass over one ingested package.
107
+ * Resolves the package's Display Case config to learn `globalStyles` (token
108
+ * definitions) and scans the whole package source tree for definitions +
109
+ * references.
110
+ */
111
+ export async function checkTokens(pkgDir: string): Promise<TokenCheckResult> {
112
+ const { config } = await resolveConfig(pkgDir)
113
+
114
+ // Gather the files that hold definitions and/or references: the configured
115
+ // global stylesheets (token source of truth) plus all package source.
116
+ const paths = new Set<string>()
117
+ for (const rel of config.globalStyles ?? []) paths.add(resolve(pkgDir, rel))
118
+ const glob = new Glob('**/*.{css,ts,tsx}')
119
+ for await (const match of glob.scan({ cwd: pkgDir, absolute: true })) {
120
+ if (
121
+ match.includes('/node_modules/') ||
122
+ match.includes('/.display-case/') ||
123
+ match.includes('/dist/')
124
+ ) {
125
+ continue
126
+ }
127
+ paths.add(match)
128
+ }
129
+
130
+ const files: ScannedFile[] = []
131
+ for (const path of paths) {
132
+ if (!(await Bun.file(path).exists())) continue
133
+ const text = await Bun.file(path).text()
134
+ const isCss = path.endsWith('.css')
135
+ files.push({
136
+ path,
137
+ clean: blankComments(text, isCss),
138
+ rawLines: text.split('\n'),
139
+ isCss,
140
+ })
141
+ }
142
+
143
+ // Pass 1 — every defined token across the whole package (definitions may live
144
+ // in a different file than the reference), seeded with the config allowlist.
145
+ const defined = new Set<string>(config.tokens?.allow ?? [])
146
+ for (const file of files) collectDefs(file, defined)
147
+
148
+ // Pass 2 — flag references to names the package never defines.
149
+ const violations: TokenViolation[] = []
150
+ for (const file of files) collectRefs(file, defined, violations)
151
+ violations.sort(
152
+ (a, b) =>
153
+ a.file.localeCompare(b.file) || a.line - b.line || a.column - b.column,
154
+ )
155
+
156
+ return {
157
+ pkgDir,
158
+ scannedFiles: files.length,
159
+ definedCount: defined.size,
160
+ violations,
161
+ }
162
+ }
package/src/cli.ts ADDED
@@ -0,0 +1,218 @@
1
+ #!/usr/bin/env bun
2
+ import { existsSync } from 'node:fs'
3
+ import { join, resolve } from 'node:path'
4
+ import { AGENT_TARGETS, DEFAULT_AGENT } from './commands/agents'
5
+ import { getManifest, startDisplayCase } from './server/server'
6
+
7
+ // Display Case is Bun-native at *runtime*, not just at install time: discovery,
8
+ // bundling, and serving all use Bun's built-in bundler and `Bun.serve`. Running
9
+ // the CLI under Node fails deep inside with an opaque `Bun is not defined`, so
10
+ // detect it up front and point the user at Bun.
11
+ if (typeof globalThis.Bun === 'undefined') {
12
+ console.error(
13
+ 'display-case requires the Bun runtime (https://bun.sh).\n' +
14
+ 'Run it with Bun — e.g. `bunx @awarebydefault/display-case .` or `bun run display-case` — not Node.',
15
+ )
16
+ process.exit(1)
17
+ }
18
+
19
+ /**
20
+ * Display Case CLI.
21
+ *
22
+ * display-case <pkgDir> [--port=N] start the dev server
23
+ * display-case <pkgDir> --print-manifest print the manifest JSON and exit
24
+ * display-case check <pkgDir> [--a11y] [--visual] [--tokens] [--structure] [--ssr] [--update] [--strict] [--only=ids] [--changed[=ref]] [--port=N]
25
+ * display-case init <pkgDir> [--agent=claude] [--with-visual] [--dry-run] [--json]
26
+ * display-case uninstall <pkgDir> [--agent=claude] [--dry-run] [--json]
27
+ *
28
+ * With no phase flag, `check` runs all phases. Naming any phase flag runs only
29
+ * the named phase(s) — e.g. `--tokens` runs the (browser-free) token check alone.
30
+ * `init`/`uninstall` scaffold (or remove) AI-agent integration in the repo.
31
+ */
32
+
33
+ const argv = process.argv.slice(2)
34
+
35
+ function flag(name: string): boolean {
36
+ return argv.includes(`--${name}`)
37
+ }
38
+ function option(name: string): string | undefined {
39
+ return argv.find((a) => a.startsWith(`--${name}=`))?.split('=')[1]
40
+ }
41
+ function positionals(): string[] {
42
+ return argv.filter((a) => !a.startsWith('--'))
43
+ }
44
+
45
+ const CONFIG_FILE = 'display-case.config.ts'
46
+
47
+ function fail(message: string): never {
48
+ console.error(message)
49
+ process.exit(1)
50
+ }
51
+
52
+ /** Nearest ancestor of `start` (inclusive) containing a config, or null. */
53
+ function discoverConfigDir(start: string): string | null {
54
+ let dir = resolve(start)
55
+ for (let i = 0; i < 24; i++) {
56
+ if (existsSync(join(dir, CONFIG_FILE))) return dir
57
+ const parent = resolve(dir, '..')
58
+ if (parent === dir) return null
59
+ dir = parent
60
+ }
61
+ return null
62
+ }
63
+
64
+ /**
65
+ * Resolve the package directory to operate on.
66
+ *
67
+ * - An explicit target (`display-case apps/foo`) is used as given and
68
+ * must contain a `display-case.config.ts` — a wrong directory fails loudly
69
+ * rather than serving an empty showcase.
70
+ * - The default (no argument, or `.`) discovers the nearest config by walking up
71
+ * from the current directory, so it works from a package root *or* any
72
+ * subdirectory. The bare form is identical to `.` — there is no separate
73
+ * "no-argument" mode to reason about.
74
+ *
75
+ * Resolution, the `.display-case/` build cache, and repo-relative paths all
76
+ * anchor to the resolved package, so running from a git worktree keeps
77
+ * everything inside that worktree and two checkouts never share a cache. The
78
+ * one rule: run it from inside the package (any depth), or pass the path
79
+ * explicitly — don't rely on a process cwd that points elsewhere.
80
+ */
81
+ function resolvePkgDir(arg: string | undefined): string {
82
+ if (arg && arg !== '.') {
83
+ const dir = resolve(arg)
84
+ if (!existsSync(join(dir, CONFIG_FILE))) {
85
+ fail(`No ${CONFIG_FILE} in ${dir} — is that a Display Case package?`)
86
+ }
87
+ return dir
88
+ }
89
+
90
+ const here = discoverConfigDir(process.cwd())
91
+ if (here) return here
92
+ fail(
93
+ `No ${CONFIG_FILE} found in ${process.cwd()} or any parent directory.\n` +
94
+ `Run from inside a Display Case package, or pass <pkgDir> explicitly.`,
95
+ )
96
+ }
97
+
98
+ // An explicit `--port` wins; otherwise honor `DISPLAY_CASE_PORT` (the dev
99
+ // orchestrator sets this per-worktree so two checkouts don't collide), falling
100
+ // back to the server's own default. `startDisplayCase` treats the chosen port as
101
+ // preferred and bumps off a busy one, so this never hard-fails on a clash.
102
+ const portArg = option('port') ?? process.env.DISPLAY_CASE_PORT
103
+ const port = portArg ? Number(portArg) : undefined
104
+
105
+ if (argv[0] === 'init' || argv[0] === 'uninstall') {
106
+ const pkgDir = resolve(positionals()[1] ?? '.')
107
+ const agent = option('agent') ?? DEFAULT_AGENT
108
+ if (!AGENT_TARGETS[agent]) {
109
+ console.error(
110
+ `Unsupported agent "${agent}". Supported: ${Object.keys(AGENT_TARGETS).join(', ')}.`,
111
+ )
112
+ process.exit(1)
113
+ }
114
+ const { runInit, runUninstall, report } = await import('./commands/init')
115
+ // init may set up the visual toolchain: explicit --with-visual, or an
116
+ // interactive prompt when attached to a TTY (never in --json/--dry-run).
117
+ let withVisual = flag('with-visual')
118
+ if (
119
+ argv[0] === 'init' &&
120
+ !withVisual &&
121
+ process.stdin.isTTY &&
122
+ !flag('json') &&
123
+ !flag('dry-run')
124
+ ) {
125
+ const answer = prompt(
126
+ 'Set up visual-regression checking (Playwright + pixelmatch + pngjs)? [y/N]',
127
+ )
128
+ withVisual = /^y(es)?$/i.test(answer?.trim() ?? '')
129
+ }
130
+ const opts = {
131
+ agent,
132
+ dryRun: flag('dry-run'),
133
+ json: flag('json'),
134
+ withVisual,
135
+ }
136
+ const result = await (argv[0] === 'init' ? runInit : runUninstall)(
137
+ pkgDir,
138
+ opts,
139
+ )
140
+ report(result)
141
+ } else if (argv[0] === 'check') {
142
+ const pkgDir = resolvePkgDir(positionals()[1])
143
+ const { runChecks } = await import('./checks/check')
144
+ const { resolveConfig } = await import('./core/discovery')
145
+ const { config } = await resolveConfig(pkgDir)
146
+ // A named phase flag ⇒ run only the named phase(s). With no phase flag, run
147
+ // every phase except those a config opts out of via `check.defaultPhases`.
148
+ const explicit = {
149
+ tokens: flag('tokens'),
150
+ a11y: flag('a11y'),
151
+ visual: flag('visual'),
152
+ structure: flag('structure'),
153
+ ssr: flag('ssr'),
154
+ }
155
+ const anyExplicit =
156
+ explicit.tokens ||
157
+ explicit.a11y ||
158
+ explicit.visual ||
159
+ explicit.structure ||
160
+ explicit.ssr
161
+ const defaults = config.check?.defaultPhases ?? {}
162
+ const runs = (phase: keyof typeof explicit): boolean =>
163
+ explicit[phase] || (!anyExplicit && defaults[phase] !== false)
164
+ // Change-scoping for the render phases (a11y/visual): `--only=<ids/globs>`
165
+ // restricts to named components; `--changed[=<ref>]` restricts to components
166
+ // whose import closure touches a file changed since <ref> (default the base
167
+ // branch, overridable via DISPLAY_CASE_BASE_REF). Both are no-ops for the
168
+ // static phases. See src/core/affected.ts.
169
+ const onlyValue = option('only')
170
+ const changedActive = flag('changed') || option('changed') !== undefined
171
+ const ok = await runChecks(pkgDir, {
172
+ a11y: runs('a11y'),
173
+ visual: runs('visual'),
174
+ tokens: runs('tokens'),
175
+ structure: runs('structure'),
176
+ ssr: runs('ssr'),
177
+ update: flag('update'),
178
+ strict: flag('strict'),
179
+ only: onlyValue ? onlyValue.split(',').filter(Boolean) : undefined,
180
+ changedRef: changedActive
181
+ ? (option('changed') ??
182
+ process.env.DISPLAY_CASE_BASE_REF ??
183
+ 'origin/main')
184
+ : undefined,
185
+ port,
186
+ })
187
+ process.exit(ok ? 0 : 1)
188
+ } else if (argv[0] === 'publish') {
189
+ const pkgDir = resolvePkgDir(positionals()[1])
190
+ const { publish } = await import('./commands/publish')
191
+ const isStatic = flag('static')
192
+ console.log('Building deployable showcase…')
193
+ const { out } = await publish(pkgDir, {
194
+ out: option('out'),
195
+ base: option('base'),
196
+ static: isStatic,
197
+ })
198
+ console.log(`\n Published → ${out}`)
199
+ console.log(
200
+ isStatic
201
+ ? ' Static export written — serve the directory with any static host.'
202
+ : ` Run it: (cd ${out} && bun install && bun server.ts) — or build the Dockerfile.`,
203
+ )
204
+ process.exit(0)
205
+ } else {
206
+ const pkgDir = resolvePkgDir(positionals()[0])
207
+
208
+ if (flag('print-manifest')) {
209
+ const manifest = await getManifest(pkgDir)
210
+ console.log(JSON.stringify(manifest, null, 2))
211
+ process.exit(0)
212
+ }
213
+
214
+ // `--dev` enables live reload of the Display Case app itself (chrome,
215
+ // components, primer) — see StartOptions.dev.
216
+ const server = await startDisplayCase(pkgDir, { port, dev: flag('dev') })
217
+ console.log(`\n Display Case → ${server.url}\n`)
218
+ }
@@ -0,0 +1,24 @@
1
+ import { describe, expect, test } from 'bun:test'
2
+ import { AGENT_TARGETS, DEFAULT_AGENT } from './agents'
3
+
4
+ describe('agent targets', () => {
5
+ test('DEFAULT_AGENT points at a registered target', () => {
6
+ expect(AGENT_TARGETS[DEFAULT_AGENT]).toBeDefined()
7
+ expect(AGENT_TARGETS[DEFAULT_AGENT].id).toBe(DEFAULT_AGENT)
8
+ })
9
+
10
+ test('the claude target declares launch, skills, and instruction locations', () => {
11
+ const t = AGENT_TARGETS.claude
12
+ expect(t.id).toBe('claude')
13
+ expect(t.launchConfigPath).toBe('.claude/launch.json')
14
+ expect(t.skillsDir).toBe('.claude/skills')
15
+ expect(t.instructionsFiles).toEqual(['AGENTS.md', 'CLAUDE.md'])
16
+ })
17
+
18
+ test('every target’s id matches its registry key', () => {
19
+ for (const [key, target] of Object.entries(AGENT_TARGETS)) {
20
+ expect(target.id).toBe(key)
21
+ expect(target.instructionsFiles.length).toBeGreaterThan(0)
22
+ }
23
+ })
24
+ })
@@ -0,0 +1,28 @@
1
+ /**
2
+ * Per-agent conventions for where launch config, skills, and instructions live.
3
+ * Adding a new agent is data, not control flow — `init`/`uninstall` are
4
+ * agent-agnostic and read everything they need from the selected target.
5
+ */
6
+ export interface AgentTarget {
7
+ id: string
8
+ /** Launch/run configuration file, relative to the repo root. */
9
+ launchConfigPath: string
10
+ /** Directory the agent loads skills from, relative to the repo root. */
11
+ skillsDir: string
12
+ /**
13
+ * Candidate instruction files in priority order; the first that exists is
14
+ * used, else the first is created.
15
+ */
16
+ instructionsFiles: string[]
17
+ }
18
+
19
+ export const AGENT_TARGETS: Record<string, AgentTarget> = {
20
+ claude: {
21
+ id: 'claude',
22
+ launchConfigPath: '.claude/launch.json',
23
+ skillsDir: '.claude/skills',
24
+ instructionsFiles: ['AGENTS.md', 'CLAUDE.md'],
25
+ },
26
+ }
27
+
28
+ export const DEFAULT_AGENT = 'claude'