@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,123 @@
1
+ import { afterEach, describe, expect, test } from 'bun:test'
2
+ import { existsSync } from 'node:fs'
3
+ import { rm } from 'node:fs/promises'
4
+ import { join } from 'node:path'
5
+ import { makeTempDir, writeFiles } from '../testing/test-helpers'
6
+ import { runInit, runUninstall } from './init'
7
+
8
+ /**
9
+ * End-to-end exercise of the scaffolder against a throwaway repo. A `.git`
10
+ * marker makes the temp dir its own repo root so `findRepoRoot` stops there and
11
+ * every artifact lands inside the sandbox.
12
+ */
13
+
14
+ const dirs: string[] = []
15
+ const opts = { agent: 'claude', json: false }
16
+
17
+ const makeRepo = async (extra: Record<string, string> = {}) => {
18
+ const dir = await makeTempDir()
19
+ dirs.push(dir)
20
+ await writeFiles(dir, { '.git/HEAD': 'ref: refs/heads/main\n', ...extra })
21
+ return dir
22
+ }
23
+
24
+ afterEach(async () => {
25
+ while (dirs.length)
26
+ await rm(dirs.pop() as string, { recursive: true, force: true })
27
+ })
28
+
29
+ describe('runInit / runUninstall', () => {
30
+ test('a dry run plans changes without writing anything', async () => {
31
+ const dir = await makeRepo()
32
+ const res = await runInit(dir, { ...opts, dryRun: true })
33
+ expect(res.command).toBe('init')
34
+ expect(existsSync(join(dir, '.claude/launch.json'))).toBe(false)
35
+ expect(existsSync(join(dir, 'AGENTS.md'))).toBe(false)
36
+ const launch = res.items.find((i) => i.artifact.endsWith('launch.json'))
37
+ expect(launch?.action).toBe('created')
38
+ })
39
+
40
+ test('init writes the launch config and the instructions pointer', async () => {
41
+ const dir = await makeRepo()
42
+ await runInit(dir, { ...opts, dryRun: false })
43
+ expect(existsSync(join(dir, '.claude/launch.json'))).toBe(true)
44
+ const agents = await Bun.file(join(dir, 'AGENTS.md')).text()
45
+ expect(agents).toContain('## Display Case (for agents)')
46
+ expect(agents).toContain('display-case:agent-guide:start')
47
+ })
48
+
49
+ test('re-running init is idempotent (the second run skips everything)', async () => {
50
+ const dir = await makeRepo()
51
+ await runInit(dir, { ...opts, dryRun: false })
52
+ const again = await runInit(dir, { ...opts, dryRun: false })
53
+ expect(again.items.every((i) => i.action === 'skipped')).toBe(true)
54
+ })
55
+
56
+ test('init merges into an existing launch.json without clobbering entries', async () => {
57
+ const dir = await makeRepo({
58
+ '.claude/launch.json': `${JSON.stringify(
59
+ { version: '0.0.1', configurations: [{ name: 'other', port: 9 }] },
60
+ null,
61
+ 2,
62
+ )}\n`,
63
+ })
64
+ await runInit(dir, { ...opts, dryRun: false })
65
+ const launch = JSON.parse(
66
+ await Bun.file(join(dir, '.claude/launch.json')).text(),
67
+ )
68
+ const names = (launch.configurations as { name: string }[]).map(
69
+ (c) => c.name,
70
+ )
71
+ expect(names).toContain('other')
72
+ expect(names).toContain('display-case')
73
+ })
74
+
75
+ test('init appends to an existing AGENTS.md, preserving prior content', async () => {
76
+ const dir = await makeRepo({ 'AGENTS.md': '# Project\n' })
77
+ await runInit(dir, { ...opts, dryRun: false })
78
+ const agents = await Bun.file(join(dir, 'AGENTS.md')).text()
79
+ expect(agents).toContain('# Project')
80
+ expect(agents).toContain('## Display Case (for agents)')
81
+ })
82
+
83
+ test('uninstall removes exactly what init wrote and is idempotent', async () => {
84
+ const dir = await makeRepo()
85
+ await runInit(dir, { ...opts, dryRun: false })
86
+
87
+ const out = await runUninstall(dir, { ...opts, dryRun: false })
88
+ expect(out.command).toBe('uninstall')
89
+
90
+ const launch = JSON.parse(
91
+ await Bun.file(join(dir, '.claude/launch.json')).text(),
92
+ )
93
+ const entry = (launch.configurations as { name: string }[]).find(
94
+ (c) => c.name === 'display-case',
95
+ )
96
+ expect(entry).toBeUndefined()
97
+
98
+ const agents = await Bun.file(join(dir, 'AGENTS.md')).text()
99
+ expect(agents).not.toContain('display-case:agent-guide:start')
100
+
101
+ const again = await runUninstall(dir, { ...opts, dryRun: false })
102
+ expect(again.items.every((i) => i.action === 'skipped')).toBe(true)
103
+ })
104
+
105
+ test('uninstall preserves an unrelated launch entry', async () => {
106
+ const dir = await makeRepo({
107
+ '.claude/launch.json': `${JSON.stringify(
108
+ { version: '0.0.1', configurations: [{ name: 'other', port: 9 }] },
109
+ null,
110
+ 2,
111
+ )}\n`,
112
+ })
113
+ await runInit(dir, { ...opts, dryRun: false })
114
+ await runUninstall(dir, { ...opts, dryRun: false })
115
+ const launch = JSON.parse(
116
+ await Bun.file(join(dir, '.claude/launch.json')).text(),
117
+ )
118
+ const names = (launch.configurations as { name: string }[]).map(
119
+ (c) => c.name,
120
+ )
121
+ expect(names).toEqual(['other'])
122
+ })
123
+ })
@@ -0,0 +1,63 @@
1
+ import { describe, expect, test } from 'bun:test'
2
+ import { reconcilePointer } from './init'
3
+
4
+ // The sentinel contract reconcilePointer keys off of. Hardcoded here on purpose:
5
+ // the test pins the exact marker format that init writes and uninstall strips.
6
+ const START = '<!-- display-case:agent-guide:start -->'
7
+ const END = '<!-- display-case:agent-guide:end -->'
8
+ const block = (body: string) => `${START}\n## Display Case\n\n${body}\n${END}`
9
+
10
+ describe('reconcilePointer', () => {
11
+ test('creates the block in an empty file', () => {
12
+ const b = block('v1')
13
+ const r = reconcilePointer('', b)
14
+ expect(r.action).toBe('created')
15
+ expect(r.next).toBe(`${b}\n`)
16
+ })
17
+
18
+ test('appends to existing content with a blank-line separator', () => {
19
+ const b = block('v1')
20
+ const r = reconcilePointer('# Project', b)
21
+ expect(r.action).toBe('created')
22
+ expect(r.next).toBe(`# Project\n\n${b}\n`)
23
+ })
24
+
25
+ test('normalizes to one blank line when content already ends in a newline', () => {
26
+ const b = block('v1')
27
+ const r = reconcilePointer('# Project\n', b)
28
+ expect(r.action).toBe('created')
29
+ expect(r.next).toBe(`# Project\n\n${b}\n`)
30
+ })
31
+
32
+ test('skips when the present block is byte-identical', () => {
33
+ const b = block('v1')
34
+ const current = `intro\n\n${b}\n`
35
+ const r = reconcilePointer(current, b)
36
+ expect(r.action).toBe('skipped')
37
+ expect(r.next).toBe(current) // unchanged → runInit writes nothing
38
+ })
39
+
40
+ test('refreshes a drifted block in place', () => {
41
+ const current = `intro\n\n${block('OLD wording')}\n`
42
+ const next = block('NEW wording')
43
+ const r = reconcilePointer(current, next)
44
+ expect(r.action).toBe('updated')
45
+ expect(r.next).toBe(`intro\n\n${next}\n`)
46
+ expect(r.next).not.toContain('OLD wording')
47
+ })
48
+
49
+ test('replaces only the block, preserving text before and after', () => {
50
+ const current = `before\n${block('OLD')}\nafter\n`
51
+ const next = block('NEW')
52
+ const r = reconcilePointer(current, next)
53
+ expect(r.action).toBe('updated')
54
+ expect(r.next).toBe(`before\n${next}\nafter\n`)
55
+ })
56
+
57
+ test('does not let a `$` in the block corrupt the replacement', () => {
58
+ const current = block('OLD')
59
+ const next = block('cost is $1 — see $& and $1 literally')
60
+ const r = reconcilePointer(current, next)
61
+ expect(r.next).toBe(next)
62
+ })
63
+ })
@@ -0,0 +1,412 @@
1
+ import { existsSync } from 'node:fs'
2
+ import { mkdir, readdir, rm } from 'node:fs/promises'
3
+ import { dirname, join, relative, resolve } from 'node:path'
4
+ import type { AgentTarget } from './agents'
5
+ import { AGENT_TARGETS } from './agents'
6
+
7
+ /**
8
+ * Scaffolds (and removes) Display Case's AI-agent integration in a target repo.
9
+ * Every write is idempotent and merge-aware; every removal is owns-only —
10
+ * artifacts are identified the same way they are written (a fixed launch
11
+ * `name`, the bundled skill ids, and sentinel-delimited instruction markers),
12
+ * so the two commands stay in lock-step without an install manifest.
13
+ */
14
+
15
+ const PKG_ROOT = resolve(import.meta.dir, '..', '..')
16
+ const SKILLS_SRC = join(PKG_ROOT, 'skills')
17
+ const PROMPT_FILE = join(PKG_ROOT, 'display-case.prompt.md')
18
+
19
+ const LAUNCH_NAME = 'display-case'
20
+ const SENTINEL_START = '<!-- display-case:agent-guide:start -->'
21
+ const SENTINEL_END = '<!-- display-case:agent-guide:end -->'
22
+
23
+ export type Action = 'created' | 'updated' | 'skipped' | 'removed'
24
+
25
+ export interface PlanItem {
26
+ artifact: string
27
+ action: Action
28
+ detail?: string
29
+ }
30
+
31
+ export interface RunOptions {
32
+ agent: string
33
+ dryRun: boolean
34
+ json: boolean
35
+ /** init only: also set up the default visual-regression toolchain. */
36
+ withVisual?: boolean
37
+ }
38
+
39
+ const VISUAL_PACKAGES = [
40
+ 'playwright',
41
+ '@axe-core/playwright',
42
+ 'pixelmatch',
43
+ 'pngjs',
44
+ ]
45
+
46
+ async function setupVisualToolchain(repoRoot: string): Promise<void> {
47
+ const run = async (cmd: string[]) => {
48
+ const proc = Bun.spawn(cmd, {
49
+ cwd: repoRoot,
50
+ stdout: 'inherit',
51
+ stderr: 'inherit',
52
+ })
53
+ const code = await proc.exited
54
+ if (code !== 0)
55
+ throw new Error(`\`${cmd.join(' ')}\` failed (exit ${code})`)
56
+ }
57
+ await run(['bun', 'add', '--dev', ...VISUAL_PACKAGES])
58
+ await run(['bunx', 'playwright', 'install', 'chromium'])
59
+ }
60
+
61
+ export interface RunResult {
62
+ command: 'init' | 'uninstall'
63
+ agent: string
64
+ dryRun: boolean
65
+ json: boolean
66
+ items: PlanItem[]
67
+ }
68
+
69
+ function findRepoRoot(start: string): string {
70
+ let dir = start
71
+ for (let i = 0; i < 12; i++) {
72
+ if (existsSync(join(dir, '.git'))) return dir
73
+ const parent = resolve(dir, '..')
74
+ if (parent === dir) break
75
+ dir = parent
76
+ }
77
+ return start
78
+ }
79
+
80
+ async function bundledSkillIds(): Promise<string[]> {
81
+ if (!existsSync(SKILLS_SRC)) return []
82
+ const entries = await readdir(SKILLS_SRC, { withFileTypes: true })
83
+ return entries
84
+ .filter((e) => e.isDirectory())
85
+ .map((e) => e.name)
86
+ .sort()
87
+ }
88
+
89
+ async function readJson(path: string): Promise<Record<string, unknown> | null> {
90
+ if (!(await Bun.file(path).exists())) return null
91
+ return JSON.parse(await Bun.file(path).text()) as Record<string, unknown>
92
+ }
93
+
94
+ function resolveInstructionsFile(
95
+ repoRoot: string,
96
+ target: AgentTarget,
97
+ ): string {
98
+ const found = target.instructionsFiles.find((f) =>
99
+ existsSync(join(repoRoot, f)),
100
+ )
101
+ return join(repoRoot, found ?? target.instructionsFiles[0])
102
+ }
103
+
104
+ function launchEntry(repoRoot: string, pkgDir: string) {
105
+ const cliRel = relative(repoRoot, join(PKG_ROOT, 'src', 'cli.ts'))
106
+ const showcaseRel = relative(repoRoot, pkgDir)
107
+ return {
108
+ name: LAUNCH_NAME,
109
+ runtimeExecutable: 'bun',
110
+ // `--dev` enables watching Display Case's own chrome (not just the showcased
111
+ // package), so editing the shell hot-reloads even when `showcaseRel` points
112
+ // elsewhere. It's a no-op when showcasing Display Case itself.
113
+ runtimeArgs: [cliRel, showcaseRel, '--port=3100', '--dev'],
114
+ port: 3100,
115
+ }
116
+ }
117
+
118
+ function guideBlock(repoRoot: string): string {
119
+ const promptRel = relative(repoRoot, PROMPT_FILE)
120
+ const docsRel = relative(repoRoot, join(PKG_ROOT, 'docs', 'ai-agents.md'))
121
+ return [
122
+ SENTINEL_START,
123
+ '## Display Case (for agents)',
124
+ '',
125
+ 'Browse the component showcase with `bun run display-case`; `--print-manifest` lists every component/case as JSON.',
126
+ 'Snapshot one case in isolation at `/render/<component>/<case>?theme=light|dark` (chrome-free HTML).',
127
+ `See [${promptRel}](${promptRel}) for authoring and [${docsRel}](${docsRel}) for the agent workflow.`,
128
+ SENTINEL_END,
129
+ ].join('\n')
130
+ }
131
+
132
+ /**
133
+ * Reconcile the sentinel-delimited agent-guide block in an instructions file
134
+ * against the freshly-rendered `block`. Pure — returns the action to report and
135
+ * the next file contents, so `runInit` only has to write when `next` changed.
136
+ *
137
+ * - No block present → append it (`created`).
138
+ * - Block present and byte-identical → `skipped`.
139
+ * - Block present but drifted → replace just that block in place (`updated`),
140
+ * so a re-init always converges the pointer to the bundled content.
141
+ */
142
+ export function reconcilePointer(
143
+ current: string,
144
+ block: string,
145
+ ): { action: Action; detail: string; next: string } {
146
+ if (current.includes(SENTINEL_START) && current.includes(SENTINEL_END)) {
147
+ const blockRe = new RegExp(`${SENTINEL_START}[\\s\\S]*?${SENTINEL_END}`)
148
+ if (current.match(blockRe)?.[0] === block) {
149
+ return {
150
+ action: 'skipped',
151
+ detail: 'pointer already up to date',
152
+ next: current,
153
+ }
154
+ }
155
+ // Replace via a function so a `$` in the block is never read as a
156
+ // replacement pattern; the regex is non-global → only the first block.
157
+ return {
158
+ action: 'updated',
159
+ detail: 'refreshed agent-guide pointer',
160
+ next: current.replace(blockRe, () => block),
161
+ }
162
+ }
163
+ let sep = ''
164
+ if (current.length) sep = current.endsWith('\n') ? '\n' : '\n\n'
165
+ return {
166
+ action: 'created',
167
+ detail: 'added agent-guide pointer',
168
+ next: `${current}${sep}${block}\n`,
169
+ }
170
+ }
171
+
172
+ /** Are two directories' files byte-identical (used to decide skip vs update)? */
173
+ async function dirsEqual(a: string, b: string): Promise<boolean> {
174
+ if (!existsSync(b)) return false
175
+ const list = async (d: string) =>
176
+ (await readdir(d, { withFileTypes: true }))
177
+ .filter((e) => e.isFile())
178
+ .map((e) => e.name)
179
+ .sort()
180
+ const [fa, fb] = await Promise.all([list(a), list(b)])
181
+ if (fa.join('|') !== fb.join('|')) return false
182
+ for (const name of fa) {
183
+ const [ta, tb] = await Promise.all([
184
+ Bun.file(join(a, name)).text(),
185
+ Bun.file(join(b, name)).text(),
186
+ ])
187
+ if (ta !== tb) return false
188
+ }
189
+ return true
190
+ }
191
+
192
+ async function copyDir(src: string, dest: string): Promise<void> {
193
+ await mkdir(dest, { recursive: true })
194
+ for (const e of await readdir(src, { withFileTypes: true })) {
195
+ if (e.isFile())
196
+ await Bun.write(join(dest, e.name), Bun.file(join(src, e.name)))
197
+ }
198
+ }
199
+
200
+ export async function runInit(
201
+ pkgDir: string,
202
+ opts: RunOptions,
203
+ ): Promise<RunResult> {
204
+ const target = AGENT_TARGETS[opts.agent] as AgentTarget
205
+ const repoRoot = findRepoRoot(pkgDir)
206
+ const items: PlanItem[] = []
207
+
208
+ // 1. Launch config — merge, never clobber other entries.
209
+ const launchPath = join(repoRoot, target.launchConfigPath)
210
+ const existing = await readJson(launchPath)
211
+ const config = existing ?? { version: '0.0.1', configurations: [] }
212
+ const configs =
213
+ (config.configurations as Record<string, unknown>[] | undefined) ?? []
214
+ const entry = launchEntry(repoRoot, pkgDir)
215
+ const idx = configs.findIndex((c) => c.name === LAUNCH_NAME)
216
+ if (idx === -1) {
217
+ configs.push(entry)
218
+ items.push({
219
+ artifact: target.launchConfigPath,
220
+ action: 'created',
221
+ detail: `added "${LAUNCH_NAME}" entry`,
222
+ })
223
+ } else if (JSON.stringify(configs[idx]) !== JSON.stringify(entry)) {
224
+ configs[idx] = entry
225
+ items.push({
226
+ artifact: target.launchConfigPath,
227
+ action: 'updated',
228
+ detail: `refreshed "${LAUNCH_NAME}" entry`,
229
+ })
230
+ } else {
231
+ items.push({
232
+ artifact: target.launchConfigPath,
233
+ action: 'skipped',
234
+ detail: 'entry already present',
235
+ })
236
+ }
237
+ config.configurations = configs
238
+ if (!opts.dryRun && items[items.length - 1].action !== 'skipped') {
239
+ await mkdir(dirname(launchPath), { recursive: true })
240
+ await Bun.write(launchPath, `${JSON.stringify(config, null, 2)}\n`)
241
+ }
242
+
243
+ // 2. Skills — copy each bundled skill, skip identical, update changed.
244
+ for (const id of await bundledSkillIds()) {
245
+ const src = join(SKILLS_SRC, id)
246
+ const dest = join(repoRoot, target.skillsDir, id)
247
+ const rel = join(target.skillsDir, id)
248
+ if (await dirsEqual(src, dest)) {
249
+ items.push({ artifact: rel, action: 'skipped', detail: 'identical' })
250
+ continue
251
+ }
252
+ const action: Action = existsSync(dest) ? 'updated' : 'created'
253
+ items.push({ artifact: rel, action })
254
+ if (!opts.dryRun) await copyDir(src, dest)
255
+ }
256
+
257
+ // 3. Instructions pointer — sentinel-marked, idempotent, and self-refreshing.
258
+ // reconcilePointer converges an existing block to the bundled content (like
259
+ // the skills above): byte-identical → skipped; drifted → replaced in place.
260
+ const instrPath = resolveInstructionsFile(repoRoot, target)
261
+ const instrRel = relative(repoRoot, instrPath)
262
+ const current = (await Bun.file(instrPath).exists())
263
+ ? await Bun.file(instrPath).text()
264
+ : ''
265
+ const { action, detail, next } = reconcilePointer(
266
+ current,
267
+ guideBlock(repoRoot),
268
+ )
269
+ items.push({ artifact: instrRel, action, detail })
270
+ if (!opts.dryRun && next !== current) await Bun.write(instrPath, next)
271
+
272
+ // 4. Optional visual-regression toolchain (opt-in).
273
+ if (opts.withVisual) {
274
+ items.push({
275
+ artifact: 'visual-regression toolchain',
276
+ action: 'created',
277
+ detail: 'Playwright Chromium + pixelmatch/pngjs',
278
+ })
279
+ if (!opts.dryRun) await setupVisualToolchain(repoRoot)
280
+ } else {
281
+ items.push({
282
+ artifact: 'visual-regression toolchain',
283
+ action: 'skipped',
284
+ detail: 'run `init --with-visual` to set up',
285
+ })
286
+ }
287
+
288
+ return {
289
+ command: 'init',
290
+ agent: opts.agent,
291
+ dryRun: opts.dryRun,
292
+ json: opts.json,
293
+ items,
294
+ }
295
+ }
296
+
297
+ export async function runUninstall(
298
+ pkgDir: string,
299
+ opts: RunOptions,
300
+ ): Promise<RunResult> {
301
+ const target = AGENT_TARGETS[opts.agent] as AgentTarget
302
+ const repoRoot = findRepoRoot(pkgDir)
303
+ const items: PlanItem[] = []
304
+
305
+ // 1. Launch config — drop only our entry; keep the file and other entries.
306
+ const launchPath = join(repoRoot, target.launchConfigPath)
307
+ const config = await readJson(launchPath)
308
+ if (config) {
309
+ const configs =
310
+ (config.configurations as Record<string, unknown>[] | undefined) ?? []
311
+ const idx = configs.findIndex((c) => c.name === LAUNCH_NAME)
312
+ if (idx === -1) {
313
+ items.push({
314
+ artifact: target.launchConfigPath,
315
+ action: 'skipped',
316
+ detail: 'no entry to remove',
317
+ })
318
+ } else {
319
+ configs.splice(idx, 1)
320
+ config.configurations = configs
321
+ items.push({
322
+ artifact: target.launchConfigPath,
323
+ action: 'removed',
324
+ detail: `removed "${LAUNCH_NAME}" entry`,
325
+ })
326
+ if (!opts.dryRun)
327
+ await Bun.write(launchPath, `${JSON.stringify(config, null, 2)}\n`)
328
+ }
329
+ } else {
330
+ items.push({
331
+ artifact: target.launchConfigPath,
332
+ action: 'skipped',
333
+ detail: 'no launch config',
334
+ })
335
+ }
336
+
337
+ // 2. Skills — remove only directories matching bundled skill ids.
338
+ for (const id of await bundledSkillIds()) {
339
+ const dest = join(repoRoot, target.skillsDir, id)
340
+ const rel = join(target.skillsDir, id)
341
+ if (existsSync(dest)) {
342
+ items.push({ artifact: rel, action: 'removed' })
343
+ if (!opts.dryRun) await rm(dest, { recursive: true, force: true })
344
+ } else {
345
+ items.push({ artifact: rel, action: 'skipped', detail: 'not installed' })
346
+ }
347
+ }
348
+
349
+ // 3. Instructions pointer — strip only the sentinel-delimited block.
350
+ const instrPath = resolveInstructionsFile(repoRoot, target)
351
+ const instrRel = relative(repoRoot, instrPath)
352
+ const current = (await Bun.file(instrPath).exists())
353
+ ? await Bun.file(instrPath).text()
354
+ : ''
355
+ if (current.includes(SENTINEL_START) && current.includes(SENTINEL_END)) {
356
+ const re = new RegExp(
357
+ `\\n*${SENTINEL_START}[\\s\\S]*?${SENTINEL_END}\\n*`,
358
+ 'g',
359
+ )
360
+ const stripped = `${current
361
+ .replace(re, '\n')
362
+ .replace(/\n{3,}/g, '\n\n')
363
+ .trimEnd()}\n`
364
+ items.push({
365
+ artifact: instrRel,
366
+ action: 'removed',
367
+ detail: 'removed agent-guide pointer',
368
+ })
369
+ if (!opts.dryRun) await Bun.write(instrPath, stripped)
370
+ } else {
371
+ items.push({
372
+ artifact: instrRel,
373
+ action: 'skipped',
374
+ detail: 'no pointer present',
375
+ })
376
+ }
377
+
378
+ return {
379
+ command: 'uninstall',
380
+ agent: opts.agent,
381
+ dryRun: opts.dryRun,
382
+ json: opts.json,
383
+ items,
384
+ }
385
+ }
386
+
387
+ const MARK: Record<Action, string> = {
388
+ created: '+',
389
+ updated: '~',
390
+ removed: '-',
391
+ skipped: '·',
392
+ }
393
+
394
+ /** Print the result as a human report, or JSON when requested. */
395
+ export function report(result: RunResult): void {
396
+ if (result.json) {
397
+ console.log(JSON.stringify(result, null, 2))
398
+ return
399
+ }
400
+ const verb = result.command === 'init' ? 'init' : 'uninstall'
401
+ const dry = result.dryRun ? ' (dry run)' : ''
402
+ console.log(`\n display-case ${verb} → ${result.agent}${dry}`)
403
+ for (const it of result.items) {
404
+ console.log(
405
+ ` ${MARK[it.action]} ${it.action.padEnd(7)} ${it.artifact}${it.detail ? ` (${it.detail})` : ''}`,
406
+ )
407
+ }
408
+ const changed = result.items.filter((i) => i.action !== 'skipped').length
409
+ console.log(
410
+ ` ${changed} change(s)${result.dryRun ? ' planned' : ''}, ${result.items.length - changed} skipped\n`,
411
+ )
412
+ }