@invisibleloop/pulse 0.20.10 → 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 +14 -7
  40. package/src/runtime/ssr.test.js +31 -0
  41. package/src/server/index.js +163 -10
  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 -345
  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,81 +0,0 @@
1
- /**
2
- * Performance metrics — computed once at server start from real build output.
3
- * Bundle sizes are measured by compressing the actual dist files with brotli.
4
- */
5
-
6
- import fs from 'fs'
7
- import path from 'path'
8
- import zlib from 'zlib'
9
- import { fileURLToPath } from 'url'
10
-
11
- const ROOT = path.resolve(fileURLToPath(import.meta.url), '../../../../..')
12
- const DIST = path.join(ROOT, 'benchmark', 'public', 'dist')
13
-
14
- function brotliKb(filepath) {
15
- try {
16
- const buf = fs.readFileSync(filepath)
17
- return zlib.brotliCompressSync(buf).length / 1024
18
- } catch {
19
- return null
20
- }
21
- }
22
-
23
- function measureBundles() {
24
- if (!fs.existsSync(DIST)) return null
25
- const files = fs.readdirSync(DIST).filter(f => f.endsWith('.js'))
26
-
27
- // Main runtime = largest runtime-*.js file
28
- const runtimes = files
29
- .filter(f => f.startsWith('runtime-'))
30
- .map(f => ({ f, kb: brotliKb(path.join(DIST, f)) }))
31
- .filter(x => x.kb !== null)
32
- .sort((a, b) => b.kb - a.kb)
33
-
34
- const counterBoot = files.find(f => f.startsWith('counter.boot-'))
35
- const staticBoot = files.find(f => f.startsWith('home.boot-'))
36
-
37
- const runtimeKb = runtimes[0]?.kb ?? null
38
- const pageBootKb = counterBoot ? brotliKb(path.join(DIST, counterBoot)) : null
39
- const staticBootKb = staticBoot ? brotliKb(path.join(DIST, staticBoot)) : null
40
-
41
- return { runtimeKb, pageBootKb, staticBootKb }
42
- }
43
-
44
- const bundles = measureBundles()
45
-
46
- function fmt(kb, fallback) {
47
- return (kb != null) ? kb.toFixed(kb < 1 ? 2 : 1) : fallback
48
- }
49
-
50
- const runtimeKb = fmt(bundles?.runtimeKb, '3.8')
51
- const firstVisit = bundles?.runtimeKb && bundles?.pageBootKb
52
- ? (bundles.runtimeKb + bundles.pageBootKb).toFixed(1)
53
- : '4.2'
54
- const pageNavKb = fmt(bundles?.pageBootKb, '0.4')
55
-
56
- export const metrics = {
57
- generatedAt: new Date().toLocaleString('en-GB', { dateStyle: 'medium', timeStyle: 'short' }),
58
-
59
- lighthouse: [
60
- { value: '100', label: 'Accessibility' },
61
- { value: '100', label: 'Best Practices' },
62
- { value: '100', label: 'SEO' },
63
- ],
64
-
65
- bundles: [
66
- { value: '0 kB', label: 'Static page — no JS shipped' },
67
- { value: `${firstVisit} kB`, label: 'Single page app — runtime + page (brotli)' },
68
- { value: `${runtimeKb} kB`, label: 'Multi-page — shared runtime, cached (brotli)' },
69
- { value: `${pageNavKb} kB`, label: 'Multi-page — per-page JS bundle (brotli)' },
70
- ],
71
-
72
- vitals: [
73
- { id: 'cls', value: '0.00', label: 'Cumulative Layout Shift' },
74
- ],
75
-
76
- architecture: [
77
- { value: '0', label: 'Runtime dependencies' },
78
- { value: 'None', label: 'Production build step' },
79
- { value: 'Brotli', label: 'Automatic compression' },
80
- ],
81
- }
@@ -1,157 +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('/accessibility')
6
-
7
- export default {
8
- route: '/accessibility',
9
- meta: {
10
- theme: 'light',
11
- title: 'Accessibility — Pulse Docs',
12
- description: 'Keyboard navigation, focus management, semantic HTML, and ARIA patterns in Pulse.',
13
- styles: ['/theme.css', '/docs.css'],
14
- },
15
- view: () => renderLayout({
16
- currentHref: '/accessibility',
17
- prev,
18
- next,
19
- content: `
20
- ${h1('Accessibility')}
21
- ${lead('Pulse enforces a 100 Lighthouse Accessibility score as the baseline. The foundations — skip link, focus styles, and focus management on navigation — are provided by the framework on every page. There is nothing to configure and nothing to forget.')}
22
-
23
- ${section('built-in', 'What the framework provides')}
24
- ${table(
25
- ['Feature', 'How'],
26
- [
27
- ['Skip link', 'Injected on every page as the first focusable element. Targets <code>#main-content</code>. Visible only on focus.'],
28
- ['Focus styles', '<code>:focus-visible</code> outline applied globally — purple, 3px, offset 2px. Suppressed for mouse users via <code>:focus:not(:focus-visible)</code>.'],
29
- ['Navigation focus', 'After client-side navigation, focus moves to <code>#main-content</code>, <code>&lt;main&gt;</code>, or <code>&lt;h1&gt;</code> — whichever is found first. Screen reader users hear the new page heading without a full reload.'],
30
- ]
31
- )}
32
- ${callout('note', 'The skip link targets <code>#main-content</code>. Pages use <code>&lt;main id="main-content"&gt;</code> as the content wrapper so the link resolves correctly.')}
33
-
34
- ${section('structure', 'Page structure')}
35
- <p>Each page view is wrapped in <code>&lt;main id="main-content"&gt;</code> with a single <code>&lt;h1&gt;</code> that describes the current page. Landmark elements communicate structure to assistive technology:</p>
36
- ${codeBlock(highlight(`view: (state) => \`
37
- <main id="main-content">
38
- <h1>Page title</h1>
39
- <!-- page content -->
40
- </main>
41
- \``, 'js'))}
42
- ${table(
43
- ['Element', 'Purpose'],
44
- [
45
- ['<code>&lt;header&gt;</code>', 'Site header, logo, primary nav'],
46
- ['<code>&lt;nav&gt;</code>', 'Navigation links — <code>aria-label</code> distinguishes multiple navs'],
47
- ['<code>&lt;main id="main-content"&gt;</code>', 'Primary page content — one per page'],
48
- ['<code>&lt;aside&gt;</code>', 'Supplementary content (sidebars, related links)'],
49
- ['<code>&lt;footer&gt;</code>', 'Site footer'],
50
- ]
51
- )}
52
-
53
- ${section('interactive', 'Interactive elements')}
54
- <p>Actions are expressed as <code>&lt;button&gt;</code> elements, navigation as <code>&lt;a href&gt;</code> links. Both are keyboard-accessible by default. <code>&lt;div&gt;</code> and <code>&lt;span&gt;</code> elements with click handlers are not reachable by keyboard:</p>
55
- ${codeBlock(highlight(`<!-- Keyboard accessible -->
56
- <button data-event="toggle">Open menu</button>
57
- <a href="/about">About</a>
58
-
59
- <!-- Not keyboard accessible — avoid -->
60
- <div data-event="toggle">Open menu</div>
61
- <span onclick="...">About</span>`, 'html'))}
62
- <p>Buttons that toggle state carry <code>aria-expanded</code> or <code>aria-pressed</code> to communicate the current state to screen readers:</p>
63
- ${codeBlock(highlight(`view: (state) => \`
64
- <button data-event="toggleMenu" aria-expanded="\${state.menuOpen}">
65
- Menu
66
- </button>
67
- \${state.menuOpen ? \`<nav>...</nav>\` : ''}
68
- \``, 'js'))}
69
-
70
- ${section('focus', 'Focus management')}
71
- <p>When a modal or dialog opens, focus moves inside it. When it closes, focus returns to the element that opened it. Since Pulse updates the DOM via morphing rather than a full replacement, the triggering element stays in the DOM and receives focus back naturally.</p>
72
- <p>The <code>autofocus</code> attribute on the first interactive element inside newly revealed content moves focus there after the DOM update — no JavaScript required:</p>
73
- ${codeBlock(highlight(`view: (state) => \`
74
- <button data-event="openDialog">Delete item</button>
75
-
76
- \${state.dialogOpen ? \`
77
- <div role="dialog" aria-modal="true" aria-labelledby="dialog-title">
78
- <h2 id="dialog-title">Confirm deletion</h2>
79
- <p>This cannot be undone.</p>
80
- <button autofocus data-event="confirmDelete">Delete</button>
81
- <button data-event="closeDialog">Cancel</button>
82
- </div>
83
- \` : ''}
84
- \``, 'js'))}
85
-
86
- ${section('live-regions', 'Dynamic content announcements')}
87
- <p>State changes that produce status messages — loading indicators, confirmations, validation errors — are wrapped in live regions so screen readers announce them without a page reload:</p>
88
- ${table(
89
- ['Role', 'Politeness', 'Used for'],
90
- [
91
- ['<code>role="status"</code>', 'Polite — waits for the user to finish', 'Loading states, success messages, counts'],
92
- ['<code>role="alert"</code>', 'Assertive — interrupts immediately', 'Validation errors, destructive confirmations'],
93
- ]
94
- )}
95
- ${codeBlock(highlight(`view: (state) => \`
96
- <form data-action="submit">
97
- <!-- fields -->
98
- <button type="submit" \${state.status === 'loading' ? 'disabled' : ''}>
99
- \${state.status === 'loading' ? 'Saving…' : 'Save'}
100
- </button>
101
- </form>
102
-
103
- \${state.status === 'loading' ? \`
104
- <p role="status">Saving…</p>
105
- \` : ''}
106
-
107
- \${state.errors.length ? \`
108
- <div role="alert">
109
- \${state.errors.map(e => \`<p>\${e.message}</p>\`).join('')}
110
- </div>
111
- \` : ''}
112
- \``, 'js'))}
113
-
114
- ${section('forms', 'Forms')}
115
- <p>Form controls are paired with <code>&lt;label&gt;</code> elements. Error messages are linked to their input via <code>aria-describedby</code> so screen readers announce them when the field receives focus:</p>
116
- ${codeBlock(highlight(`<form data-action="submit">
117
- <div class="field">
118
- <label for="email">Email</label>
119
- <input
120
- id="email"
121
- name="email"
122
- type="email"
123
- required
124
- aria-describedby="\${state.emailError ? 'email-error' : ''}"
125
- >
126
- \${state.emailError
127
- ? \`<p id="email-error" role="alert">\${state.emailError}</p>\`
128
- : ''}
129
- </div>
130
-
131
- <fieldset>
132
- <legend>Notification preferences</legend>
133
- <label><input type="checkbox" name="email-notifs"> Email</label>
134
- <label><input type="checkbox" name="sms-notifs"> SMS</label>
135
- </fieldset>
136
-
137
- <button type="submit">Submit</button>
138
- </form>`, 'html'))}
139
-
140
- ${section('images', 'Images')}
141
- <p>Decorative images carry <code>alt=""</code> so screen readers skip them. Informative images have descriptive alt text. Icon-only buttons are labelled with <code>aria-label</code>, with the icon marked <code>aria-hidden="true"</code>:</p>
142
- ${codeBlock(highlight(`<!-- Informative image -->
143
- <img src="/team.jpg" alt="The Pulse team at the 2025 offsite" width="800" height="450">
144
-
145
- <!-- Decorative image -->
146
- <img src="/divider.svg" alt="" width="600" height="4">
147
-
148
- <!-- Icon-only button -->
149
- <button aria-label="Close">
150
- <svg aria-hidden="true" focusable="false">...</svg>
151
- </button>`, 'html'))}
152
-
153
- ${section('lighthouse', 'Lighthouse score')}
154
- <p>Every page is expected to score 100 on Lighthouse Accessibility. Run <code>/pulse-report</code> after every new page or significant change. Regressions — contrast failures, missing labels, unreachable controls — are caught before they reach users.</p>
155
- `,
156
- }),
157
- }
@@ -1,194 +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('/actions')
6
-
7
- export default {
8
- route: '/actions',
9
- meta: {
10
- theme: 'light',
11
- title: 'Actions — Pulse Docs',
12
- description: 'Async operations in Pulse — lifecycle, FormData, error handling.',
13
- styles: ['/theme.css', '/docs.css'],
14
- },
15
- state: {},
16
- view: () => renderLayout({
17
- currentHref: '/actions',
18
- prev,
19
- next,
20
- content: `
21
- ${h1('Actions')}
22
- ${lead('Actions handle async operations with an enforced lifecycle. The order of steps — capture inputs, validate, run, succeed or fail — is fixed. Skipping validation or running async work before showing a loading state is not possible within the action structure.')}
23
-
24
- ${callout('tip', 'Actions run client-side and need hydration. For forms that must also work with JavaScript disabled, pair the action with a server-side <a href="/forms"><code>submit</code> handler</a> — the same form serves both paths.')}
25
-
26
- ${section('lifecycle', 'The action lifecycle')}
27
- <p>When a form with <code>data-action</code> is submitted, Pulse runs the action through a fixed sequence of steps:</p>
28
- ${codeBlock(highlight(`onStart(state, formData)
29
- ↓ (optional) validate — checks spec.validation rules
30
- run(state, serverState, formData)
31
- ↓ success ↓ error
32
- onSuccess(state, payload) onError(state, err)`, 'bash'))}
33
- <p>Each step triggers a view re-render, so the UI always reflects the current state — loading, validated, succeeded, or failed. The sequence cannot be reordered.</p>
34
-
35
- ${section('defining', 'Defining an action')}
36
- ${codeBlock(highlight(`export default {
37
- route: '/contact',
38
- state: {
39
- status: 'idle', // 'idle' | 'loading' | 'success' | 'error'
40
- errors: [],
41
- },
42
- validation: {
43
- 'fields.name': { required: true, minLength: 2 },
44
- 'fields.email': { required: true, format: 'email' },
45
- 'fields.message': { required: true, minLength: 10 },
46
- },
47
- actions: {
48
- submit: {
49
- // 1. Immediately update state — show loading indicator
50
- onStart: (state, formData) => ({
51
- status: 'loading',
52
- errors: [],
53
- // Capture form values into state before validation runs
54
- fields: {
55
- name: formData.get('name'),
56
- email: formData.get('email'),
57
- message: formData.get('message'),
58
- },
59
- }),
60
-
61
- // 2. Run spec.validation before proceeding to run()
62
- validate: true,
63
-
64
- // 3. Perform the async work
65
- run: async (state, serverState, formData) => {
66
- const res = await fetch('/api/contact', {
67
- method: 'POST',
68
- headers: { 'Content-Type': 'application/json' },
69
- body: JSON.stringify(Object.fromEntries(formData)),
70
- })
71
- if (!res.ok) throw new Error('Request failed')
72
- return res.json()
73
- },
74
-
75
- // 4a. Success
76
- onSuccess: (state, payload) => ({
77
- status: 'success',
78
- errors: [],
79
- }),
80
-
81
- // 4b. Error — payload may have validation errors
82
- onError: (state, err) => ({
83
- status: 'error',
84
- errors: err?.validation ?? [{ message: err.message }],
85
- }),
86
- },
87
- },
88
- }`, 'js'))}
89
-
90
- ${section('binding', 'Binding actions to forms')}
91
- <p>A <code>data-action</code> attribute on a <code>&lt;form&gt;</code> element binds it to an action. When the form is submitted, Pulse creates a <code>FormData</code> object from the form's inputs and passes it through the action lifecycle:</p>
92
- ${codeBlock(highlight(`<form data-action="submit">
93
- <input name="name" type="text" placeholder="Your name">
94
- <input name="email" type="email" placeholder="Email">
95
- <textarea name="message" placeholder="Message"></textarea>
96
- <button type="submit">Send</button>
97
- </form>`, 'html'))}
98
- ${callout('note', 'Pulse intercepts and prevents the default form submission. The action lifecycle is fully in control of what happens with the data — no manual <code>event.preventDefault()</code> needed.')}
99
-
100
- ${section('on-start', 'onStart')}
101
- <p><code>onStart(state, formData)</code> runs synchronously as soon as the form is submitted, before any async work begins. It sets a loading state, captures form values into state so validation can check them, and clears previous errors.</p>
102
- ${callout('warning', '<code>onStart</code> runs <strong>before</strong> validation. <code>FormData</code> values are captured into state first, because the HTML re-renders (destroying the form inputs) once validation runs. All form values are captured here so validation can read them from state via dot-paths.')}
103
-
104
- ${section('validate', 'validate')}
105
- <p>Set <code>validate: true</code> to run the spec's <a href="/validation">validation rules</a> after <code>onStart</code>. If validation fails, <code>onError</code> is called immediately — <code>run</code> is never reached. Async work cannot execute against invalid input.</p>
106
- ${codeBlock(highlight(`// Validation error structure
107
- {
108
- message: 'Validation failed',
109
- validation: [
110
- { field: 'fields.email', rule: 'format', message: 'Must be a valid email' },
111
- { field: 'fields.name', rule: 'required', message: 'Required' },
112
- ]
113
- }`, 'js'))}
114
- <p>Access the errors in <code>onError</code>:</p>
115
- ${codeBlock(highlight(`onError: (state, err) => ({
116
- status: 'error',
117
- errors: err?.validation ?? [{ message: err.message }],
118
- })`, 'js'))}
119
-
120
- ${section('run', 'run')}
121
- <p><code>run(state, serverState, formData)</code> is where the async work happens. Throw or reject to trigger <code>onError</code>. The return value is passed to <code>onSuccess</code> as <code>payload</code>.</p>
122
- ${codeBlock(highlight(`run: async (state, serverState, formData) => {
123
- const res = await fetch('/api/submit', {
124
- method: 'POST',
125
- body: formData,
126
- })
127
- if (!res.ok) {
128
- const err = await res.json()
129
- throw Object.assign(new Error('Server error'), err)
130
- }
131
- return res.json() // → onSuccess payload
132
- },`, 'js'))}
133
-
134
- ${section('on-success', 'onSuccess')}
135
- <p><code>onSuccess(state, payload)</code> receives the current state and whatever <code>run</code> returned. Return a partial state update:</p>
136
- ${codeBlock(highlight(`onSuccess: (state, payload) => ({
137
- status: 'success',
138
- userId: payload.id,
139
- })`, 'js'))}
140
-
141
- ${section('on-error', 'onError')}
142
- <p><code>onError(state, err)</code> receives the current state and the thrown error. Return a partial state update to surface the error in the view:</p>
143
- ${codeBlock(highlight(`onError: (state, err) => ({
144
- status: 'error',
145
- errors: err?.validation ?? [{ message: err.message }],
146
- })`, 'js'))}
147
-
148
- ${section('toast', 'Toast notifications')}
149
- <p>Return <code>_toast</code> from any action hook to show a notification. It is stripped from spec state automatically — it never appears in <code>getState()</code> or the view.</p>
150
- ${codeBlock(highlight(`onSuccess: (state, payload) => ({
151
- status: 'success',
152
- _toast: { message: 'Saved successfully', variant: 'success' },
153
- }),
154
-
155
- onError: (state, err) => ({
156
- status: 'error',
157
- errors: err?.validation ?? [{ message: err.message }],
158
- _toast: { message: 'Something went wrong', variant: 'error' },
159
- }),`, 'js'))}
160
- <p><code>_toast</code> works in <code>onStart</code>, <code>onSuccess</code>, and <code>onError</code>, and also in mutations. The toast container is injected into <code>document.body</code> once and survives client-side navigations.</p>
161
- ${table(
162
- ['Option', 'Type', 'Default', 'Description'],
163
- [
164
- ['<code>message</code>', 'string', '—', 'Required. The notification text.'],
165
- ['<code>variant</code>', '<code>success | error | warning | info</code>', '<code>info</code>', ''],
166
- ['<code>duration</code>', 'number (ms)', '<code>4000</code>', 'Auto-dismiss delay. <code>0</code> = sticky until dismissed.'],
167
- ]
168
- )}
169
-
170
- ${section('store-update', 'Pushing to the global store')}
171
- <p>Return <code>_storeUpdate</code> from <code>onSuccess</code> to push a partial update into the <a href="/store">global store</a>. Every mounted page that subscribes to the updated keys re-renders immediately — no navigation, no polling.</p>
172
- ${codeBlock(highlight(`onSuccess: (state, theme) => ({
173
- saved: true,
174
- _storeUpdate: { settings: { theme } }, // ← merged into global store state
175
- }),`, 'js'))}
176
- <p><code>_storeUpdate</code> is stripped from the page's own state — only the rest of the return object is merged into local state as usual. See <a href="/store">Global Store</a> for the full store API.</p>
177
-
178
- ${section('rendering-errors', 'Rendering errors in the view')}
179
- ${codeBlock(highlight(`view: (state) => \`
180
- <form data-action="submit">
181
- \${state.errors.map(e => \`
182
- <p class="error">
183
- \${e.field ? \`<strong>\${e.field}:</strong> \` : ''}\${e.message}
184
- </p>
185
- \`).join('')}
186
- <!-- ... form fields ... -->
187
- <button \${state.status === 'loading' ? 'disabled' : ''}>
188
- \${state.status === 'loading' ? 'Sending…' : 'Send'}
189
- </button>
190
- </form>
191
- \``, 'js'))}
192
- `,
193
- }),
194
- }
@@ -1,176 +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('/auth')
6
-
7
- export default {
8
- route: '/auth',
9
- meta: {
10
- theme: 'light',
11
- title: 'Auth (Auth0) — Pulse Docs',
12
- description: 'Integrating Auth0 OAuth authentication with Pulse using guard functions, ctx.setCookie, and raw response specs.',
13
- styles: ['/theme.css', '/docs.css'],
14
- },
15
- view: () => renderLayout({
16
- currentHref: '/auth',
17
- prev,
18
- next,
19
- content: `
20
- ${h1('Auth (Auth0)')}
21
- ${lead('Pulse integrates with Auth0 — and any OAuth 2.0 provider — using plain HTTP redirects and a token exchange. No client-side auth SDK is required. Protected routes enforce access through <code>guard</code>, which runs before any data fetcher can execute.')}
22
-
23
- ${callout('info', 'No Auth0 SDK required. The OAuth flow is plain HTTP redirects and a token exchange fetch — no client-side library needed.')}
24
-
25
- ${section('setup', 'Setup')}
26
- <p>Register your application in Auth0 and note your credentials. Store them in environment variables — never hardcode them in specs.</p>
27
- ${codeBlock(highlight(`# .env
28
- AUTH0_DOMAIN=your-tenant.auth0.com
29
- AUTH0_CLIENT_ID=your_client_id
30
- AUTH0_CLIENT_SECRET=your_client_secret
31
- AUTH0_CALLBACK_URL=http://localhost:3000/auth/callback`, 'bash'))}
32
-
33
- ${section('login', 'Login route')}
34
- <p>The login route is a raw response spec that redirects the browser to Auth0's authorization endpoint.</p>
35
- ${codeBlock(highlight(`// src/pages/auth/login.js
36
- const {
37
- AUTH0_DOMAIN, AUTH0_CLIENT_ID, AUTH0_CALLBACK_URL
38
- } = process.env
39
-
40
- export default {
41
- route: '/auth/login',
42
- contentType: 'text/html',
43
-
44
- render: (ctx) => {
45
- const params = new URLSearchParams({
46
- response_type: 'code',
47
- client_id: AUTH0_CLIENT_ID,
48
- redirect_uri: AUTH0_CALLBACK_URL,
49
- scope: 'openid profile email',
50
- state: crypto.randomUUID(),
51
- })
52
- ctx.setHeader('Location', \`https://\${AUTH0_DOMAIN}/authorize?\${params}\`)
53
- return { redirect: \`https://\${AUTH0_DOMAIN}/authorize?\${params}\` }
54
- },
55
- }`, 'js'))}
56
-
57
- ${section('callback', 'Callback route')}
58
- <p>Auth0 redirects back to <code>/auth/callback</code> with a <code>code</code> query parameter. The server exchanges it for tokens, sets a session cookie, and redirects to the app.</p>
59
- ${codeBlock(highlight(`// src/pages/auth/callback.js
60
- const {
61
- AUTH0_DOMAIN, AUTH0_CLIENT_ID,
62
- AUTH0_CLIENT_SECRET, AUTH0_CALLBACK_URL
63
- } = process.env
64
-
65
- export default {
66
- route: '/auth/callback',
67
- contentType: 'text/html',
68
-
69
- server: {
70
- session: async (ctx) => {
71
- const { code } = ctx.query
72
- if (!code) return null
73
-
74
- // Exchange auth code for tokens
75
- const res = await fetch(\`https://\${AUTH0_DOMAIN}/oauth/token\`, {
76
- method: 'POST',
77
- headers: { 'Content-Type': 'application/json' },
78
- body: JSON.stringify({
79
- grant_type: 'authorization_code',
80
- client_id: AUTH0_CLIENT_ID,
81
- client_secret: AUTH0_CLIENT_SECRET,
82
- redirect_uri: AUTH0_CALLBACK_URL,
83
- code,
84
- }),
85
- })
86
-
87
- if (!res.ok) return null
88
- const { access_token, id_token } = await res.json()
89
-
90
- // Set a secure session cookie with the access token
91
- ctx.setCookie('session', access_token, {
92
- httpOnly: true,
93
- secure: process.env.NODE_ENV === 'production',
94
- sameSite: 'Lax',
95
- maxAge: 86400, // 24 hours
96
- })
97
-
98
- return access_token
99
- },
100
- },
101
-
102
- render: (ctx, server) => {
103
- if (!server.session) return { redirect: '/auth/login' }
104
- return { redirect: '/' }
105
- },
106
- }`, 'js'))}
107
-
108
- ${section('logout', 'Logout route')}
109
- <p>Clear the session cookie and redirect to Auth0's logout endpoint to invalidate the session there too.</p>
110
- ${codeBlock(highlight(`// src/pages/auth/logout.js
111
- const { AUTH0_DOMAIN, AUTH0_CLIENT_ID } = process.env
112
-
113
- export default {
114
- route: '/auth/logout',
115
- contentType: 'text/html',
116
-
117
- render: (ctx) => {
118
- // Expire the session cookie
119
- ctx.setCookie('session', '', { maxAge: 0 })
120
-
121
- const returnTo = encodeURIComponent('http://localhost:3000')
122
- return { redirect: \`https://\${AUTH0_DOMAIN}/v2/logout?client_id=\${AUTH0_CLIENT_ID}&returnTo=\${returnTo}\` }
123
- },
124
- }`, 'js'))}
125
-
126
- ${section('guard', 'Protecting routes')}
127
- <p>Use <code>guard</code> to verify the session token before any server data is fetched. For production, verify the JWT signature locally rather than calling Auth0 on every request.</p>
128
- ${codeBlock(highlight(`// src/pages/dashboard.js
129
- export default {
130
- route: '/dashboard',
131
-
132
- guard: async (ctx) => {
133
- if (!ctx.cookies.session) return { redirect: '/auth/login' }
134
-
135
- // Optional: verify JWT signature for production
136
- // const user = await verifyJwt(ctx.cookies.session)
137
- // if (!user) return { redirect: '/auth/login' }
138
- },
139
-
140
- server: {
141
- profile: async (ctx) => fetchUserProfile(ctx.cookies.session),
142
- },
143
-
144
- view: (state, server) => \`
145
- <main id="main-content">
146
- <h1>Welcome, \${server.profile.name}</h1>
147
- </main>
148
- \`,
149
- }`, 'js'))}
150
-
151
- ${callout('info', 'Guard runs before server data fetchers — if the session is invalid the profile fetch never happens.')}
152
-
153
- ${section('ctx-reference', 'ctx reference')}
154
- ${table(
155
- ['Method', 'Description'],
156
- [
157
- ['<code>ctx.cookies.session</code>', 'Read the session cookie set during OAuth callback'],
158
- ['<code>ctx.setCookie(name, value, opts)</code>', 'Set a response cookie — used in callback and logout routes'],
159
- ['<code>ctx.setHeader(name, value)</code>', 'Set an arbitrary response header'],
160
- ]
161
- )}
162
-
163
- ${table(
164
- ['setCookie option', 'Type', 'Description'],
165
- [
166
- ['<code>httpOnly</code>', 'boolean', 'Prevents JS access — always use for session cookies'],
167
- ['<code>secure</code>', 'boolean', 'HTTPS only — set <code>true</code> in production'],
168
- ['<code>sameSite</code>', '<code>"Lax" | "Strict" | "None"</code>', '<code>Lax</code> works for most OAuth flows'],
169
- ['<code>maxAge</code>', 'number', 'Lifetime in seconds — omit for session cookie'],
170
- ['<code>path</code>', 'string', 'Defaults to <code>/</code>'],
171
- ['<code>domain</code>', 'string', 'Scope to a domain — omit for current host'],
172
- ]
173
- )}
174
- `,
175
- }),
176
- }