@invisibleloop/pulse 0.17.1 → 0.17.3

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 (43) hide show
  1. package/.claude/commands/build-page.md +1 -1
  2. package/.claude/commands/verify.md +6 -4
  3. package/.claude/pulse-checklist.md +10 -0
  4. package/CLAUDE.md +56 -2
  5. package/docs/.claude/pulse-checklist.md +10 -0
  6. package/docs/public/pulse-ui.css +8 -2
  7. package/docs/server.js +2 -0
  8. package/docs/src/lib/nav.js +2 -0
  9. package/docs/src/pages/actions.js +2 -0
  10. package/docs/src/pages/error-pages.js +98 -0
  11. package/docs/src/pages/forms.js +128 -0
  12. package/docs/src/pages/guard.js +1 -0
  13. package/docs/src/pages/routing.js +7 -9
  14. package/docs/src/pages/server-api.js +2 -1
  15. package/examples/.claude/pulse-checklist.md +10 -0
  16. package/examples/public/pulse-ui.css +52 -2
  17. package/package.json +2 -2
  18. package/public/.pulse-ui-version +1 -1
  19. package/public/pulse-ui.css +8 -2
  20. package/scripts/build.js +1 -204
  21. package/scripts/css-purge.js +241 -0
  22. package/scripts/css-purge.test.js +94 -0
  23. package/src/agent/checklist.md +10 -0
  24. package/src/agent/commands/verify.md +6 -4
  25. package/src/agent/guide-routing.md +21 -0
  26. package/src/agent/guide-server.md +34 -0
  27. package/src/agent/guide-spec.md +25 -0
  28. package/src/agent/guide-styles.md +3 -1
  29. package/src/agent/skills/build-page/SKILL.md +1 -1
  30. package/src/agent/skills/verify/SKILL.md +2 -2
  31. package/src/agent/workflow.md +15 -3
  32. package/src/mcp/mcp.test.js +55 -1
  33. package/src/mcp/server.js +82 -5
  34. package/src/runtime/ssr.js +30 -7
  35. package/src/server/index.js +119 -16
  36. package/src/server/server.test.js +200 -0
  37. package/src/spec/schema.js +20 -3
  38. package/src/spec/schema.test.js +33 -0
  39. package/src/ui/drop-cap.js +1 -1
  40. package/src/ui/gallery.js +5 -1
  41. package/src/ui/ui.test.js +34 -0
  42. package/types/schema.d.ts +31 -2
  43. package/types/server.d.ts +7 -0
@@ -50,7 +50,7 @@ Check for:
50
50
  - Layout looks correct
51
51
  - No console errors via `mcp__chrome-devtools__list_console_messages`
52
52
 
53
- Then ask the user: "Happy with the design and layout, or would you like any changes before I run tests and Lighthouse?" — if your host provides a question tool (e.g. AskUserQuestion), offer the choices `["Yes, looks good — run tests and Lighthouse", "I'd like some changes first"]`; it waits for the answer without ending the turn. If you must ask in plain prose and end your turn, call `pulse_await_approval` first so the Stop hooks let the turn end. If a `VERIFY REQUIRED` stop-hook block fires while your question is unanswered, that is not permission to continue — call `pulse_await_approval`, re-ask, and end your turn.
53
+ Then **call `pulse_await_approval`** and ask the user: "Happy with the design and layout, or would you like any changes before I run tests and Lighthouse?" — if your host provides a question tool (e.g. AskUserQuestion), offer the choices `["Yes, looks good — run tests and Lighthouse", "I'd like some changes first"]`; otherwise ask in plain prose. Always call the tool before asking, whichever way you ask: in some hosts (including Claude Code) even a question tool ends the turn and fires the Stop hooks; the marker is harmless if the turn doesn't end. If a `VERIFY REQUIRED` stop-hook block fires while your question is unanswered, that is not permission to continue — call `pulse_await_approval`, re-ask, and end your turn.
54
54
 
55
55
  **Do not proceed to step 7 until the user explicitly confirms.** If they want changes, make them and return to step 6.
56
56
 
@@ -15,7 +15,7 @@ If $ARGUMENTS is provided, use it as the file path or route. Otherwise, identify
15
15
  If this is a new page build (came through `pulse_intake` or `build-page`):
16
16
 
17
17
  1. Take a screenshot and describe what you see to the user.
18
- 2. Ask the user: "Happy with the design and layout, or would you like any changes before I run Lighthouse?" — if your host provides a question tool (e.g. AskUserQuestion), offer the choices `["Yes, looks good — proceed", "I'd like some changes first"]`; it waits for the answer without ending the turn. If you must ask in plain prose and end your turn, call `pulse_await_approval` first so the Stop hooks let the turn end.
18
+ 2. **Call `pulse_await_approval` first**, then ask the user: "Happy with the design and layout, or would you like any changes before I run Lighthouse?" — if your host provides a question tool (e.g. AskUserQuestion), offer the choices `["Yes, looks good — proceed", "I'd like some changes first"]`; otherwise ask in plain prose. Always call the tool before asking, whichever way you ask: in some hosts (including Claude Code) even a question tool ends the turn and fires the Stop hooks. The marker is harmless if the turn doesn't end — it is consumed when the user replies.
19
19
  3. **Do not proceed to step 3 until the user explicitly confirms.** If they want changes, stop here, make the changes, then restart from step 2. Lighthouse takes ~90 seconds — do not waste it on a design the user hasn't approved. If a `VERIFY REQUIRED` stop-hook block fires while your question is unanswered, that is not permission to continue — call `pulse_await_approval`, re-ask, and end your turn.
20
20
 
21
21
  ### 3. Validate the spec
@@ -46,7 +46,7 @@ The browser should still be on `http://localhost:3001/` from step 6. Run `mcp__c
46
46
 
47
47
  **Same pass bar: Accessibility, Best Practices, and SEO must all be 100.** Fix any failures and restart from step 3.
48
48
 
49
- After both Lighthouse runs pass, call `pulse_restart_server` to return to the dev server.
49
+ **Stay on the production server (port 3001)** — steps 8 and 9 use it too. Do not call `pulse_restart_server` yet.
50
50
 
51
51
  ### 8. Mobile layout check
52
52
 
@@ -54,7 +54,7 @@ Emulate a mobile viewport and take a screenshot to catch wrapping, overflow, and
54
54
 
55
55
  ```
56
56
  mcp__chrome-devtools__emulate viewport: "390x844x2,mobile,touch"
57
- mcp__chrome-devtools__navigate_page url: "http://localhost:3000/"
57
+ mcp__chrome-devtools__navigate_page url: "http://localhost:3001/"
58
58
  mcp__chrome-devtools__take_screenshot
59
59
  ```
60
60
 
@@ -72,7 +72,9 @@ mcp__chrome-devtools__emulate viewport: "1440x900x1"
72
72
 
73
73
  ### 9. Performance
74
74
 
75
- 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.).
75
+ **Run the trace against the production server, not dev.** Navigate to `http://localhost:3001/<route>` first — if the production server is no longer running, run `pulse_build` again. Tracing the dev server (port 3000) reports misleading LCP/CLS. 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.).
76
+
77
+ After the trace, call `pulse_restart_server` to return to the dev server.
76
78
 
77
79
  ### 10. Console errors
78
80
 
@@ -20,6 +20,8 @@ Before finishing any spec, verify every point below. Fix anything that fails.
20
20
 
21
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
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
+
23
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.
24
26
 
25
27
  ### Components first — or creative override
@@ -111,6 +113,14 @@ Before finishing any spec, verify every point below. Fix anything that fails.
111
113
  ### Security
112
114
 
113
115
  - Any value from user input (URL params, form fields, external APIs) interpolated into view HTML must be escaped.
116
+ - **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).
117
+ - **`submit` returns `{ redirect }` after a successful mutation** (POST-redirect-GET). Rendering success directly from the POST means browser refresh resubmits the form.
118
+
119
+ ### Server-side forms & error pages
120
+
121
+ - **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.
122
+ - 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.
123
+ - **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.
114
124
 
115
125
  ### Tests
116
126
 
package/CLAUDE.md CHANGED
@@ -41,12 +41,15 @@ A spec is a plain JS object. Every property is optional except `route` and `view
41
41
 
42
42
  ```js
43
43
  export const mySpec = {
44
- route: '/path', // URL pattern, supports :params
44
+ route: '/path', // URL pattern, supports :params. Use '*' for the custom
45
+ // not-found page — rendered with status 404 when no route matches
45
46
 
46
47
  meta: {
47
48
  title: 'Page Title',
48
49
  description: 'Meta description',
49
50
  styles: ['/pulse.css'],
51
+ theme: 'light', // 'light' | 'dark' — ⚠ default is DARK when unset; decide at write time
52
+ vibe: 'minimal', // optional preset: radius, weight, spacing
50
53
  ogTitle: '...',
51
54
  ogImage: '...',
52
55
  },
@@ -119,6 +122,19 @@ export const mySpec = {
119
122
  }
120
123
  },
121
124
 
125
+ // Server-side form handling — POST to this route, no client JS required.
126
+ // CSRF protection is automatic: include ${server.csrf} inside the <form>.
127
+ // { redirect: '/path' } → 303 POST-redirect-GET
128
+ // anything else → page re-renders; return value appears as server.form
129
+ // (optional status field sets the response status, e.g. 422)
130
+ submit: async (ctx) => {
131
+ const data = await ctx.formData()
132
+ if (!data?.email) return { errors: { email: 'Required' }, values: data ?? {} }
133
+ await saveSignup(data)
134
+ return { redirect: '/thanks' }
135
+ },
136
+ // csrf: false, // opt out ONLY for endpoints with their own auth (e.g. signed webhooks)
137
+
122
138
  // Streaming SSR — split view into shell (instant) + deferred segments
123
139
  stream: {
124
140
  shell: ['header', 'nav'],
@@ -139,6 +155,42 @@ export const mySpec = {
139
155
  export default mySpec // required for hydration imports
140
156
  ```
141
157
 
158
+ ## Server-Side Forms (no client JS)
159
+
160
+ A spec with `submit` accepts POST on its route — the form works with JavaScript disabled. **Always include `${server.csrf}` inside the form** (CSRF is enforced; POSTs without the token get 403):
161
+
162
+ ```js
163
+ view: (state, server) => `
164
+ <main id="main-content">
165
+ ${server.form?.errors?.email ? alert({ variant: 'error', message: server.form.errors.email }) : ''}
166
+ <form method="POST">
167
+ ${server.csrf}
168
+ ${input({ label: 'Email', name: 'email', type: 'email', required: true, value: server.form?.values?.email ?? '' })}
169
+ ${button({ label: 'Sign up', type: 'submit' })}
170
+ </form>
171
+ </main>`,
172
+ ```
173
+
174
+ - `server.form` is the return value of `submit()` on a re-render (validation errors, submitted values) — `undefined` on plain GET.
175
+ - **Progressive enhancement:** the same form can also carry `data-action="signup"` — hydrated visitors get the async action (no page reload), no-JS visitors fall back to the POST. Echo submitted values back into inputs via `value=` so a failed validation doesn't wipe the form.
176
+ - Always `return { redirect }` after a successful mutation (POST-redirect-GET) — never render success directly from the POST, or refresh resubmits the form.
177
+ - Multi-instance deployments: set a stable `secret` in `createServer` so CSRF tokens validate across instances.
178
+
179
+ ## Custom 404 Page
180
+
181
+ Create a spec with `route: '*'` — it renders through the normal pipeline (layout, styles, hydration, validation) with status 404 whenever no route matches:
182
+
183
+ ```js
184
+ // src/pages/not-found.js
185
+ export default {
186
+ route: '*',
187
+ meta: { title: 'Page not found', styles: ['/pulse-ui.css', '/theme.css', '/app.css'] },
188
+ view: () => `<main id="main-content"><h1>Page not found</h1><p><a href="/">Back home</a></p></main>`,
189
+ }
190
+ ```
191
+
192
+ Without a `'*'` spec, the framework's plain default 404 is served. 500s are customised via `createServer`'s `onError` option.
193
+
142
194
  ## HTML Event Binding
143
195
 
144
196
  Pulse binds to DOM attributes — no JSX, no templates.
@@ -228,6 +280,8 @@ await createServer(
228
280
  // spec.serverTimeout overrides per-page
229
281
  shutdownTimeout: 30000, // ms to wait for in-flight requests before force-exit on SIGTERM/SIGINT
230
282
  healthCheck: '/healthz', // built-in health endpoint path, or false to disable
283
+ secret: process.env.PULSE_SECRET, // HMAC secret for CSRF tokens — set a stable value
284
+ // when running multiple instances (default: random per boot)
231
285
  csp: { // extra sources merged into the framework's default CSP
232
286
  'style-src': ['https://fonts.googleapis.com'],
233
287
  'font-src': ['https://fonts.gstatic.com'],
@@ -468,7 +522,7 @@ For any **new page, landing page, or branded site**, run this sequence first. Do
468
522
  3. pulse_intent(description) → archetype + scaffold + guide list
469
523
  ```
470
524
 
471
- **Before calling `pulse_intake`**, gather the required information by asking the user — one question at a time, in plain prose, no bullet lists. You need at minimum: the product name, what it does (pitch), and its key features. **Always ask first whether they have design inspiration** (a site they love, a screenshot, a mood board — dropped into `public/intake/` or pasted as a URL), and check `public/intake/` for images already there. Then ask for palette, vibe, and anti-style if not mentioned.
525
+ **Before calling `pulse_intake`**, gather the required information by asking the user — one question at a time, in plain prose, no bullet lists. You need at minimum: the product name, what it does (pitch), and its key features. **Always ask first whether they have design inspiration** (a site they love, a screenshot, a mood board — dropped into `public/intake/` or pasted as a URL), and check `public/intake/` for images already there. Then ask for palette, **theme (light or dark — Pulse defaults to dark when unset)**, vibe, and anti-style if not mentioned.
472
526
 
473
527
  **`pulse_sketch` is mandatory** for `playful`, `bold`, `brutalist`, `retro`, or `neon` vibes — those vibes fight the default centred-hero layout. Skip it only for `corporate` or `minimal` when no structural preference has been stated.
474
528
 
@@ -20,6 +20,8 @@ Before finishing any spec, verify every point below. Fix anything that fails.
20
20
 
21
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
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
+
23
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.
24
26
 
25
27
  ### Components first — or creative override
@@ -111,6 +113,14 @@ Before finishing any spec, verify every point below. Fix anything that fails.
111
113
  ### Security
112
114
 
113
115
  - Any value from user input (URL params, form fields, external APIs) interpolated into view HTML must be escaped.
116
+ - **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).
117
+ - **`submit` returns `{ redirect }` after a successful mutation** (POST-redirect-GET). Rendering success directly from the POST means browser refresh resubmits the form.
118
+
119
+ ### Server-side forms & error pages
120
+
121
+ - **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.
122
+ - 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.
123
+ - **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.
114
124
 
115
125
  ### Tests
116
126
 
@@ -4031,8 +4031,14 @@ fieldset[disabled] .ui-rating-stars .ui-rating-star:hover ~ .ui-rating-star { co
4031
4031
  }
4032
4032
 
4033
4033
  .ui-drop-cap-letter {
4034
- font-weight: 700;
4035
- color: var(--ui-text);
4034
+ /* line-height ~cap height keeps the float box hugging the glyph so the cap
4035
+ top-aligns with the first text line — a relative line-height makes serif
4036
+ caps float below the opening line. Component sets font-size inline. */
4037
+ float: left;
4038
+ font-weight: 700;
4039
+ line-height: 0.82;
4040
+ margin-right: 0.1em;
4041
+ color: var(--ui-text);
4036
4042
  }
4037
4043
 
4038
4044
  /* Colophon — minimal footer for editorial pages */
package/docs/server.js CHANGED
@@ -45,9 +45,11 @@ await createServer(
45
45
  new URL('./src/pages/markdown.js', import.meta.url),
46
46
  new URL('./src/pages/store.js', import.meta.url),
47
47
  new URL('./src/pages/routing.js', import.meta.url),
48
+ new URL('./src/pages/error-pages.js', import.meta.url),
48
49
  new URL('./src/pages/streaming.js', import.meta.url),
49
50
  new URL('./src/pages/caching.js', import.meta.url),
50
51
  new URL('./src/pages/guard.js', import.meta.url),
52
+ new URL('./src/pages/forms.js', import.meta.url),
51
53
  new URL('./src/pages/raw-responses.js', import.meta.url),
52
54
  new URL('./src/pages/server-api.js', import.meta.url),
53
55
  new URL('./src/pages/extending.js', import.meta.url),
@@ -41,9 +41,11 @@ export const NAV = [
41
41
  { label: 'Markdown', href: '/markdown' },
42
42
  { label: 'Global Store', href: '/store' },
43
43
  { label: 'Routing', href: '/routing' },
44
+ { label: 'Error Pages', href: '/error-pages' },
44
45
  { label: 'Streaming SSR', href: '/streaming' },
45
46
  { label: 'Caching', href: '/caching' },
46
47
  { label: 'Guard', href: '/guard' },
48
+ { label: 'Forms', href: '/forms' },
47
49
  { label: 'Raw Responses', href: '/raw-responses' },
48
50
  { label: 'Server API', href: '/server-api' },
49
51
  { label: 'Extending Pulse', href: '/extending' },
@@ -20,6 +20,8 @@ export default {
20
20
  ${h1('Actions')}
21
21
  ${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.')}
22
22
 
23
+ ${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.')}
24
+
23
25
  ${section('lifecycle', 'The action lifecycle')}
24
26
  <p>When a form with <code>data-action</code> is submitted, Pulse runs the action through a fixed sequence of steps:</p>
25
27
  ${codeBlock(highlight(`onStart(state, formData)
@@ -0,0 +1,98 @@
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('/error-pages')
6
+
7
+ export default {
8
+ route: '/error-pages',
9
+ meta: {
10
+ title: 'Error Pages — Pulse Docs',
11
+ description: 'Custom 404 pages in Pulse. A spec with route "*" renders through the normal pipeline with status 404 whenever no route matches.',
12
+ styles: ['/docs.css'],
13
+ },
14
+ view: () => renderLayout({
15
+ currentHref: '/error-pages',
16
+ prev,
17
+ next,
18
+ content: `
19
+ ${h1('Error Pages')}
20
+ ${lead('A spec with <code>route: \'*\'</code> is your site\'s custom 404 page. It renders through the normal pipeline — layout, styles, components, hydration — with status <strong>404</strong> whenever no route matches. Without one, visitors to a bad URL get the framework\'s unbranded default.')}
21
+
22
+ ${section('not-found', 'The custom 404 page')}
23
+ <p>Create a spec like any other page, with <code>'*'</code> as the route. Use the shared <code>layout()</code> so the page carries the site's nav and footer:</p>
24
+ ${codeBlock(highlight(`import { layout } from '../components/layout.js'
25
+
26
+ export default {
27
+ route: '*',
28
+ meta: {
29
+ title: 'Page not found',
30
+ styles: ['/pulse-ui.css', '/theme.css', '/app.css'],
31
+ },
32
+ view: () => layout({
33
+ content: \`
34
+ <h1>Page not found</h1>
35
+ <p>That page doesn't exist. <a href="/">Back home</a></p>
36
+ \`,
37
+ }),
38
+ }`, 'js', ), 'src/pages/not-found.js')}
39
+ <p>The filename doesn't matter — <code>not-found.js</code> is just a readable convention. The <code>route: '*'</code> property is what registers it as the fallback.</p>
40
+
41
+ ${callout('tip', 'Every site should have one. The framework\'s default 404 is a bare unstyled page with no navigation — a dead end for visitors and a missed signal for search engines crawling old URLs.')}
42
+
43
+ ${section('behaviour', 'How it behaves')}
44
+ ${table(
45
+ ['Aspect', 'Behaviour'],
46
+ [
47
+ ['Status code', 'Always <strong>404</strong> — correct for SEO; crawlers de-index dead URLs'],
48
+ ['Pipeline', 'Full normal render: <code>meta</code>, styles, components, hydration if it has mutations/actions'],
49
+ ['Validation', 'Validated at startup like every spec — a broken 404 page fails fast'],
50
+ ['Matching', 'Only when no other route matches. It is a fallback, not a route — it never shadows real pages'],
51
+ ['Client-side navigation', 'A bad link falls back to a full page load, which renders the custom 404'],
52
+ ['Caching', '404 responses are never stored in the in-process page cache'],
53
+ ]
54
+ )}
55
+
56
+ ${section('useful-404', 'Making the 404 useful')}
57
+ <p>The best 404 pages help the visitor recover. Since the page is a normal spec, it can use any component — search, popular links, even server data:</p>
58
+ ${codeBlock(highlight(`export default {
59
+ route: '*',
60
+ meta: { title: 'Page not found', styles: ['/pulse-ui.css', '/theme.css', '/app.css'] },
61
+ server: {
62
+ popular: async () => getPopularPages(), // suggest somewhere to go
63
+ },
64
+ view: (state, server) => layout({
65
+ content: \`
66
+ <h1>Page not found</h1>
67
+ <p>Try one of these instead:</p>
68
+ \${list({ items: (server.popular ?? []).map(p => \`<a href="\${p.href}">\${p.title}</a>\`) })}
69
+ \`,
70
+ }),
71
+ }`, 'js'))}
72
+
73
+ ${section('server-errors', '500s and other errors')}
74
+ <p>Server errors are handled by <code>createServer</code>'s <code>onError</code> option, not a spec — when a render throws, the spec pipeline itself may be the thing that is broken, so the handler works at the HTTP level:</p>
75
+ ${codeBlock(highlight(`await createServer(pages, {
76
+ onError: (err, req, res) => {
77
+ console.error(err)
78
+ if (res.headersSent) return
79
+ res.writeHead(500, { 'Content-Type': 'text/html; charset=utf-8' })
80
+ res.end('<h1>Something went wrong</h1><p><a href="/">Back home</a></p>')
81
+ },
82
+ })`, 'js'))}
83
+ <p>For errors thrown inside a <em>view</em> specifically, prefer <code>onViewError</code> on the spec — it returns a 200 with fallback HTML instead of a 500, keeping the page alive when one piece of data is malformed.</p>
84
+
85
+ ${callout('note', 'Three layers, three jobs: <code>route: \'*\'</code> for URLs that don\'t exist, <code>onViewError</code> for views that throw on bad data, <code>onError</code> for everything else that goes wrong server-side.')}
86
+
87
+ ${section('reference', 'Reference')}
88
+ ${table(
89
+ ['Property', 'Where', 'Notes'],
90
+ [
91
+ ['<code>route: \'*\'</code>', 'spec', 'Custom not-found page — rendered with status 404 when no route matches'],
92
+ ['<code>onViewError</code>', 'spec', 'Fallback HTML when the view throws — returns 200'],
93
+ ['<code>onError</code>', '<code>createServer</code> option', 'HTTP-level handler for unhandled server errors (500s)'],
94
+ ]
95
+ )}
96
+ `,
97
+ }),
98
+ }
@@ -0,0 +1,128 @@
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('/forms')
6
+
7
+ export default {
8
+ route: '/forms',
9
+ meta: {
10
+ title: 'Forms — Pulse Docs',
11
+ description: 'Server-side form handling in Pulse. spec.submit processes POST requests without client JavaScript, with automatic CSRF protection and POST-redirect-GET.',
12
+ styles: ['/docs.css'],
13
+ },
14
+ view: () => renderLayout({
15
+ currentHref: '/forms',
16
+ prev,
17
+ next,
18
+ content: `
19
+ ${h1('Forms')}
20
+ ${lead('A spec with a <code>submit</code> handler accepts POST requests on its own route — the form works with JavaScript disabled. CSRF protection is automatic, and successful submissions follow POST-redirect-GET. Client-side <code>actions</code> become an enhancement, not a requirement.')}
21
+
22
+ ${section('basics', 'Basic usage')}
23
+ <p>Add a <code>submit</code> function to the spec and render a plain <code>&lt;form method="POST"&gt;</code>. The route accepts POST automatically — no <code>methods</code> declaration needed.</p>
24
+ ${codeBlock(highlight(`export default {
25
+ route: '/contact',
26
+
27
+ submit: async (ctx) => {
28
+ const data = await ctx.formData()
29
+ if (!data?.email) return { errors: { email: 'Email is required' }, values: data ?? {} }
30
+ await sendMessage(data)
31
+ return { redirect: '/contact?sent=1' } // 303 POST-redirect-GET
32
+ },
33
+
34
+ view: (state, server) => \`
35
+ <main id="main-content">
36
+ \${server.form?.errors?.email ? \`<p class="error">\${escHtml(server.form.errors.email)}</p>\` : ''}
37
+ <form method="POST">
38
+ \${server.csrf}
39
+ \${input({ label: 'Email', name: 'email', type: 'email', required: true,
40
+ value: server.form?.values?.email ?? '' })}
41
+ \${button({ label: 'Send', type: 'submit' })}
42
+ </form>
43
+ </main>
44
+ \`,
45
+ }`, 'js'))}
46
+ <p>The flow: the browser POSTs the form, <code>submit(ctx)</code> runs server-side, and either redirects (success) or re-renders the page with the returned value available to the view as <code>server.form</code> (validation failure).</p>
47
+
48
+ ${callout('warning', '<strong><code>\${server.csrf}</code> must be inside every POST form.</strong> It renders the hidden CSRF token input — submissions without a valid token are rejected with <strong>403</strong> before <code>submit</code> runs.')}
49
+
50
+ ${section('return-values', 'Return values')}
51
+ ${table(
52
+ ['Return', 'Response', 'Use for'],
53
+ [
54
+ ['<code>{ redirect: \'/path\' }</code>', '<strong>303 See Other</strong>', 'Successful submissions — POST-redirect-GET means refresh never resubmits'],
55
+ ['<code>{ errors, values, … }</code>', 'Page re-renders (200), value exposed as <code>server.form</code>', 'Validation failures — show errors, echo values back into inputs'],
56
+ ['<code>{ status: 422, errors, … }</code>', 'Same re-render with status <strong>422</strong>', 'Validation failures where the status code matters (API clients, tests)'],
57
+ ['<code>undefined</code>', 'Page re-renders (200), <code>server.form</code> is <code>{}</code>', 'Fire-and-forget side effects'],
58
+ ]
59
+ )}
60
+ <p><strong>Always redirect after a successful mutation.</strong> Rendering a success message directly from the POST means a browser refresh resubmits the form. Redirect to a URL that shows the confirmation instead (e.g. <code>/contact?sent=1</code>).</p>
61
+
62
+ ${section('server-form', 'server.form — errors and echoed values')}
63
+ <p>On a re-render, whatever <code>submit</code> returned is available to the view as <code>server.form</code>. On a plain GET it is <code>undefined</code> — always use optional chaining.</p>
64
+ ${codeBlock(highlight(`// In submit — return errors AND the submitted values:
65
+ return { errors: { email: 'Invalid email' }, values: data }
66
+
67
+ // In the view — show the error, echo the value back so the form isn't wiped:
68
+ \${server.form?.errors?.email ? \`<p class="error">\${escHtml(server.form.errors.email)}</p>\` : ''}
69
+ \${input({ name: 'email', label: 'Email', value: server.form?.values?.email ?? '' })}`, 'js'))}
70
+ ${callout('warning', 'Escape user-submitted values before interpolating them into HTML — <code>server.form.values</code> is raw user input. Component props like <code>input({ value })</code> are auto-escaped; manual interpolation needs <code>escHtml()</code>.')}
71
+
72
+ ${section('csrf', 'CSRF protection')}
73
+ <p>Every <code>submit</code> spec gets CSRF protection automatically — there is nothing to configure. Under the hood Pulse uses an HMAC double-submit pattern:</p>
74
+ ${table(
75
+ ['Piece', 'Where it lives', 'What it does'],
76
+ [
77
+ ['Random id', '<code>pulse_csrf</code> cookie (HttpOnly, SameSite=Lax)', 'Set on the first GET of the form page'],
78
+ ['Token', 'Hidden <code>_csrf</code> input via <code>\${server.csrf}</code>', '<code>HMAC-SHA256(secret, id)</code> — embedded into the form server-side'],
79
+ ['Verification', 'On every POST, before <code>submit</code> runs', 'Recomputes the HMAC from the cookie and compares in constant time — mismatch is 403'],
80
+ ]
81
+ )}
82
+ <p>An attacker on another origin can cause the cookie to be sent, but cannot read it or compute the HMAC — so they cannot forge the pair.</p>
83
+
84
+ ${sub('multi-instance', 'Multiple server instances')}
85
+ <p>The HMAC secret defaults to a random value per server boot. Behind a load balancer, pin it so tokens issued by one instance validate on another:</p>
86
+ ${codeBlock(highlight(`await createServer(pages, {
87
+ secret: process.env.PULSE_SECRET, // stable across instances and restarts
88
+ })`, 'js'))}
89
+
90
+ ${sub('opt-out', 'Opting out')}
91
+ <p>Set <code>csrf: false</code> on the spec to disable protection — only for endpoints with their own authentication, such as signed webhooks:</p>
92
+ ${codeBlock(highlight(`export default {
93
+ route: '/webhooks/stripe',
94
+ csrf: false, // Stripe signs its requests — verify the signature instead
95
+ submit: async (ctx) => {
96
+ verifyStripeSignature(ctx.headers['stripe-signature'], await ctx.text())
97
+ // ...
98
+ },
99
+ view: () => '<main id="main-content">ok</main>',
100
+ }`, 'js'))}
101
+ ${callout('warning', 'Never set <code>csrf: false</code> to "fix" a 403. The 403 means the form is missing <code>\${server.csrf}</code> — add the field, don\'t remove the protection.')}
102
+
103
+ ${section('progressive-enhancement', 'Progressive enhancement')}
104
+ <p>The same form can serve both worlds: <code>method="POST"</code> is the no-JS fallback, <code>data-action</code> is the hydrated path. With JavaScript enabled the runtime intercepts the submit and runs the async action (no page reload); without it the browser POSTs to <code>submit</code>.</p>
105
+ ${codeBlock(highlight(`<form method="POST" data-action="send">
106
+ \${server.csrf}
107
+ \${input({ label: 'Email', name: 'email', type: 'email', required: true })}
108
+ \${button({ label: 'Send', type: 'submit' })}
109
+ </form>`, 'html'))}
110
+ <p>Keep the two paths consistent: <code>submit</code> and the action's <code>run</code> should call the same underlying function so validation and side effects cannot drift apart.</p>
111
+
112
+ ${section('caching', 'Interaction with caching')}
113
+ <p>Pages with <code>submit</code> are automatically excluded from the in-process page cache — the CSRF token is per-visitor, and a cached page would serve one visitor's token to everyone. POST responses are never cached.</p>
114
+
115
+ ${section('reference', 'Reference')}
116
+ ${table(
117
+ ['Property', 'Type', 'Notes'],
118
+ [
119
+ ['<code>submit</code>', '<code>async (ctx) =&gt; { redirect } | object | void</code>', 'Handles POST on the route. Guard runs first.'],
120
+ ['<code>csrf</code>', '<code>boolean</code>', 'Default <code>true</code>. Set <code>false</code> only for self-authenticated endpoints.'],
121
+ ['<code>server.csrf</code>', 'string (view)', 'Hidden <code>_csrf</code> input — required inside every POST form.'],
122
+ ['<code>server.form</code>', 'object | undefined (view)', 'Return value of <code>submit</code> on a re-render; <code>undefined</code> on GET.'],
123
+ ['<code>secret</code>', 'string (createServer option)', 'Stable HMAC secret for multi-instance deployments.'],
124
+ ]
125
+ )}
126
+ `,
127
+ }),
128
+ }
@@ -105,6 +105,7 @@ export default {
105
105
  // return nothing to let a GET request proceed to the view
106
106
  }`, 'js'))}
107
107
  ${callout('note', 'To use <code>guard</code> as a POST handler, the spec must declare <code>methods: [\'GET\', \'POST\']</code>. Without it, POST requests are rejected with 405 before guard runs.')}
108
+ ${callout('tip', 'For HTML form submissions, prefer <a href="/forms"><code>spec.submit</code></a> — it accepts POST automatically, gets CSRF protection for free, and re-renders the page with validation errors. Guard-based POST handling is best for API-style endpoints.')}
108
109
 
109
110
  ${section('reference', 'Reference')}
110
111
  ${table(
@@ -80,15 +80,13 @@ server: {
80
80
  }`, 'js'))}
81
81
 
82
82
  ${section('not-found', '404 handling')}
83
- <p>If no spec matches the incoming request path, Pulse returns a 404 response. The response body is a minimal HTML page. To customise the 404 page, use the <code>onError</code> option in <code>createServer</code>:</p>
84
- ${codeBlock(highlight(`createServer(specs, {
85
- onError: (err, req, res) => {
86
- if (err.status === 404) {
87
- res.writeHead(404, { 'Content-Type': 'text/html' })
88
- res.end('<h1>Not found</h1>')
89
- }
90
- }
91
- })`, 'js'))}
83
+ <p>If no spec matches the incoming request path, Pulse returns a 404. To customise it, create a spec with <code>route: '*'</code> — it renders through the normal pipeline (layout, styles, components) with status 404:</p>
84
+ ${codeBlock(highlight(`export default {
85
+ route: '*',
86
+ meta: { title: 'Page not found', styles: ['/pulse-ui.css', '/theme.css', '/app.css'] },
87
+ view: () => \`<main id="main-content"><h1>Page not found</h1><p><a href="/">Back home</a></p></main>\`,
88
+ }`, 'js'), 'src/pages/not-found.js')}
89
+ <p>See <a href="/error-pages">Error Pages</a> for the full behaviour, including 500 handling.</p>
92
90
 
93
91
  ${section('conventions', 'File naming conventions')}
94
92
  <p>While Pulse does not auto-discover files, the recommended convention maps file names to routes:</p>
@@ -48,8 +48,9 @@ await createServer(entries, options)`, 'js'))}
48
48
  ['<code>shutdownTimeout</code>', '<code>number</code>', '<code>30000</code>', 'Milliseconds to wait for in-flight requests to finish during graceful shutdown before force-exiting. See <a href="#graceful-shutdown">Graceful shutdown</a>.'],
49
49
  ['<code>healthCheck</code>', '<code>string | false</code>', '<code>"/healthz"</code>', 'Path for the built-in health check endpoint. Returns <code>{ status: "ok", uptime }</code>. Set to <code>false</code> to disable. The endpoint bypasses <code>onRequest</code> so load balancers always get a response.'],
50
50
  ['<code>resolveBrand</code>', '<code>async (host) => any</code>', '<code>undefined</code>', 'Multi-brand support. Called once per host (cached 60s). Result is attached to <code>ctx.brand</code> and available in <code>guard</code>, <code>server</code>, and <code>meta</code> functions.'],
51
+ ['<code>secret</code>', '<code>string</code>', 'random per boot', 'HMAC secret for CSRF tokens (<a href="/forms">Forms</a>). Set a stable value when running multiple instances so tokens issued by one validate on another.'],
51
52
  ['<code>onRequest</code>', '<code>function</code>', '<code>undefined</code>', 'Called on every request before routing. Return <code>false</code> to short-circuit Pulse handling.'],
52
- ['<code>onError</code>', '<code>function</code>', '<code>undefined</code>', 'Called on unhandled errors. Receives <code>(err, req, res)</code>.'],
53
+ ['<code>onError</code>', '<code>function</code>', '<code>undefined</code>', 'Called on unhandled errors. Receives <code>(err, req, res)</code>. See <a href="/error-pages">Error Pages</a>.'],
53
54
  ]
54
55
  )}
55
56
 
@@ -20,6 +20,8 @@ Before finishing any spec, verify every point below. Fix anything that fails.
20
20
 
21
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
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
+
23
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.
24
26
 
25
27
  ### Components first — or creative override
@@ -111,6 +113,14 @@ Before finishing any spec, verify every point below. Fix anything that fails.
111
113
  ### Security
112
114
 
113
115
  - Any value from user input (URL params, form fields, external APIs) interpolated into view HTML must be escaped.
116
+ - **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).
117
+ - **`submit` returns `{ redirect }` after a successful mutation** (POST-redirect-GET). Rendering success directly from the POST means browser refresh resubmits the form.
118
+
119
+ ### Server-side forms & error pages
120
+
121
+ - **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.
122
+ - 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.
123
+ - **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.
114
124
 
115
125
  ### Tests
116
126