@invisibleloop/pulse 0.20.11 → 0.20.19

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 (266) hide show
  1. package/package.json +6 -5
  2. package/public/.pulse-ui-version +1 -1
  3. package/scripts/build.js +26 -0
  4. package/scripts/release-version.js +17 -8
  5. package/scripts/strip-server.test.js +60 -0
  6. package/src/agent/checklist.md +8 -12
  7. package/src/agent/commands/pulse-start.md +2 -0
  8. package/src/agent/commands/verify.md +30 -8
  9. package/src/agent/guide-components.md +7 -25
  10. package/src/agent/guide-routing.md +0 -12
  11. package/src/agent/guide-server.md +102 -1
  12. package/src/agent/guide-spec.md +8 -1
  13. package/src/agent/guide-styles.md +23 -0
  14. package/src/agent/identity.md +9 -0
  15. package/src/agent/workflow.md +4 -23
  16. package/src/cli/check-bundles.js +113 -0
  17. package/src/cli/check-bundles.test.js +137 -0
  18. package/src/cli/cli.test.js +105 -0
  19. package/src/cli/dev.js +48 -3
  20. package/src/cli/diagnose.js +105 -0
  21. package/src/cli/diagnose.test.js +124 -0
  22. package/src/cli/discover.js +9 -2
  23. package/src/cli/index.js +171 -9
  24. package/src/cli/process-utils.js +57 -0
  25. package/src/cli/process-utils.test.js +114 -0
  26. package/src/cli/review.js +391 -0
  27. package/src/cli/review.test.js +143 -0
  28. package/src/cli/scaffold.js +9 -4
  29. package/src/cli/stamp.js +102 -0
  30. package/src/cli/stamp.test.js +149 -0
  31. package/src/cli/start.js +15 -2
  32. package/src/cli/validate.js +184 -0
  33. package/src/cli/validate.test.js +153 -0
  34. package/src/mcp/mcp.test.js +261 -30
  35. package/src/mcp/server.js +207 -515
  36. package/src/mcp/validate-worker.js +18 -1
  37. package/src/runtime/index.js +144 -11
  38. package/src/runtime/runtime.test.js +199 -0
  39. package/src/runtime/ssr.js +13 -6
  40. package/src/runtime/ssr.test.js +31 -0
  41. package/src/server/index.js +162 -9
  42. package/src/server/server.test.js +225 -2
  43. package/src/spec/schema.js +1 -1
  44. package/src/ui/index.js +0 -5
  45. package/src/ui/ui.test.js +0 -75
  46. package/types/ui.d.ts +0 -21
  47. package/.claude/commands/build-page.md +0 -63
  48. package/.claude/commands/new-doc-page.md +0 -45
  49. package/.claude/commands/pulse-start.md +0 -11
  50. package/.claude/commands/pulse-stop.md +0 -5
  51. package/.claude/commands/verify.md +0 -118
  52. package/.claude/pulse-checklist.md +0 -189
  53. package/.github/workflows/ci.yml +0 -25
  54. package/.github/workflows/publish.yml +0 -45
  55. package/.pulse/load-reports/home/1773432711417.json +0 -22
  56. package/.pulse-verified +0 -1
  57. package/AGENTS.md +0 -697
  58. package/CLAUDE.md +0 -697
  59. package/README.md +0 -95
  60. package/benchmark/public/dist/counter.boot-PO6SNSJZ.js +0 -8
  61. package/benchmark/public/dist/home.boot-YK6VAEQI.js +0 -6
  62. package/benchmark/public/dist/manifest.json +0 -5
  63. package/benchmark/public/dist/runtime-5Y7ESAIB.js +0 -2
  64. package/benchmark/public/dist/runtime-KO4BHUQ3.js +0 -49
  65. package/benchmark/results/2026-03-27-09-20.json +0 -188
  66. package/benchmark/results/2026-03-27-09-21.json +0 -188
  67. package/benchmark/results/2026-03-27-09-29.json +0 -188
  68. package/benchmark/results/2026-03-27-10-14.json +0 -188
  69. package/benchmark/results/2026-03-27-10-18.json +0 -232
  70. package/benchmark/src/pages/counter.js +0 -37
  71. package/benchmark/src/pages/home.js +0 -21
  72. package/docs/.claude/commands/pulse-build.md +0 -7
  73. package/docs/.claude/commands/pulse-dev.md +0 -7
  74. package/docs/.claude/commands/pulse-start.md +0 -9
  75. package/docs/.claude/commands/pulse-stop.md +0 -5
  76. package/docs/.claude/commands/verify.md +0 -84
  77. package/docs/.claude/coverage-check.js +0 -196
  78. package/docs/.claude/pulse-checklist.md +0 -189
  79. package/docs/public/.pulse-ui-version +0 -1
  80. package/docs/public/andypixel.css +0 -237
  81. package/docs/public/bookworms.css +0 -422
  82. package/docs/public/coutumes.css +0 -561
  83. package/docs/public/docs.css +0 -2388
  84. package/docs/public/favicon.svg +0 -4
  85. package/docs/public/home-brut.css +0 -982
  86. package/docs/public/images/bookworms/hero-books.jpg +0 -0
  87. package/docs/public/images/bookworms/how-we-read-now.jpg +0 -0
  88. package/docs/public/images/bookworms/lantern-keepers.jpg +0 -0
  89. package/docs/public/images/bookworms/provincial-bookseller.jpg +0 -0
  90. package/docs/public/images/journey/178.jpg +0 -0
  91. package/docs/public/images/journey/29.jpg +0 -0
  92. package/docs/public/images/retrowheels/hero.jpg +0 -0
  93. package/docs/public/images/shop/devbook-pro.jpg +0 -0
  94. package/docs/public/images/suzukibento/1015.jpg +0 -0
  95. package/docs/public/images/suzukibento/1025.jpg +0 -0
  96. package/docs/public/images/suzukibento/1043.jpg +0 -0
  97. package/docs/public/images/suzukibento/1059.jpg +0 -0
  98. package/docs/public/images/suzukibento/1062.jpg +0 -0
  99. package/docs/public/images/suzukibento/1084.jpg +0 -0
  100. package/docs/public/images/swish/1469334031218-e382a71b716b.jpg +0 -0
  101. package/docs/public/images/swish/1483985988355-763728e1935b.jpg +0 -0
  102. package/docs/public/images/swish/1485518882345-15568b007407.jpg +0 -0
  103. package/docs/public/images/swish/1490481651871-ab68de25d43d.jpg +0 -0
  104. package/docs/public/images/swish/1496747611176-843222e1e57c.jpg +0 -0
  105. package/docs/public/images/swish/1502716119720-b23a93e5fe1b.jpg +0 -0
  106. package/docs/public/images/swish/1539109136881-3be0616acf4b.jpg +0 -0
  107. package/docs/public/images/swish/1551803091-e20673f15770.jpg +0 -0
  108. package/docs/public/images/swish/1558769132-cb1aea458c5e.jpg +0 -0
  109. package/docs/public/images/swish/1572804013309-59a88b7e92f1.jpg +0 -0
  110. package/docs/public/img/heic1501a.jpg +0 -0
  111. package/docs/public/img/placeholder-landscape.svg +0 -18
  112. package/docs/public/img/placeholder-square.svg +0 -14
  113. package/docs/public/img/placeholder-tall.svg +0 -22
  114. package/docs/public/journey.css +0 -528
  115. package/docs/public/menu.js +0 -327
  116. package/docs/public/pulse-ui.css +0 -3028
  117. package/docs/public/pulse-ui.js +0 -257
  118. package/docs/public/retrowheels.css +0 -425
  119. package/docs/public/shop.css +0 -269
  120. package/docs/public/sparkys.css +0 -382
  121. package/docs/public/suzukibento.css +0 -517
  122. package/docs/public/swish.css +0 -394
  123. package/docs/public/theme.css +0 -52
  124. package/docs/public/themes/lumio.css +0 -50
  125. package/docs/public/themes/observatory.css +0 -104
  126. package/docs/server.js +0 -161
  127. package/docs/src/lib/component-page.js +0 -49
  128. package/docs/src/lib/highlight.js +0 -2
  129. package/docs/src/lib/layout.js +0 -190
  130. package/docs/src/lib/metrics-store.js +0 -6
  131. package/docs/src/lib/nav.js +0 -171
  132. package/docs/src/lib/stats.js +0 -81
  133. package/docs/src/pages/accessibility.js +0 -157
  134. package/docs/src/pages/actions.js +0 -194
  135. package/docs/src/pages/auth.js +0 -176
  136. package/docs/src/pages/caching.js +0 -95
  137. package/docs/src/pages/components/accordion.js +0 -48
  138. package/docs/src/pages/components/alert.js +0 -35
  139. package/docs/src/pages/components/app-badge.js +0 -41
  140. package/docs/src/pages/components/avatar.js +0 -35
  141. package/docs/src/pages/components/badge.js +0 -36
  142. package/docs/src/pages/components/breadcrumbs.js +0 -94
  143. package/docs/src/pages/components/button.js +0 -84
  144. package/docs/src/pages/components/card.js +0 -225
  145. package/docs/src/pages/components/charts.js +0 -283
  146. package/docs/src/pages/components/checkbox.js +0 -129
  147. package/docs/src/pages/components/cluster.js +0 -47
  148. package/docs/src/pages/components/container.js +0 -40
  149. package/docs/src/pages/components/cta.js +0 -54
  150. package/docs/src/pages/components/divider.js +0 -37
  151. package/docs/src/pages/components/empty.js +0 -36
  152. package/docs/src/pages/components/feature.js +0 -60
  153. package/docs/src/pages/components/fieldset.js +0 -65
  154. package/docs/src/pages/components/fileupload.js +0 -128
  155. package/docs/src/pages/components/footer.js +0 -58
  156. package/docs/src/pages/components/grid.js +0 -169
  157. package/docs/src/pages/components/heading.js +0 -107
  158. package/docs/src/pages/components/hero.js +0 -123
  159. package/docs/src/pages/components/icons.js +0 -410
  160. package/docs/src/pages/components/image.js +0 -71
  161. package/docs/src/pages/components/input.js +0 -51
  162. package/docs/src/pages/components/media.js +0 -51
  163. package/docs/src/pages/components/modal.js +0 -111
  164. package/docs/src/pages/components/nav.js +0 -136
  165. package/docs/src/pages/components/phone-frame.js +0 -74
  166. package/docs/src/pages/components/progress.js +0 -102
  167. package/docs/src/pages/components/prose.js +0 -111
  168. package/docs/src/pages/components/pullquote.js +0 -71
  169. package/docs/src/pages/components/radio.js +0 -194
  170. package/docs/src/pages/components/rating.js +0 -106
  171. package/docs/src/pages/components/search.js +0 -61
  172. package/docs/src/pages/components/section.js +0 -98
  173. package/docs/src/pages/components/segmented.js +0 -121
  174. package/docs/src/pages/components/select.js +0 -45
  175. package/docs/src/pages/components/slider.js +0 -115
  176. package/docs/src/pages/components/spinner.js +0 -73
  177. package/docs/src/pages/components/stack.js +0 -48
  178. package/docs/src/pages/components/stat.js +0 -55
  179. package/docs/src/pages/components/stepper.js +0 -66
  180. package/docs/src/pages/components/table.js +0 -45
  181. package/docs/src/pages/components/textarea.js +0 -31
  182. package/docs/src/pages/components/toggle.js +0 -84
  183. package/docs/src/pages/components/tooltip.js +0 -48
  184. package/docs/src/pages/components.js +0 -200
  185. package/docs/src/pages/config.js +0 -193
  186. package/docs/src/pages/constraints.js +0 -100
  187. package/docs/src/pages/deploy.js +0 -233
  188. package/docs/src/pages/error-pages.js +0 -99
  189. package/docs/src/pages/extending.js +0 -198
  190. package/docs/src/pages/faq.js +0 -99
  191. package/docs/src/pages/forms.js +0 -129
  192. package/docs/src/pages/getting-started.js +0 -147
  193. package/docs/src/pages/guard.js +0 -120
  194. package/docs/src/pages/home.js +0 -350
  195. package/docs/src/pages/how-it-works.js +0 -244
  196. package/docs/src/pages/hydration.js +0 -108
  197. package/docs/src/pages/images.js +0 -120
  198. package/docs/src/pages/markdown.js +0 -156
  199. package/docs/src/pages/meta.js +0 -193
  200. package/docs/src/pages/mutations.js +0 -107
  201. package/docs/src/pages/navigation.js +0 -85
  202. package/docs/src/pages/performance.js +0 -157
  203. package/docs/src/pages/persist.js +0 -89
  204. package/docs/src/pages/project-structure.js +0 -91
  205. package/docs/src/pages/prompt-examples.js +0 -186
  206. package/docs/src/pages/raw-responses.js +0 -122
  207. package/docs/src/pages/routing.js +0 -120
  208. package/docs/src/pages/server-api.js +0 -291
  209. package/docs/src/pages/server-data.js +0 -203
  210. package/docs/src/pages/slash-commands.js +0 -55
  211. package/docs/src/pages/spec.js +0 -207
  212. package/docs/src/pages/state.js +0 -102
  213. package/docs/src/pages/store.js +0 -203
  214. package/docs/src/pages/streaming.js +0 -107
  215. package/docs/src/pages/stripe.js +0 -194
  216. package/docs/src/pages/supabase.js +0 -324
  217. package/docs/src/pages/templates/andypixel.js +0 -260
  218. package/docs/src/pages/templates/blog-post.js +0 -226
  219. package/docs/src/pages/templates/bookworms.js +0 -227
  220. package/docs/src/pages/templates/coutumes.js +0 -237
  221. package/docs/src/pages/templates/journey.js +0 -186
  222. package/docs/src/pages/templates/mobile-app.js +0 -371
  223. package/docs/src/pages/templates/retrowheels.js +0 -355
  224. package/docs/src/pages/templates/shop.js +0 -268
  225. package/docs/src/pages/templates/sparkys.js +0 -264
  226. package/docs/src/pages/templates/suzukibento.js +0 -200
  227. package/docs/src/pages/templates/swish.js +0 -313
  228. package/docs/src/pages/templates.js +0 -534
  229. package/docs/src/pages/testing.js +0 -199
  230. package/docs/src/pages/validation.js +0 -138
  231. package/examples/.claude/commands/pulse-build.md +0 -7
  232. package/examples/.claude/commands/pulse-dev.md +0 -7
  233. package/examples/.claude/commands/pulse-start.md +0 -9
  234. package/examples/.claude/commands/pulse-stop.md +0 -5
  235. package/examples/.claude/commands/verify.md +0 -84
  236. package/examples/.claude/coverage-check.js +0 -196
  237. package/examples/.claude/pulse-checklist.md +0 -189
  238. package/examples/andypixel.css +0 -237
  239. package/examples/andypixel.js +0 -258
  240. package/examples/bookworms.css +0 -422
  241. package/examples/bookworms.js +0 -225
  242. package/examples/contact.js +0 -166
  243. package/examples/counter.js +0 -94
  244. package/examples/dev.server.js +0 -110
  245. package/examples/examples.test.js +0 -394
  246. package/examples/journey.css +0 -528
  247. package/examples/journey.js +0 -186
  248. package/examples/pricing.js +0 -262
  249. package/examples/products.js +0 -190
  250. package/examples/public/.pulse-ui-version +0 -1
  251. package/examples/public/pulse-ui.css +0 -3028
  252. package/examples/public/pulse-ui.js +0 -257
  253. package/examples/quiz.js +0 -208
  254. package/examples/retrowheels.css +0 -425
  255. package/examples/retrowheels.js +0 -353
  256. package/examples/shared.js +0 -84
  257. package/examples/suzukibento.css +0 -513
  258. package/examples/suzukibento.js +0 -200
  259. package/examples/swish.css +0 -394
  260. package/examples/swish.js +0 -311
  261. package/examples/todos.js +0 -162
  262. package/src/ui/fieldset.js +0 -37
  263. package/src/ui/phone-frame.js +0 -40
  264. package/src/ui/search.js +0 -78
  265. package/src/ui/segmented.js +0 -50
  266. package/src/ui/tooltip.js +0 -28
@@ -1,199 +0,0 @@
1
- import { renderLayout, h1, lead, section, codeBlock, callout, table } from '../lib/layout.js'
2
- import { prevNext } from '../lib/nav.js'
3
- import { highlight } from '../lib/highlight.js'
4
-
5
- const { prev, next } = prevNext('/testing')
6
-
7
- export default {
8
- route: '/testing',
9
- meta: {
10
- theme: 'light',
11
- title: 'Testing — Pulse Docs',
12
- description: 'Test Pulse view functions with renderSync and render — query the HTML output with CSS-like selectors, no DOM required.',
13
- styles: ['/theme.css', '/docs.css'],
14
- },
15
- view: () => renderLayout({
16
- currentHref: '/testing',
17
- prev,
18
- next,
19
- content: `
20
- ${h1('Testing')}
21
- ${lead('Pulse ships a built-in testing helper at <code>@invisibleloop/pulse/testing</code>. Render a spec\'s view in tests and query the HTML output with CSS-like selectors — no DOM, no jsdom, no extra dependencies.')}
22
-
23
- ${section('quick-start', 'Quick start')}
24
- ${codeBlock(highlight(`import { renderSync, render } from '@invisibleloop/pulse/testing'
25
- import assert from 'node:assert/strict'
26
- import spec from './src/pages/counter.js'
27
-
28
- // Synchronous — calls view directly, mock state and server
29
- const result = renderSync(spec, { state: { count: 5 } })
30
- assert(result.has('button'))
31
- assert.equal(result.get('#count').text, '5')
32
-
33
- // Async — runs real spec.server fetchers (or pass server to mock them)
34
- const result = await render(productSpec, {
35
- server: { product: { id: 1, name: 'Widget', price: 9.99 } }
36
- })
37
- assert.equal(result.get('h1').text, 'Widget')
38
- assert.equal(result.count('li'), 3)`, 'js'))}
39
-
40
- ${section('render-sync', 'renderSync(spec, options?)')}
41
- <p>Synchronous. Calls the view function directly — no server fetcher resolution. The fastest path for unit testing pure view functions.</p>
42
- ${table(
43
- ['Option', 'Type', 'Default', 'Description'],
44
- [
45
- ['<code>state</code>', '<code>object</code>', '<code>{}</code>', 'State overrides merged with <code>spec.state</code>.'],
46
- ['<code>server</code>', '<code>object</code>', '<code>{}</code>', 'Server state passed directly to the view. Fetchers are never called.'],
47
- ]
48
- )}
49
- ${codeBlock(highlight(`import { renderSync } from '@invisibleloop/pulse/testing'
50
-
51
- const result = renderSync(formSpec, {
52
- state: { name: 'Alice', email: 'alice@example.com' },
53
- server: { plans: [{ id: 'pro', label: 'Pro' }] },
54
- })`, 'js'))}
55
-
56
- ${section('render-async', 'render(spec, options?)')}
57
- <p>Async. Two modes — mock or integration:</p>
58
- <ul>
59
- <li><strong>Mock mode</strong> — pass <code>server</code> to use that data directly. Fetchers are not called. Fast and deterministic.</li>
60
- <li><strong>Integration mode</strong> — omit <code>server</code> and real <code>spec.server</code> fetchers run. Pass <code>ctx</code> to set params, cookies, headers, etc.</li>
61
- </ul>
62
- ${table(
63
- ['Option', 'Type', 'Default', 'Description'],
64
- [
65
- ['<code>state</code>', '<code>object</code>', '<code>{}</code>', 'State overrides merged with <code>spec.state</code>.'],
66
- ['<code>server</code>', '<code>object</code>', '<code>undefined</code>', 'Server state passed directly to the view. When set, fetchers are skipped entirely.'],
67
- ['<code>ctx</code>', '<code>object</code>', '<code>{}</code>', 'Request context passed to <code>spec.server</code> fetchers (integration mode only). Accepts <code>params</code>, <code>query</code>, <code>cookies</code>, <code>headers</code>, etc.'],
68
- ]
69
- )}
70
- ${codeBlock(highlight(`import { render } from '@invisibleloop/pulse/testing'
71
-
72
- // Mock — skip fetchers
73
- const result = await render(productSpec, {
74
- server: { product: { id: 42, name: 'Gadget' } }
75
- })
76
-
77
- // Integration — real fetchers, with ctx
78
- const result = await render(productSpec, {
79
- ctx: { params: { id: '42' }, cookies: { session: 'abc' } }
80
- })`, 'js'))}
81
-
82
- ${section('render-result', 'RenderResult')}
83
- <p>Both functions return the same <code>RenderResult</code> object.</p>
84
- ${table(
85
- ['Property / method', 'Returns', 'Description'],
86
- [
87
- ['<code>.html</code>', '<code>string</code>', 'Raw HTML string from the view.'],
88
- ['<code>.state</code>', '<code>object</code>', 'Client state used for rendering.'],
89
- ['<code>.server</code>', '<code>object</code>', 'Server state used for rendering.'],
90
- ['<code>.text()</code>', '<code>string</code>', 'All text content — tags stripped, entities decoded, whitespace collapsed.'],
91
- ['<code>.has(selector)</code>', '<code>boolean</code>', 'True if any element matches selector.'],
92
- ['<code>.find(selector)</code>', '<code>Element | null</code>', 'First matching element, or null.'],
93
- ['<code>.get(selector)</code>', '<code>Element</code>', 'First matching element. Throws with a clear message if not found.'],
94
- ['<code>.findAll(selector)</code>', '<code>Element[]</code>', 'All matching elements.'],
95
- ['<code>.count(selector)</code>', '<code>number</code>', 'Number of matching elements.'],
96
- ['<code>.attr(selector, name)</code>', '<code>string | null</code>', 'Attribute value of the first matching element. Null if element or attribute absent.'],
97
- ]
98
- )}
99
-
100
- ${section('element', 'Element')}
101
- <p>Elements returned by <code>find()</code>, <code>get()</code>, and <code>findAll()</code> support the same query methods scoped to their own subtree.</p>
102
- ${table(
103
- ['Property / method', 'Returns', 'Description'],
104
- [
105
- ['<code>.tag</code>', '<code>string</code>', 'Tag name (lowercase).'],
106
- ['<code>.text</code>', '<code>string</code>', 'All text content within the element, whitespace-collapsed.'],
107
- ['<code>.attrs</code>', '<code>object</code>', 'Parsed attribute map. Boolean attrs (e.g. <code>disabled</code>) have value <code>true</code>.'],
108
- ['<code>.attr(name)</code>', '<code>string | null</code>', 'Get one attribute. Returns <code>""</code> for boolean attrs, <code>null</code> if absent — mirrors <code>getAttribute()</code>.'],
109
- ['<code>.find(selector)</code>', '<code>Element | null</code>', 'First matching descendant.'],
110
- ['<code>.findAll(selector)</code>', '<code>Element[]</code>', 'All matching descendants.'],
111
- ['<code>.has(selector)</code>', '<code>boolean</code>', 'True if any descendant matches selector.'],
112
- ]
113
- )}
114
-
115
- ${section('selectors', 'Supported selectors')}
116
- <p>The selector engine supports the most common CSS patterns including descendant combinators (<code>div p</code>, <code>.parent li</code>). Descendant selectors match the rightmost token within the ancestor context — if no ancestors match, the result is empty (count 0).</p>
117
- ${table(
118
- ['Selector', 'Example', 'Matches'],
119
- [
120
- ['Tag', '<code>button</code>', 'Any <code>&lt;button&gt;</code>'],
121
- ['Class', '<code>.ui-btn</code>', 'Elements with that class'],
122
- ['ID', '<code>#submit</code>', 'Element with that id'],
123
- ['Attribute present','<code>[disabled]</code>', 'Elements with a <code>disabled</code> attribute'],
124
- ['Attribute value', '<code>[type="submit"]</code>', 'Elements where <code>type</code> equals <code>submit</code>'],
125
- ['Compound', '<code>button.primary[disabled]</code>', 'All conditions on the same element'],
126
- ['Descendant', '<code>tbody tr</code>', '<code>&lt;tr&gt;</code> elements inside <code>&lt;tbody&gt;</code>'],
127
- ]
128
- )}
129
- ${codeBlock(highlight(`result.has('button') // any <button>
130
- result.has('.ui-btn--primary') // BEM modifier class
131
- result.has('[data-action="submit"]') // data attribute
132
- result.has('input[type="email"][required]') // compound
133
- result.get('form').findAll('input') // inputs inside form`, 'js'))}
134
-
135
- ${section('patterns', 'Common patterns')}
136
- ${codeBlock(highlight(`// Assert an element exists and check its text
137
- assert.equal(result.get('h1').text, 'Page Title')
138
-
139
- // Assert an element does NOT exist
140
- assert(!result.has('.error-message'))
141
-
142
- // Check an attribute value
143
- assert.equal(result.attr('input[name="email"]', 'type'), 'email')
144
-
145
- // Boolean attributes — attr() returns '' (not 'disabled')
146
- assert.equal(result.attr('[disabled]', 'disabled'), '')
147
- assert(result.get('[disabled]').attr('disabled') === '')
148
-
149
- // Count elements
150
- assert.equal(result.count('li'), 3)
151
-
152
- // Inspect all items
153
- const items = result.findAll('li')
154
- assert.equal(items[0].text, 'Alpha')
155
- assert.equal(items[1].text, 'Beta')
156
-
157
- // Scope a search to a subtree
158
- const form = result.get('form')
159
- assert(form.has('button[type="submit"]'))
160
- assert.equal(form.count('input'), 2)
161
-
162
- // Text content decodes entities
163
- // <p>&lt;b&gt;bold&lt;/b&gt;</p> → text === '<b>bold</b>'
164
- assert.equal(result.get('p').text, '<b>bold</b>')`, 'js'))}
165
-
166
- ${section('test-file', 'Example test file')}
167
- ${codeBlock(highlight(`/**
168
- * src/pages/counter.test.js
169
- * run: node src/pages/counter.test.js
170
- */
171
- import { test } from 'node:test'
172
- import assert from 'node:assert/strict'
173
- import { renderSync } from '@invisibleloop/pulse/testing'
174
- import spec from './counter.js'
175
-
176
- test('renders the current count', () => {
177
- const result = renderSync(spec, { state: { count: 7 } })
178
- assert.equal(result.get('#count').text, '7')
179
- })
180
-
181
- test('increment mutation returns count + 1', () => {
182
- const next = spec.mutations.increment({ count: 0 })
183
- assert.equal(next.count, 1)
184
- })
185
-
186
- test('decrement mutation returns count - 1', () => {
187
- const next = spec.mutations.decrement({ count: 5 })
188
- assert.equal(next.count, 4)
189
- })
190
-
191
- test('view renders increment and decrement buttons', () => {
192
- const result = renderSync(spec)
193
- assert(result.has('[data-event="increment"]'))
194
- assert(result.has('[data-event="decrement"]'))
195
- })`, 'js'))}
196
- ${callout('note', 'Use <code>renderSync</code> for mutations and pure view tests — it\'s synchronous and needs no <code>await</code>. Use <code>render</code> when your spec has <code>server</code> fetchers you want to exercise for integration coverage.')}
197
- `,
198
- }),
199
- }
@@ -1,138 +0,0 @@
1
- import { renderLayout, h1, lead, section, sub, codeBlock, callout, table } from '../lib/layout.js'
2
- import { prevNext } from '../lib/nav.js'
3
- import { highlight } from '../lib/highlight.js'
4
-
5
- const { prev, next } = prevNext('/validation')
6
-
7
- export default {
8
- route: '/validation',
9
- meta: {
10
- theme: 'light',
11
- title: 'Validation — Pulse Docs',
12
- description: 'Declarative validation rules in Pulse — syntax, formats, dot-path notation, and error handling.',
13
- styles: ['/theme.css', '/docs.css'],
14
- },
15
- view: () => renderLayout({
16
- currentHref: '/validation',
17
- prev,
18
- next,
19
- content: `
20
- ${h1('Validation')}
21
- ${lead('Validation rules are declared in the spec, co-located with the state they guard. When an action sets <code>validate: true</code>, Pulse enforces every rule before the async work runs. Invalid data cannot reach <code>run()</code>.')}
22
-
23
- ${section('declaring', 'Declaring validation rules')}
24
- <p>The <code>validation</code> field maps dot-path state keys to rule objects:</p>
25
- ${codeBlock(highlight(`export default {
26
- route: '/signup',
27
- state: {
28
- fields: { name: '', email: '', age: '', website: '' },
29
- },
30
- validation: {
31
- 'fields.name': { required: true, minLength: 2, maxLength: 100 },
32
- 'fields.email': { required: true, format: 'email' },
33
- 'fields.age': { required: true, min: 18, max: 120 },
34
- 'fields.website': { format: 'url' }, // optional field, but must be valid URL if provided
35
- },
36
- // ...
37
- }`, 'js'))}
38
-
39
- ${section('rules', 'Available rules')}
40
- ${table(
41
- ['Rule', 'Type', 'Description'],
42
- [
43
- ['<code>required</code>', '<code>boolean</code>', 'Field must be present and non-empty.'],
44
- ['<code>minLength</code>', '<code>number</code>', 'String must be at least N characters.'],
45
- ['<code>maxLength</code>', '<code>number</code>', 'String must be at most N characters.'],
46
- ['<code>min</code>', '<code>number</code>', 'Numeric value must be ≥ N.'],
47
- ['<code>max</code>', '<code>number</code>', 'Numeric value must be ≤ N.'],
48
- ['<code>pattern</code>', '<code>RegExp | string</code>', 'Value must match the regular expression.'],
49
- ['<code>format</code>', '<code>string</code>', 'Named format: <code>email</code>, <code>url</code>, or <code>numeric</code>.'],
50
- ]
51
- )}
52
-
53
- ${section('formats', 'Named formats')}
54
- ${table(
55
- ['Format', 'What it checks'],
56
- [
57
- ['<code>email</code>', 'Basic email structure — must contain <code>@</code> and a domain.'],
58
- ['<code>url</code>', 'Must start with <code>http://</code> or <code>https://</code>.'],
59
- ['<code>numeric</code>', 'Must consist entirely of digit characters.'],
60
- ]
61
- )}
62
-
63
- ${section('dot-paths', 'Dot-path notation')}
64
- <p>Validation keys are dot-paths into the current <code>state</code>, allowing nested fields to be validated without any special syntax:</p>
65
- ${codeBlock(highlight(`state: {
66
- billing: {
67
- address: { street: '', city: '', postcode: '' },
68
- card: { number: '', expiry: '' },
69
- },
70
- }
71
-
72
- validation: {
73
- 'billing.address.street': { required: true },
74
- 'billing.address.city': { required: true },
75
- 'billing.address.postcode': { required: true, pattern: /^[A-Z]{1,2}\\d[A-Z\\d]? \\d[A-Z]{2}$/i },
76
- 'billing.card.number': { required: true, format: 'numeric', minLength: 16, maxLength: 16 },
77
- 'billing.card.expiry': { required: true },
78
- }`, 'js'))}
79
-
80
- ${section('when-runs', 'When validation runs')}
81
- <p>Validation only runs when an action declares <code>validate: true</code>. The order is enforced by the framework:</p>
82
- <ol>
83
- <li><code>onStart</code> captures <code>FormData</code> values into state</li>
84
- <li>Validation reads those values from state using dot-paths</li>
85
- <li>If any rules fail, <code>onError</code> is called immediately — <code>run</code> is skipped</li>
86
- </ol>
87
- ${callout('note', 'Validation reads from <strong>state</strong>, not from raw <code>FormData</code>. <code>onStart</code> must copy form values into state first — this is what makes them available to dot-path rules.')}
88
-
89
- ${section('error-structure', 'Error structure')}
90
- <p>When validation fails, the runtime throws an error object with a <code>validation</code> array:</p>
91
- ${codeBlock(highlight(`{
92
- message: 'Validation failed',
93
- validation: [
94
- { field: 'fields.email', rule: 'format', message: 'Must be a valid email address' },
95
- { field: 'fields.name', rule: 'required', message: 'Required' },
96
- { field: 'fields.age', rule: 'min', message: 'Must be at least 18' },
97
- ]
98
- }`, 'js'))}
99
- <p>In your action's <code>onError</code>, check for <code>err?.validation</code> to distinguish validation errors from other failures:</p>
100
- ${codeBlock(highlight(`onError: (state, err) => ({
101
- status: 'error',
102
- errors: err?.validation ?? [{ message: err.message }],
103
- })`, 'js'))}
104
-
105
- ${section('rendering', 'Rendering errors')}
106
- <p>The errors array maps to UI in the view — a global error list, or inline errors using the <code>field</code> property to place them next to each input:</p>
107
- ${codeBlock(highlight(`view: (state) => {
108
- const errFor = (field) => {
109
- const e = state.errors.find(e => e.field === field)
110
- return e ? \`<p class="field-error">\${e.message}</p>\` : ''
111
- }
112
-
113
- return \`
114
- <form data-action="submit">
115
- <label>
116
- Email
117
- <input name="email" type="email" value="\${state.fields.email}">
118
- \${errFor('fields.email')}
119
- </label>
120
- <label>
121
- Name
122
- <input name="name" type="text" value="\${state.fields.name}">
123
- \${errFor('fields.name')}
124
- </label>
125
- <button>Submit</button>
126
- </form>
127
- \`
128
- }`, 'js'))}
129
-
130
- ${section('optional-fields', 'Optional fields')}
131
- <p>Omit <code>required: true</code> for optional fields. Other rules (format, minLength, etc.) are only enforced when the field has a value — empty optional fields always pass:</p>
132
- ${codeBlock(highlight(`validation: {
133
- // website is optional, but must be a valid URL if provided
134
- 'fields.website': { format: 'url' },
135
- }`, 'js'))}
136
- `,
137
- }),
138
- }
@@ -1,7 +0,0 @@
1
- Build this Pulse project for production.
2
-
3
- ```bash
4
- pulse build
5
- ```
6
-
7
- This bundles all pages into `public/dist/`. When complete, confirm the build succeeded and list the generated files.
@@ -1,7 +0,0 @@
1
- Start (or restart) the Pulse dev server for this project.
2
-
3
- ```bash
4
- pulse stop; pulse dev
5
- ```
6
-
7
- The server port is read from `pulse.config.js` (defaults to 3000).
@@ -1,9 +0,0 @@
1
- Start the Pulse production server for this project.
2
-
3
- First ensure a build exists (`public/dist/`). Then run:
4
-
5
- ```bash
6
- pulse start
7
- ```
8
-
9
- The production server uses the port from `pulse.config.js` (defaults to 3000).
@@ -1,5 +0,0 @@
1
- Stop the Pulse dev server for this project.
2
-
3
- ```bash
4
- pulse stop
5
- ```
@@ -1,84 +0,0 @@
1
- # Verify
2
-
3
- Run the full verification loop on the current page or the page specified in $ARGUMENTS.
4
-
5
- ## Steps
6
-
7
- ### 1. Identify the target
8
-
9
- If $ARGUMENTS is provided, use it as the file path or route. Otherwise, identify the most recently edited spec file from context.
10
-
11
- ### 2. Validate the spec
12
-
13
- Use `pulse_validate` with the spec file content. If validation fails, report every error clearly and stop — do not proceed to screenshot until the spec is valid.
14
-
15
- ### 3. Check the dev server
16
-
17
- Use `pulse_fetch_page` to confirm the server is responding for the route. If it errors, use `pulse_restart_server` and retry once.
18
-
19
- **If `pulse_fetch_page` returns unexpected HTML (e.g. a different project's page title, a Next.js 404, or any non-Pulse response):** check the `X-Pulse` response header — Pulse servers always send this. If it is absent, the wrong server is running on that port. Stop and inform the user before continuing.
20
-
21
- ### 4. Screenshot
22
-
23
- Use `mcp__chrome-devtools__navigate_page` to load the page route, then `mcp__chrome-devtools__take_screenshot` to capture the result. Describe what you see — layout, content, any obvious rendering issues.
24
-
25
- ### 5. Lighthouse — desktop
26
-
27
- Run `mcp__chrome-devtools__lighthouse_audit` on the route with `{ "strategy": "desktop" }`.
28
-
29
- **Pass bar: Accessibility, Best Practices, and SEO must all be 100.** Performance is measured and reported but is not a hard requirement (it varies with machine load). Report the actual scores. If Accessibility, Best Practices, or SEO is below 100, identify the failing audit(s), fix the issue, and restart from step 2.
30
-
31
- ### 6. Lighthouse — mobile
32
-
33
- Run `mcp__chrome-devtools__lighthouse_audit` on the same route with `{ "strategy": "mobile" }`.
34
-
35
- **Same pass bar: Accessibility, Best Practices, and SEO must all be 100.** Fix any failures and restart from step 2.
36
-
37
- **If you need to visually inspect the layout at a mobile viewport width** (e.g. to debug a wrapping or overflow issue), use `mcp__chrome-devtools__emulate` with `viewport: "390x844x2,mobile,touch"` — not `resize_page`. The `emulate` tool reliably sets the viewport; `resize_page` does not reliably trigger responsive layout changes. Reset afterward with `emulate` using the desktop viewport: `"1440x900x1"`.
38
-
39
- ### 7. Performance
40
-
41
- Navigate to the page route, then run `mcp__chrome-devtools__performance_start_trace` with `reload: true` and `autoStop: true`. Report LCP and CLS. Flag any LCP insight that suggests a fixable problem (render-blocking resources, large image load delay, etc.).
42
-
43
- ### 8. Console errors
44
-
45
- Use `mcp__chrome-devtools__list_console_messages` — report any errors or unexpected warnings.
46
-
47
- ### 9. Code review
48
-
49
- Call `pulse_review` with the absolute path to the spec file. Read the source, rendered HTML, validator output, and checklist it returns. Fix every issue before proceeding. This is the structured review — do not skip it or replace it with a manual checklist read.
50
-
51
- ### 10. Close the browser
52
-
53
- Use `mcp__chrome-devtools__list_pages` to get all open pages, then call `mcp__chrome-devtools__close_page` for every page ID returned. This closes the entire browser, not just the tab. `pageId` must be a number, not a string.
54
-
55
- ### 11. Write verification stamp
56
-
57
- Run:
58
-
59
- ```bash
60
- date +%s > .pulse-verified
61
- ```
62
-
63
- **This must be the last write operation before stopping.** The stop hook compares each edited spec's modification time against this stamp — if any spec is newer than the stamp, the hook blocks. Do not edit any spec file after writing the stamp.
64
-
65
- ### 12. Report
66
-
67
- Summarise:
68
- - Validation: pass or fail (with errors if any)
69
- - Visual: what was visible in the screenshot
70
- - Lighthouse desktop: scores for all four categories
71
- - Lighthouse mobile: scores for all four categories
72
- - Performance: LCP and CLS values, any flagged insights
73
- - Console: any errors
74
- - Review: pass or issues found and fixed
75
-
76
- **Pass bar — all of these must be met before the page is confirmed good:**
77
- - Validation: clean (no errors, no warnings)
78
- - Lighthouse desktop: Accessibility, Best Practices, SEO all 100
79
- - Lighthouse mobile: same
80
- - CLS: 0.00 — any layout shift is a blocker
81
- - LCP: report the value; flag and fix any insight that identifies a fixable cause
82
- - Console: no errors
83
-
84
- If any gate fails, fix the issue and run `/verify` again.
@@ -1,196 +0,0 @@
1
- #!/usr/bin/env node
2
- /**
3
- * coverage-check.js
4
- *
5
- * Called by the Stop hook in .claude/settings.json to enforce test coverage.
6
- * Runs `npm run test:coverage` and blocks the agent if any src/pages/ file
7
- * has uncovered lines — except code that cannot be unit-tested without mocking
8
- * real APIs:
9
- *
10
- * - server fetchers (`server:` blocks) and store fetchers
11
- * - action `run:` functions
12
- * - `guard:`, `submit:`, and `sitemap:` handlers
13
- * - `meta:` blocks (async meta functions hit real data sources)
14
- *
15
- * Also honours c8-style ignore comments, which Node's built-in coverage
16
- * (--experimental-test-coverage) does NOT support natively. In a block or line
17
- * comment, write:
18
- *
19
- * "c8 ignore next" — exempts the following line
20
- * "c8 ignore next 3" — exempts the following 3 lines
21
- * "c8 ignore start" ... "c8 ignore stop" — exempts the range
22
- * ("coverage-ignore" works as a synonym for "c8 ignore")
23
- *
24
- * Outputs a Claude Code hook JSON decision to stdout.
25
- */
26
-
27
- import { execSync } from 'child_process'
28
- import fs from 'fs'
29
- import { pathToFileURL } from 'url'
30
-
31
- // Property blocks that are exempt — all server-side-only code paths that call
32
- // real external APIs and cannot run in unit tests.
33
- const EXEMPT_BLOCK_STARTS = [
34
- /^server\s*:/,
35
- /^run\s*:/, /^run\s*\(/,
36
- /^guard\s*:/,
37
- /^submit\s*:/,
38
- /^sitemap\s*:/,
39
- /^meta\s*:/,
40
- ]
41
-
42
- /**
43
- * Build the set of exempt (1-based) line numbers for a source string:
44
- * lines inside exempt property blocks, plus c8-ignore-annotated lines.
45
- *
46
- * @param {string} src
47
- * @returns {Set<number>}
48
- */
49
- export function computeExemptLines(src) {
50
- const srcLines = src.split('\n')
51
- const exemptLines = new Set()
52
-
53
- // 1. Exempt property blocks — scan tracking brace/paren depth from block start
54
- let i = 0
55
- while (i < srcLines.length) {
56
- const trimmed = srcLines[i].trimStart()
57
- if (EXEMPT_BLOCK_STARTS.some(re => re.test(trimmed))) {
58
- let depth = 0
59
- let started = false
60
- while (i < srcLines.length) {
61
- exemptLines.add(i + 1) // 1-based
62
- for (const ch of srcLines[i]) {
63
- if (ch === '{' || ch === '(') { depth++; started = true }
64
- if (ch === '}' || ch === ')') { depth-- }
65
- }
66
- if (started && depth <= 0) break
67
- i++
68
- }
69
- }
70
- i++
71
- }
72
-
73
- // 2. c8-style ignore comments (Node's built-in coverage ignores these, so we
74
- // honour them here — the marker line itself is always exempt too)
75
- const IGNORE = /(?:c8 ignore|coverage-ignore)(?:\s+(next|start|stop))?(?:\s+(\d+))?/
76
- let rangeOpen = false
77
- for (let n = 0; n < srcLines.length; n++) {
78
- if (rangeOpen) exemptLines.add(n + 1)
79
- const m = srcLines[n].match(IGNORE)
80
- if (!m) continue
81
- exemptLines.add(n + 1)
82
- const keyword = m[1]
83
- if (keyword === 'start') { rangeOpen = true; continue }
84
- if (keyword === 'stop') { rangeOpen = false; continue }
85
- // 'next' (or bare marker) — exempt the following N lines (default 1)
86
- const count = m[2] ? parseInt(m[2], 10) : 1
87
- for (let k = 1; k <= count; k++) exemptLines.add(n + 1 + k)
88
- }
89
-
90
- return exemptLines
91
- }
92
-
93
- /**
94
- * Returns true if all uncovered line numbers fall on exempt lines.
95
- *
96
- * @param {string} filePath
97
- * @param {string} uncoveredSpec - e.g. "30-36 50"
98
- * @returns {boolean}
99
- */
100
- export function allLinesExempt(filePath, uncoveredSpec) {
101
- let src
102
- try { src = fs.readFileSync(filePath, 'utf8') } catch { return false }
103
-
104
- // Expand "30-36 50" → [30, 31, 32, 33, 34, 35, 36, 50]
105
- const lineNums = []
106
- for (const part of uncoveredSpec.split(/\s+/)) {
107
- const range = part.match(/^(\d+)-(\d+)$/)
108
- if (range) {
109
- for (let i = parseInt(range[1]); i <= parseInt(range[2]); i++) lineNums.push(i)
110
- } else if (/^\d+$/.test(part)) {
111
- lineNums.push(parseInt(part))
112
- }
113
- }
114
-
115
- const exemptLines = computeExemptLines(src)
116
- return lineNums.every(n => exemptLines.has(n))
117
- }
118
-
119
- // ---------------------------------------------------------------------------
120
- // Main — only when executed directly (the exports above are unit-tested)
121
- // ---------------------------------------------------------------------------
122
-
123
- if (process.argv[1] && import.meta.url === pathToFileURL(process.argv[1]).href) {
124
- main()
125
- }
126
-
127
- function main() {
128
- // Agent is ending its turn to wait for the user's answer (approval gate or a
129
- // mid-flow pause) — let the turn end. The marker is consumed on the user's
130
- // next prompt, so the gates return in full force afterwards.
131
- if (fs.existsSync('.pulse-awaiting-approval')) process.exit(0)
132
-
133
- // No test files → nothing to check
134
- const hasTests = fs.existsSync('src/pages') && (function scan(d) {
135
- for (const f of fs.readdirSync(d)) {
136
- const p = `${d}/${f}`
137
- if (fs.statSync(p).isDirectory()) { if (scan(p)) return true }
138
- else if (f.endsWith('.test.js')) return true
139
- }
140
- return false
141
- })('src/pages')
142
-
143
- if (!hasTests) process.exit(0)
144
-
145
- let out = ''
146
- try {
147
- out = execSync('npm run test:coverage 2>&1', { encoding: 'utf8', timeout: 60000 })
148
- } catch (e) {
149
- out = e.stdout || e.message
150
- }
151
-
152
- // Parse the coverage report — look for lines with uncovered line numbers
153
- const lines = out.split('\n')
154
- let inReport = false
155
- const rawGaps = []
156
-
157
- for (const line of lines) {
158
- if (line.includes('start of coverage report')) { inReport = true; continue }
159
- if (!inReport) continue
160
-
161
- // Match: # filename.js | line% | branch% | func% | 30-36 50
162
- const m = line.match(/^[#ℹ]\s+([\w./[\]-]+\.js)\s*\|\s*[\d.]+\s*\|\s*[\d.]+\s*\|\s*[\d.]+\s*\|\s*([^|\n]+)$/)
163
- if (m) {
164
- const uncov = m[2].trim()
165
- if (uncov && uncov !== 'uncovered lines') {
166
- rawGaps.push({ file: m[1].trim(), uncovered: uncov })
167
- }
168
- }
169
- }
170
-
171
- const gaps = []
172
- for (const { file, uncovered } of rawGaps) {
173
- // Try to resolve relative to cwd (coverage reports use relative paths)
174
- const candidates = [file, `src/pages/${file}`, file.replace(/^.*src\/pages\//, 'src/pages/')]
175
- const resolved = candidates.find(p => fs.existsSync(p))
176
-
177
- if (resolved && allLinesExempt(resolved, uncovered)) continue // auto-exempt
178
-
179
- gaps.push(` ${file} — uncovered lines: ${uncovered}`)
180
- }
181
-
182
- if (gaps.length) {
183
- process.stdout.write(JSON.stringify({
184
- decision: 'block',
185
- reason: [
186
- 'COVERAGE GAPS detected. Add tests for these uncovered lines before finishing:',
187
- ...gaps,
188
- '',
189
- 'Run `npm run test:coverage` to see the full report.',
190
- 'Auto-exempt: server fetchers, action run(), guard, submit, sitemap, and meta blocks — these call real APIs and need no tests.',
191
- 'For anything else that genuinely cannot be tested, annotate it: /* c8 ignore next */ (or next N, or start/stop for a range) — this hook honours those comments even though Node\'s built-in coverage does not.',
192
- 'Everything else — view branches, mutations, onViewError, pure helper functions — must be covered.',
193
- ].join('\n'),
194
- }))
195
- }
196
- }