@invisibleloop/pulse 0.20.11 → 0.20.20

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,189 +0,0 @@
1
- ## Spec review checklist
2
-
3
- Before finishing any spec, verify every point below. Fix anything that fails.
4
-
5
- ### Critical
6
-
7
- - **`meta` must be a plain object — never a function.** Individual fields (`title`, `description`, `styles`) can be `async (ctx) => value` functions, but `meta` itself is always `{}`.
8
-
9
- ```js
10
- // WRONG — meta is not a function factory
11
- meta: async (ctx) => ({ title: '...', styles: [...] })
12
-
13
- // CORRECT — meta is a plain object; individual fields are functions
14
- meta: {
15
- title: async (ctx) => (await post(ctx)).frontmatter.title,
16
- description: async (ctx) => (await post(ctx)).frontmatter.description,
17
- styles: ['/pulse-ui.css', '/app.css'],
18
- }
19
- ```
20
-
21
- - **Do not set `hydrate` in specs.** It is auto-derived by the framework from the URL entry passed to `createServer`. Specs with `mutations`, `actions`, or `persist` are hydrated automatically. Purely server-rendered specs get zero JavaScript — no configuration needed.
22
-
23
- - **The default theme is DARK.** When `meta.theme` is unset, the page renders with the dark palette. If the design calls for a light page, set `meta.theme: 'light'` when you **first write the spec** — do not assume light and discover dark at the screenshot, which costs an edit → restart → re-approval cycle. Declare the theme in your plan/build brief, and ask the user if the brief doesn't say.
24
-
25
- - **Always restart the server and reload the browser after every file edit — before checking the result.** The dev server restarts on file changes but the browser tab stays stale. The required sequence is: `pulse_restart_server` → `navigate_page`. Never attempt to debug a visual problem without doing both steps first.
26
-
27
- ### Components first — or creative override
28
-
29
- - **Before writing any HTML by hand, check `src/ui/index.js`.** There are 50+ components. Use `button`, `card`, `alert`, `input`, `spinner`, `badge`, `modal`, `nav`, `pagination`, `table`, etc. before writing equivalent HTML from scratch.
30
- - **Do not reinvent component patterns.** These patterns have dedicated components — never write custom HTML for them unless a creative override applies (see below):
31
- - Hero sections → `hero({ title, subtitle, actions, image, ... })`
32
- - Product/service cards → `card({ title, body, image, footer, ... })`
33
- - Image + text two-column layouts → `media({ image, content, reverse })`
34
- - Feature tiles → `feature({ icon, title, description })`
35
- - Testimonials → `card({ content, footer })` with quote + avatar/name in the content slot
36
- - CTAs → `cta({ title, subtitle, actions })`
37
- - Navigation → `nav({ logo, links, actions })`
38
- - Footers → `footer({ logo, links, columns, legal, ... })`
39
-
40
- - **Creative override — raw HTML throughout is permitted** when the design intent genuinely calls for it: a highly custom visual identity, an asymmetric or typographically-driven layout, a brutalist/editorial/retro/neon/paper vibe where components would constrain the expression, or a layout that requires features a component simply doesn't support (e.g. full-viewport height, custom gradient glows, clamp-scaled display type). This is a deliberate design decision, not a shortcut. When taking this path:
41
- - **Declare it in a comment at the top of the spec** so the reviewer recognises it: `// component-free — creative override: <reason>`. This is the signal the review tool reads.
42
- - Functional atoms (`button`, `input`, `badge`, `modal`) should still come from components unless there is a specific design reason not to.
43
- - `/pulse-ui.css` is always required — it provides the token system your custom CSS will use.
44
- - The quality gate replaces the component checklist: **Lighthouse 100 on Accessibility, Best Practices, and SEO — desktop and mobile — plus CLS 0.00 is the pass bar.** (Performance is reported but not gated; it varies with machine load.) A component-free page that passes every audit is correct. A component-heavy page that fails accessibility is not.
45
-
46
- - **When reviewing**: before flagging a class name like `.hero` as a violation, first ask whether the `hero()` component could actually reproduce the layout. If the answer is no, it is a creative override — check Lighthouse instead of refactoring. Do not flatten a good custom layout into a generic component just to clear a pattern-match.
47
-
48
- - **The review tool auto-detects creative override** from the spec comment. When detected, component pattern checks are shown as advisory (`⚡`) not as failures, and the Lighthouse gate is the stated pass bar.
49
-
50
- - **Never write App Store or Google Play download buttons by hand.** Always use `appBadge({ store: 'apple', href })` and `appBadge({ store: 'google', href })`. Raw `<a>` tags with badge images are incorrect — `appBadge` renders the correct accessible, styled badge for each store.
51
-
52
- ```js
53
- import { appBadge } from '@invisibleloop/pulse/ui'
54
-
55
- // In a hero() actions slot:
56
- ${hero({
57
- actions: `
58
- ${appBadge({ store: 'apple', href: 'https://apps.apple.com/...' })}
59
- ${appBadge({ store: 'google', href: 'https://play.google.com/...' })}
60
- `,
61
- })}
62
- ```
63
-
64
- ### Reuse (DRY)
65
-
66
- - **Extract a view helper when the same HTML pattern appears 3 or more times in a single spec.** A plain JS function returning an HTML string is sufficient — no framework needed:
67
- ```js
68
- const card = ({ title, body }) => `<div class="card"><h3>${title}</h3><p>${body}</p></div>`
69
- ```
70
- - **Create a shared component in `src/components/` when the same pattern is needed across 2 or more different specs.** A named export that returns an HTML string — same shape as the `layout()` pattern in the routing guide. Never paste a section's HTML into a second spec: extract it at the second use, then import it from both. (`src/ui/` is the framework's own library inside the package — project-level shared code always goes in `src/components/`.)
71
- - **Do not abstract a pattern that appears only once.** Duplication is cheaper than the wrong abstraction. Within a single spec, wait until the third use before extracting a helper; across specs, extract at the second use.
72
-
73
- ### Correctness
74
-
75
- - Mutations return plain partial-state objects and have no side effects (no fetch, no DOM access).
76
- - `persist` contains only serialisable state that should survive a page reload — not ephemeral UI state like a loading flag or temporary selection.
77
- - `e.target` assumptions in mutations are safe if the element has child nodes — use `e.target.closest('[data-index]')` rather than assuming `e.target` is the element with the attribute.
78
- - State shape is consistent — avoid a single field that is sometimes `null`, sometimes a string, sometimes a boolean. Use a dedicated `status` field instead.
79
- - **Never use `state.modalOpen` or conditional modal rendering.** This destroys the `<dialog>` on every render, breaking focus, animation, and native ESC handling. Instead, always render the `<dialog>` in the DOM and use `data-dialog-open="id"` to open it — the runtime handles this without any spec state:
80
- ```html
81
- <!-- always in the view, never conditional -->
82
- ${modal({ id: 'confirm', title: 'Confirm', content: '...' })}
83
- <!-- anywhere on the page — opens the dialog, no mutation needed -->
84
- ${modalTrigger({ target: 'confirm', label: 'Open' })}
85
- <!-- or inline: -->
86
- <button data-dialog-open="confirm">Open</button>
87
- ```
88
- Close is handled natively by `<form method="dialog">` (inside the modal), ESC key, backdrop click, or `data-dialog-close` on any element.
89
-
90
- ### Defensive data handling
91
-
92
- - **Always check `res.ok` before parsing fetch responses.** Never call `res.json()` on a response that may have failed. Never use `await res.text()` as an error message — on a 404 or 500, this returns raw HTML which surfaces directly in toasts and error alerts. The correct pattern:
93
- ```js
94
- const res = await fetch('/api/...')
95
- if (!res.ok) {
96
- let message = `Request failed: ${res.status}`
97
- try { const j = await res.json(); message = j.message || j.error || message } catch {}
98
- throw new Error(message)
99
- }
100
- return await res.json()
101
- ```
102
- - **Never assume the shape of data from external APIs or server fetchers.** Use optional chaining (`?.`) and nullish coalescing (`??`) at every access point. If a server fetcher can return `null`, the view must handle it — `server.user?.name ?? 'Guest'` not `server.user.name`.
103
- - **Validate FormData fields before use.** `formData.get('email')` returns `null` if the field is missing. Check for null/empty before passing to an API or database.
104
- - **Do not trust URL params.** `ctx.params.id` is a raw string from the URL. Validate it before use — check it exists, is the right type, and refers to a real resource. Return a 404 or redirect if it doesn't.
105
-
106
- ### CSS
107
-
108
- - **`app.css` must contain no hex values or raw colour values.** A lint hook enforces this and will block the build. Hex values belong in `public/theme.css` as token definitions; `app.css` references them via `var()` only.
109
- - **Load order in `meta.styles`:** `pulse-ui.css` → `theme.css` → `app.css`. Theme tokens must be defined before `app.css` references them.
110
- - **CSS for a shared section lives in the shared `app.css`, once.** Every spec that renders the section lists the same file in `meta.styles`. Never copy a CSS block into a second per-page stylesheet — two copies drift apart. Per-page stylesheets are for styles only one page uses.
111
-
112
- ### Security
113
-
114
- - Any value from user input (URL params, form fields, external APIs) interpolated into view HTML must be escaped.
115
- - **Every `<form method="POST">` on a `submit` page includes `${server.csrf}`.** CSRF is enforced — a form without the hidden token field gets 403 on submit. Never set `csrf: false` to "fix" a 403; that disables the protection. `csrf: false` is only for endpoints with their own authentication (e.g. signed webhooks).
116
- - **`submit` returns `{ redirect }` after a successful mutation** (POST-redirect-GET). Rendering success directly from the POST means browser refresh resubmits the form.
117
-
118
- ### Server-side forms & error pages
119
-
120
- - **Forms that must work without JavaScript use `spec.submit`** + `<form method="POST">` with `${server.csrf}` inside. Hydrated `data-action` on the same form is the enhancement, not the requirement.
121
- - On a validation re-render, show `server.form?.errors` and echo `server.form?.values` back into the inputs via `value=` — failed validation must not wipe what the user typed.
122
- - **Every site has a custom 404 page** — a spec with `route: '*'`, using the shared `layout()` so it carries the site's nav and footer. Without it, bad URLs show the framework's unbranded default.
123
-
124
- ### Tests
125
-
126
- - Pure logic functions extracted from a spec (e.g. `checkResult`, `validate`, `formatPrice`) must have unit tests in a corresponding `.test.js` file.
127
- - **When fixing a bug, write a failing test first.** The test must reproduce the bug before the fix is applied, then pass after. This pins the behaviour so the bug cannot silently return. A fix without a regression test is incomplete.
128
- - **Use `renderSync` / `render` from `@invisibleloop/pulse/testing` to test view HTML output.** Do not test views with raw `html.includes()` — use the query helpers instead:
129
- ```js
130
- import { renderSync, render } from '@invisibleloop/pulse/testing'
131
-
132
- // Sync — call view directly with mock state/server
133
- const result = renderSync(mySpec, { state: { count: 5 }, server: { items: [] } })
134
- assert(result.has('button'))
135
- assert.equal(result.get('#count').text, '5')
136
- assert.equal(result.count('li'), 0)
137
-
138
- // Async — run real server fetchers (integration), or pass server to skip them
139
- const result = await render(mySpec, { server: { product: mockProduct } })
140
- assert.equal(result.get('h1').text, mockProduct.name)
141
- assert.equal(result.attr('img', 'src'), mockProduct.image)
142
- ```
143
- Supported selectors: `tag`, `.class`, `#id`, `[attr]`, `[attr="value"]`, combinations (`button.primary[disabled]`), and **descendant combinators** (`result.count('.parent li')`, `result.find('tbody tr')`). Descendant selectors match the rightmost token within the ancestor context — they do not return 0 if the ancestor doesn't exist, they simply find no matching descendants.
144
-
145
- ### Markdown
146
-
147
- - **Use `md()` from `@invisibleloop/pulse/md` for `.md` file content.** Never read and parse markdown manually. Pass the result's `html` to `prose()`.
148
- - **Call the same `md()` fetcher in both `meta` and `server`** — it caches on `ctx._mdCache` so the file is only read once per request. Do not create two separate fetchers for the same file.
149
- - **Always add `onViewError` on dynamic markdown routes** (routes with `:param` segments loading `.md` files). A missing file throws `{ status: 404 }` which must be caught gracefully.
150
-
151
- ### View error handling
152
-
153
- - **Define `onViewError` on any page where the view could throw due to bad or missing data.** Without it, a runtime view error returns a 500 on the server and shows a generic inline message on the client. With it, the server returns 200 with your fallback HTML instead of a 500, and the client renders your fallback:
154
- ```js
155
- onViewError: (err, state, serverState) => `
156
- <div class="u-p-4 u-text-center">
157
- <p>Something went wrong. <a href="">Reload</a></p>
158
- </div>
159
- `
160
- ```
161
- Use this on pages that render data from external APIs or user-supplied content — any path where the view can encounter `null`, `undefined`, or unexpected shapes that would cause a crash. It is not required on simple pages with predictable data.
162
-
163
- ### Store updates from actions
164
-
165
- - **Use `_storeUpdate` to push changes to the global store from an action.** Return it from `onSuccess` alongside the local state update — it is stripped from page state and forwarded to the store. All mounted pages that subscribe to the affected keys re-render immediately:
166
- ```js
167
- onSuccess: (state, theme) => ({
168
- saved: true,
169
- _storeUpdate: { settings: { theme } }, // ← merged into store state
170
- }),
171
- ```
172
- `_storeUpdate` only merges into the store — it does not appear in the page's own state. The rest of the return is merged into local state as normal. Use this instead of a full-page reload when a user action changes shared data (theme, cart count, user profile, etc.).
173
-
174
- ### Accessibility
175
-
176
- - Interactive elements without visible text have an `aria-label`.
177
- - Disabled state is reflected with the `disabled` attribute, not just CSS.
178
- - The page has a `<main id="main-content">` landmark.
179
- - **Every element with `data-event`, `data-store-event`, `data-dialog-open`, or `data-dialog-close` is natively interactive (`button`, `a`, `input`, `select`, `textarea`, `summary`) or has `tabindex="0"`.** Non-interactive tags (`div`, `span`, `li`, `td`, etc.) with these attributes are not keyboard reachable. Prefer `<button>` — it is focusable, fires on Enter/Space, and carries the correct ARIA role for free. Only use `tabindex="0"` on a non-interactive element when a `<button>` genuinely cannot be used.
180
- - **Every interactive element communicates its purpose and state:**
181
- - Buttons have visible text or `aria-label`. Links use descriptive text — "click here", "here", "read more" are failures.
182
- - Form inputs have an associated `<label for="id">` or `aria-label`. `placeholder` alone is not a label.
183
- - Toggle controls (open/close, show/hide, expand/collapse) carry `aria-expanded="true|false"` or `aria-pressed="true|false"`.
184
- - While an action is in flight, the trigger button has `aria-busy="true"` or its label changes (e.g. "Saving…"). A spinner alone is not enough.
185
- - Active nav items have `aria-current="page"`. Selected items in a list, tab set, or option group have `aria-selected="true"`.
186
- - **The page has a logical tab order:**
187
- - No `tabindex` value greater than 0. Positive tabindex values override the natural DOM order and create a broken tab sequence. Remove them and reorder the DOM instead.
188
- - Visually hidden interactive content (off-canvas menus, collapsed panels, `opacity:0` elements) is removed from the tab order with `tabindex="-1"` or the `inert` attribute — CSS alone does not remove an element from tab order.
189
- - DOM order matches the visual reading order. When flexbox `order` or CSS grid placement visually reposition elements, the tab sequence still follows the DOM — author the DOM in the order a user would read and interact with the page.
@@ -1,237 +0,0 @@
1
- /*
2
- * andypixel.css — Andy Pixel blog article example
3
- *
4
- * Token definitions (hex values) + layout styles (var() only).
5
- * Orange accent palette — editorial light theme.
6
- */
7
-
8
- /* ─── Token definitions ──────────────────────────────────────── */
9
-
10
- [data-theme="light"] {
11
- --accent: #f97316;
12
- --accent-hover: #ea6c0a;
13
- --accent-dim: #fff3e8;
14
- --surface-2: #f0f0f0;
15
- --border: #e5e5e5;
16
- --muted-bg: #f5f5f5;
17
-
18
- /* Code block dark-shell tokens — defined here so app.css stays hex-free */
19
- --ap-code-shell: #1e1e1e;
20
- --ap-code-text: #d4d4d4;
21
- --ap-btn-dark: #1a1a1a;
22
- --ap-btn-dark-hover: #333333;
23
- }
24
-
25
- /* ─── Base ───────────────────────────────────────────────────── */
26
-
27
- *, *::before, *::after { box-sizing: border-box; margin: 0; padding: 0; }
28
-
29
- body {
30
- font-family: var(--ui-font, system-ui, sans-serif);
31
- line-height: 1.6;
32
- background: var(--ui-bg);
33
- color: var(--ui-text);
34
- }
35
-
36
- /* ─── Contrast fixes ─────────────────────────────────────────── */
37
-
38
- /* Badge: muted on surface-2 fails WCAG AA at small size */
39
- .ui-badge--default {
40
- color: var(--ui-text);
41
- }
42
-
43
- /* Inline code: accent on surface-2 fails WCAG AA */
44
- .ui-prose code {
45
- color: var(--ui-text);
46
- }
47
-
48
- /* ─── Reading column ─────────────────────────────────────────── */
49
-
50
- .ap-reading-col {
51
- max-width: 72ch;
52
- margin-left: auto;
53
- margin-right: auto;
54
- padding-left: var(--ui-space-6);
55
- padding-right: var(--ui-space-6);
56
- }
57
-
58
- /* ─── Article header ─────────────────────────────────────────── */
59
-
60
- .ap-article-header {
61
- padding: var(--ui-space-12) 0 var(--ui-space-8);
62
- border-bottom: 3px solid var(--ui-accent);
63
- margin-bottom: var(--ui-space-10);
64
- }
65
-
66
- .ap-article-meta {
67
- display: flex;
68
- align-items: center;
69
- gap: var(--ui-space-2);
70
- flex-wrap: wrap;
71
- margin-bottom: var(--ui-space-5);
72
- }
73
-
74
- .ap-article-title {
75
- font-family: var(--ui-font-display, system-ui, serif);
76
- font-size: clamp(2rem, 5vw, 3.25rem);
77
- font-weight: 800;
78
- line-height: 1.1;
79
- letter-spacing: -0.02em;
80
- color: var(--ui-text);
81
- margin-bottom: var(--ui-space-4);
82
- }
83
-
84
- .ap-article-subtitle {
85
- font-size: var(--ui-text-xl);
86
- color: var(--ui-muted);
87
- line-height: 1.55;
88
- margin-bottom: var(--ui-space-6);
89
- max-width: 56ch;
90
- }
91
-
92
- .ap-article-author-strip {
93
- display: flex;
94
- align-items: center;
95
- gap: var(--ui-space-3);
96
- }
97
-
98
- /* ─── Article body ───────────────────────────────────────────── */
99
-
100
- .ap-article-body {
101
- padding-bottom: var(--ui-space-12);
102
- }
103
-
104
- .ap-article-lead {
105
- font-size: var(--ui-text-xl) !important;
106
- line-height: 1.65 !important;
107
- color: var(--ui-text) !important;
108
- }
109
-
110
- /* ─── Code block ─────────────────────────────────────────────── */
111
-
112
- .ap-code-block {
113
- margin: var(--ui-space-6) 0;
114
- border-radius: var(--ui-radius);
115
- overflow: hidden;
116
- border: 1px solid var(--ui-border);
117
- }
118
-
119
- .ap-code-block__header {
120
- display: flex;
121
- align-items: center;
122
- gap: var(--ui-space-2);
123
- padding: var(--ui-space-2) var(--ui-space-4);
124
- background: var(--ui-text);
125
- color: var(--ui-accent);
126
- font-family: var(--ui-mono, monospace);
127
- font-size: var(--ui-text-sm);
128
- }
129
-
130
- .ap-code-block__pre {
131
- background: var(--ap-code-shell);
132
- padding: var(--ui-space-5) var(--ui-space-6);
133
- overflow-x: auto;
134
- margin: 0;
135
- }
136
-
137
- .ap-code-block__code {
138
- font-family: var(--ui-mono, monospace);
139
- font-size: var(--ui-text-sm);
140
- line-height: 1.75;
141
- color: var(--ap-code-text);
142
- }
143
-
144
- /* ─── Pullquote ──────────────────────────────────────────────── */
145
-
146
- .ap-pullquote {
147
- margin: var(--ui-space-8) 0;
148
- padding: var(--ui-space-5) var(--ui-space-6);
149
- border-left: 4px solid var(--ui-accent);
150
- background: var(--accent-dim);
151
- border-radius: 0 var(--ui-radius) var(--ui-radius) 0;
152
- }
153
-
154
- .ap-pullquote p {
155
- font-family: var(--ui-font-display, system-ui, serif);
156
- font-size: var(--ui-text-xl);
157
- font-style: italic;
158
- color: var(--ui-text);
159
- margin: 0;
160
- line-height: 1.55;
161
- }
162
-
163
- /* ─── Author bio ─────────────────────────────────────────────── */
164
-
165
- .ap-author-bio-wrap {
166
- border-top: 1px solid var(--ui-border);
167
- border-bottom: 1px solid var(--ui-border);
168
- padding: var(--ui-space-8) 0;
169
- margin-bottom: var(--ui-space-10);
170
- }
171
-
172
- .ap-author-bio {
173
- display: flex;
174
- gap: var(--ui-space-4);
175
- align-items: flex-start;
176
- }
177
-
178
- .ap-author-bio__name {
179
- font-weight: 700;
180
- font-size: var(--ui-text-base);
181
- margin-bottom: var(--ui-space-1);
182
- }
183
-
184
- /* ─── Newsletter ─────────────────────────────────────────────── */
185
-
186
- .ap-newsletter-section {
187
- max-width: 540px;
188
- margin: 0 auto;
189
- text-align: center;
190
- padding: var(--ui-space-12) var(--ui-space-6);
191
- border-top: 3px solid var(--ui-accent);
192
- }
193
-
194
- .ap-newsletter-eyebrow {
195
- font-family: var(--ui-mono, monospace);
196
- font-size: var(--ui-text-sm);
197
- text-transform: uppercase;
198
- letter-spacing: 0.1em;
199
- color: var(--ui-text);
200
- margin-bottom: var(--ui-space-2);
201
- }
202
-
203
- .ap-newsletter-title {
204
- font-family: var(--ui-font-display, system-ui, serif);
205
- font-size: clamp(1.5rem, 4vw, 2.25rem);
206
- font-weight: 800;
207
- letter-spacing: -0.02em;
208
- color: var(--ui-text);
209
- margin-bottom: var(--ui-space-3);
210
- line-height: 1.15;
211
- }
212
-
213
- .ap-newsletter-form {
214
- display: flex;
215
- gap: var(--ui-space-2);
216
- margin-top: var(--ui-space-6);
217
- flex-wrap: wrap;
218
- justify-content: center;
219
- }
220
-
221
- /* Orange bg on small text fails WCAG AA — use near-black for contrast (15:1) */
222
- .ap-newsletter-form .ui-btn--primary {
223
- background: var(--ap-btn-dark);
224
- }
225
- .ap-newsletter-form .ui-btn--primary:hover {
226
- background: var(--ap-btn-dark-hover);
227
- }
228
-
229
- .ap-newsletter-error {
230
- color: var(--ui-error, #dc2626);
231
- margin-top: var(--ui-space-3);
232
- }
233
-
234
- .ap-newsletter-success {
235
- padding: var(--ui-space-12) var(--ui-space-6);
236
- text-align: center;
237
- }