@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
@@ -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
 
@@ -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
 
@@ -126,6 +126,27 @@ Convention: name dynamic-route files [param].js inside a subfolder:
126
126
 
127
127
  This is purely a human readability convention. Pulse does not process [ ] in filenames.
128
128
 
129
+ ## Custom 404 page
130
+
131
+ Create a spec with `route: '*'` — it renders through the normal pipeline (layout, styles, hydration) with status 404 whenever no route matches:
132
+
133
+ ```js
134
+ // src/pages/not-found.js
135
+ import { layout } from '../components/layout.js'
136
+
137
+ export default {
138
+ route: '*',
139
+ meta: { title: 'Page not found', styles: ['/pulse-ui.css', '/theme.css', '/app.css'] },
140
+ view: () => layout({
141
+ content: `<h1>Page not found</h1><p>That page doesn't exist. <a href="/">Back home</a></p>`,
142
+ }),
143
+ }
144
+ ```
145
+
146
+ - Use the shared `layout()` so the 404 carries the site's nav and footer.
147
+ - **Every site should have one** — without it, visitors to a bad URL get the framework's unbranded default 404.
148
+ - 500 errors are customised via `createServer`'s `onError` option, not a spec.
149
+
129
150
  ## Canonical URLs
130
151
 
131
152
  Pulse auto-derives a canonical URL from every request and emits `<link rel="canonical">` in the `<head>`. No config needed in most cases.
@@ -102,6 +102,40 @@ actions: {
102
102
  ```
103
103
  `_storeUpdate` is stripped from local page state — it is only forwarded to the store. The rest of `onSuccess` merges into the page's own state as normal.
104
104
 
105
+ ## Server-side forms — `spec.submit` (works without client JS)
106
+
107
+ A spec with a `submit` handler accepts POST on its own route. The form works with JavaScript disabled — hydrated `actions` become an enhancement, not a requirement.
108
+
109
+ ```js
110
+ export default {
111
+ route: '/contact',
112
+ submit: async (ctx) => {
113
+ const data = await ctx.formData()
114
+ if (!data?.email) return { errors: { email: 'Email is required' }, values: data ?? {} }
115
+ if (!isValid(data.email)) return { status: 422, errors: { email: 'Invalid email' }, values: data }
116
+ await sendMessage(data)
117
+ return { redirect: '/contact?sent=1' } // 303 POST-redirect-GET
118
+ },
119
+ view: (state, server) => `
120
+ <main id="main-content">
121
+ ${server.form?.errors?.email ? `<p class="error">${escHtml(server.form.errors.email)}</p>` : ''}
122
+ <form method="POST">
123
+ ${server.csrf}
124
+ ${input({ label: 'Email', name: 'email', type: 'email', required: true, value: server.form?.values?.email ?? '' })}
125
+ ${button({ label: 'Send', type: 'submit' })}
126
+ </form>
127
+ </main>`,
128
+ }
129
+ ```
130
+
131
+ **Rules:**
132
+ - `${server.csrf}` **must be inside every POST form** — CSRF protection is automatic and POSTs without a valid token are rejected with 403. Opt out with `csrf: false` only for endpoints with their own authentication (e.g. signed webhooks).
133
+ - `{ redirect }` → 303 See Other. Always redirect after a successful mutation — rendering success directly from the POST means refresh resubmits the form.
134
+ - Any other return value re-renders the page with it exposed as `server.form` — put `errors` and the submitted `values` there, and echo values back into inputs so failed validation doesn't wipe what the user typed. An optional `status` field sets the response status (e.g. 422).
135
+ - Escape user-submitted values before interpolating them into HTML (`escHtml`).
136
+ - **Progressive enhancement:** add `data-action="send"` to the same form — hydrated visitors get the async action with no page reload; no-JS visitors fall back to the POST.
137
+ - Multi-instance deployments: pass a stable `secret` to `createServer` (e.g. from an env var) so CSRF tokens issued by one instance validate on another.
138
+
105
139
  ## Server context — redirects, cookies, POST bodies
106
140
 
107
141
  The `ctx` object is available in `guard`, `server.*` fetchers, and `meta` functions.
@@ -16,6 +16,10 @@ export default {
16
16
  title: 'Page Title', // string or async (ctx) => string
17
17
  description: 'Meta description', // string or async (ctx) => string
18
18
  styles: ['/app.css'], // string[] or (ctx) => string[]
19
+ theme: 'light', // 'light' | 'dark' — ⚠ DEFAULT IS DARK when unset.
20
+ // Decide at write time: a light design needs
21
+ // meta.theme: 'light' or the page renders dark.
22
+ vibe: 'minimal', // optional — radius/weight/spacing preset
19
23
  ogTitle: 'Social title', // optional — falls back to title
20
24
  ogImage: 'https://…/og.jpg', // optional — 1200×630 recommended
21
25
  canonical: 'https://mysite.com/…', // optional — string or (ctx) => string
@@ -42,10 +46,31 @@ export default {
42
46
  // Resolved server-side before render. Passed to view() as second arg.
43
47
  posts: async () => fetchPostsFromDb(),
44
48
  },
49
+ // Server-side form handling — POST works with JavaScript disabled.
50
+ // { redirect } → 303 PRG; anything else → re-render with the value as server.form.
51
+ // CSRF enforced automatically: the view MUST include ${server.csrf} inside the <form>.
52
+ submit: async (ctx) => {
53
+ const data = await ctx.formData()
54
+ if (!data?.email) return { errors: { email: 'Required' }, values: data ?? {} }
55
+ await save(data)
56
+ return { redirect: '/thanks' }
57
+ },
45
58
  view: (state, serverState) => `<h1>${state.title}</h1>`,
46
59
  }
47
60
  ```
48
61
 
62
+ ## Server-side forms (`submit`)
63
+
64
+ - A spec with `submit` accepts POST on its own route — plain `<form method="POST">`, no client JS needed.
65
+ - **Always put `${server.csrf}` inside the form** — it renders the hidden CSRF input. POSTs without a valid token are rejected with 403. (`csrf: false` opts out — only for endpoints with their own auth.)
66
+ - `server.form` holds the return value of `submit()` on a re-render: show `server.form?.errors`, echo `server.form?.values` back into inputs via `value=` so failed validation doesn't wipe the form.
67
+ - After a successful mutation always `return { redirect: '/path' }` (POST-redirect-GET) — never render success from the POST itself. An optional `status` field on a re-render return sets the response status (e.g. 422).
68
+ - Progressive enhancement: the same form can also have `data-action="name"` — hydrated visitors get the async action, no-JS visitors use the POST.
69
+
70
+ ## Custom not-found page
71
+
72
+ A spec with `route: '*'` renders with **status 404** whenever no route matches — same pipeline as any page (layout, styles, hydration, validation). Every site should have one; the default framework 404 is unbranded.
73
+
49
74
  ## Streaming SSR — shell + deferred segments
50
75
 
51
76
  Use streaming when a page has slow data and you want the shell to paint instantly while slower content streams in.
@@ -199,6 +199,8 @@ The default CSP does **not** restrict `img-src` — it inherits `default-src 'se
199
199
 
200
200
  **Do this at build time, before writing any image tags.** If you know you'll use external images, add the CSP entry to `pulse.config.js` first — discovering the block at Lighthouse time costs an extra round-trip.
201
201
 
202
+ **Use `pulse_check_csp` to get the origin list right first time.** Many hosts redirect to a CDN origin (picsum.photos → fastly.picsum.photos), and allowing only the URL you wrote still blocks the actual bytes. Pass it the URLs you plan to use and it follows each redirect chain and returns ready-to-paste CSP config with every origin included.
203
+
202
204
  ```js
203
205
  // pulse.config.js
204
206
  export default {
@@ -213,7 +215,7 @@ Or via `createServer`:
213
215
  ```js
214
216
  createServer(specs, {
215
217
  csp: {
216
- 'img-src': ['https://images.unsplash.com', 'https://picsum.photos'],
218
+ 'img-src': ['https://images.unsplash.com', 'https://picsum.photos', 'https://fastly.picsum.photos'],
217
219
  },
218
220
  })
219
221
  ```
@@ -53,7 +53,7 @@ Check for:
53
53
  - Layout looks correct
54
54
  - No console errors via list_console_messages
55
55
 
56
- Then ask the user: *"Happy with the design and layout, or would you like any changes before I run tests and Lighthouse?"* — use your host's question tool if it has one (e.g. AskUserQuestion); 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. **Do not proceed until the user explicitly approves.** 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.
56
+ 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?"* Always call the tool before asking, even when using a question tool like AskUserQuestion — in some hosts (including Claude Code) the question still ends the turn and fires the Stop hooks; the marker is harmless if the turn doesn't end. **Do not proceed until the user explicitly approves.** 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.
57
57
 
58
58
  ### 7. Lighthouse
59
59
 
@@ -34,7 +34,7 @@ Navigate to the page route with `mcp__chrome-devtools__navigate_page`, then take
34
34
  **If `pulse_intake` ran earlier in this session** (i.e. this is a new page or site, not an edit or bug fix):
35
35
 
36
36
  1. Show the screenshot to the user.
37
- 2. Ask: *"Happy with the layout and direction, or any changes before I run Lighthouse?"* — use your host's question tool if it has one (e.g. AskUserQuestion); 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.
37
+ 2. **Call `pulse_await_approval` first, then ask:** *"Happy with the layout and direction, or any changes before I run Lighthouse?"* Always call the tool before asking, even when using a question tool like AskUserQuestion — in some hosts (including Claude Code) the question still 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.
38
38
  3. **Stop and wait for their response.** Do not proceed to step 4b or Lighthouse until the user explicitly approves. 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.
39
39
 
40
40
  If the user requests changes: make edits, restart the server, take a new screenshot, describe what changed, ask again. Repeat until approved. No Lighthouse between design rounds.
@@ -67,7 +67,7 @@ Run `mcp__chrome-devtools__lighthouse_audit` with `device: "mobile"` against the
67
67
 
68
68
  ### 8. Performance
69
69
 
70
- Navigate to the page route, then run `mcp__chrome-devtools__performance_start_trace` with `reload: true` and `autoStop: true`. Report LCP and CLS as a single line, e.g. `LCP 73ms · CLS 0.00`. Flag any insight that identifies a fixable cause (render-blocking resources, large image delay). CLS must be 0.00.
70
+ **Stay on the production server (port 3001) for the trace** — navigate to `http://localhost:3001/<route>` first; 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 as a single line, e.g. `LCP 73ms · CLS 0.00`. Flag any insight that identifies a fixable cause (render-blocking resources, large image delay). CLS must be 0.00.
71
71
 
72
72
  ### 9. Console errors
73
73
 
@@ -32,7 +32,7 @@ For any **new page, landing page, or branded site**, run the intake sequence fir
32
32
 
33
33
  - **Step 0 — Ask for inspiration first (mandatory):** Before calling any tool or presenting any plan, ask the user: *"Do you have any design inspiration — a site you love, a screenshot, or a mood board image? Drop images into `public/intake/` or paste a URL and I'll extract the design intent before we start."* Wait for the answer. Do not skip this question, even if the user's brief already seems detailed. Check `public/intake/` for any images already dropped there.
34
34
  - **`pulse_extract_inspiration`** — call this after step 0 if the user supplies a URL, site name, or image they like. Also call it for any images already present in `public/intake/`. Gives you a structured extraction template: visit the URL or analyse the image with your vision tools, extract colours, layout, typography, and feel, then feed those findings into pulse_intake. If the user explicitly says they have no reference, skip this step.
35
- - **`pulse_intake`** — captures the real content (name, pitch, features, palette, vibe, what it should NOT look like). Returns a brief with copy-ready content and early contrast warnings. Always ask the user one question at a time — never multi-choice for open-ended questions.
35
+ - **`pulse_intake`** — captures the real content (name, pitch, features, palette, theme, vibe, what it should NOT look like). Returns a brief with copy-ready content and early contrast warnings. Always ask the user one question at a time — never multi-choice for open-ended questions. **Always ask light or dark** — Pulse renders dark when `meta.theme` is unset, so an unstated light design ships dark.
36
36
  - **`pulse_sketch`** — generates 3 structurally distinct layout directions (full-bleed, asymmetric split, typography-only, editorial, dense grid, story scroll, content-first). **Call this before writing any code.** Prevents defaulting to centred hero + three-column features every time.
37
37
  - **Skip `pulse_sketch` only** when the vibe is `corporate` or `minimal` AND the user hasn't expressed any structural preference. For `playful`, `bold`, `brutalist`, `retro`, or `neon` vibes, `pulse_sketch` is mandatory — templates have default section orders that actively fight these vibes.
38
38
  - **`pulse_intent`** — maps the chosen direction to a spec scaffold and tells you which guides to read.
@@ -70,6 +70,7 @@ Do not fetch every guide section for every task. Fetch what you need.
70
70
  Decide the complexity tier (see below) and confirm with the user if the task is non-trivial. Output a concise plan:
71
71
 
72
72
  - Route, state shape, mutations/actions, server fetchers
73
+ - **Theme: light or dark** — Pulse defaults to **dark** when `meta.theme` is unset. Decide now (ask the user if the brief doesn't say) — discovering a wrong theme at the screenshot costs a full edit → restart → re-approval cycle.
73
74
  - Which UI components you will use
74
75
  - Files you will create or modify
75
76
 
@@ -179,6 +180,7 @@ Before writing any file, output a build brief:
179
180
  ```
180
181
  Building: <page name> (<route>)
181
182
  Mode: A — components first | B — creative override (raw HTML, reason: ...)
183
+ Theme: light | dark (+ vibe, if any) — default is DARK when meta.theme is unset
182
184
  State: <fields and types, or "none">
183
185
  Mutations: <list, or "none">
184
186
  Actions: <list with brief description, or "none">
@@ -242,8 +244,8 @@ Do this before every screenshot, console check, or visual inspection. Never debu
242
244
 
243
245
  **How to wait without tripping the Stop hooks.** The project's Stop hooks (missing tests, coverage, verify stamp) block any turn that ends with unverified spec edits — they cannot tell "abandoning the work" from "pausing for the user's answer". So:
244
246
 
245
- - **Preferred:** ask the question with your host's question tool (e.g. AskUserQuestion in Claude Code). A question tool waits for the answer *without ending the turn*, so the Stop hooks never fire.
246
- - **Otherwise:** call `pulse_await_approval` immediately before asking in plain prose and ending your turn. It writes a one-turn marker the Stop hooks respect; the marker is consumed when the user replies.
247
+ - **Always call `pulse_await_approval` immediately before asking** — regardless of whether you ask via a question tool or in plain prose. In some hosts (including Claude Code) even AskUserQuestion ends the turn and fires the Stop hooks. The marker is harmless if the turn doesn't end: it is simply consumed when the user replies.
248
+ - Then ask the question (question tool with choices where available, plain prose otherwise) and end your turn.
247
249
  - **Never** respond to a `VERIFY REQUIRED` block by running `/verify` while your approval question is unanswered. The block does not override the approval gate — an unanswered question means the design is not approved, and Lighthouse on an unapproved design wastes ~90 seconds and pre-empts the user's changes.
248
250
 
249
251
  **If the user requests changes:**
@@ -274,6 +276,16 @@ Do this before every screenshot, console check, or visual inspection. Never debu
274
276
  2. `pulse_layout_review <url>` — 390/768/1280px overflow, broken images, collapsed sections
275
277
  3. `/verify` — Lighthouse desktop + mobile (100/100/100) + CLS 0.00 + `pulse_review`
276
278
 
279
+ **Two different reviews — don't confuse them.** `pulse_design_review` is the *visual* review (7 signals) and runs **before** Lighthouse. `pulse_review` is the *code* review and runs **after** Lighthouse, always last. The canonical end-to-end order is:
280
+
281
+ ```
282
+ pulse_validate → design approval → pulse_design_review → pulse_layout_review
283
+ → pulse_build → Lighthouse desktop + mobile → performance trace
284
+ → pulse_review → pulse_stamp
285
+ ```
286
+
287
+ If a step's fixes touch the spec, re-run from `pulse_validate` — never re-run `pulse_review` on code that hasn't re-passed Lighthouse.
288
+
277
289
  **Do not report the page as done until `/verify` writes the `.pulse-verified` stamp.** Skipping Lighthouse means broken contrast, missing landmarks, and Best Practices failures ship to the user.
278
290
 
279
291
  Pass gates:
@@ -261,7 +261,61 @@ test('approval pause mechanism is wired end to end', () => {
261
261
 
262
262
  // 5. The workflow must teach the mechanism at the design-approval gate
263
263
  assert(workflow.includes('pulse_await_approval'),
264
- 'workflow.md Phase 5a must explain how to pause: host question tool preferred, pulse_await_approval otherwise')
264
+ 'workflow.md Phase 5a must explain how to pause: always pulse_await_approval before asking')
265
+
266
+ // 6. No doc may claim a host question tool avoids ending the turn — field
267
+ // testing showed AskUserQuestion in Claude Code still ends the turn and
268
+ // fires the Stop hooks. The safe instruction is: always write the marker
269
+ // first, regardless of how the question is asked.
270
+ const claimDocs = agentDocs.filter(f =>
271
+ fs.existsSync(path.join(ROOT, f)) &&
272
+ /without ending the turn/i.test(fs.readFileSync(path.join(ROOT, f), 'utf8'))
273
+ )
274
+ if (/without ending the turn.*Stop hooks never fire/is.test(serverSrc)) claimDocs.push('src/mcp/server.js')
275
+ assert(claimDocs.length === 0,
276
+ `These docs claim a question tool avoids ending the turn (empirically false in Claude Code): ${claimDocs.join(', ')}. ` +
277
+ `Instruct: always call pulse_await_approval before asking, whichever way the question is asked.`)
278
+ })
279
+
280
+ test('pulse_check_contrast extracts variables from [data-theme="light"] blocks', () => {
281
+ // Regression: the light-theme block pattern was an ungrouped alternation
282
+ // (a|b) + '\\s*\\{([^}]+)\\}' — the body matcher bound only to the second
283
+ // alternative, so [data-theme="light"] blocks matched with no body capture
284
+ // and the checker silently reported nothing for the light theme.
285
+ // Recreate extractVars with the exact pattern strings from server.js source.
286
+ const patterns = [...serverSrc.matchAll(/extractVars\(source,\s*'((?:[^'\\]|\\.)*)'\)/g)].map(m =>
287
+ m[1].replace(/\\\\/g, '\\').replace(/\\'/g, "'")
288
+ )
289
+ assert(patterns.length === 2, `Expected 2 extractVars call patterns in server.js, found ${patterns.length}`)
290
+ const lightPattern = patterns.find(p => p.includes('data-theme'))
291
+ assert(lightPattern, 'No data-theme pattern found in extractVars calls')
292
+
293
+ const sampleCss = `:root { --accent: #112233; }\n[data-theme="light"] { --accent: #445566; --bg: #ffffff; }`
294
+ const blockRegex = new RegExp(lightPattern + '\\s*\\{([^}]+)\\}', 'gi')
295
+ const m = blockRegex.exec(sampleCss)
296
+ assert(m && m[1] && m[1].includes('--accent'),
297
+ `The light-theme block pattern in pulse_check_contrast fails to capture the block body. ` +
298
+ `Pattern: ${lightPattern} — alternations must be wrapped in (?:...).`)
299
+ })
300
+
301
+ test('dark theme default is declared at every spec-writing entry point', () => {
302
+ // Agents repeatedly assumed the default theme is light, built the page, then
303
+ // discovered dark at the screenshot — costing an edit → restart → re-approval
304
+ // cycle. The dark default must be stated wherever a spec is first written:
305
+ // the spec skeleton, the plan/build brief, and the checklist.
306
+ const guideSpec = fs.readFileSync(path.join(agentDir, 'guide-spec.md'), 'utf8')
307
+ const workflow = fs.readFileSync(path.join(agentDir, 'workflow.md'), 'utf8')
308
+ const checklist = fs.readFileSync(path.join(agentDir, 'checklist.md'), 'utf8')
309
+ const claudeMd = fs.readFileSync(path.join(ROOT, 'CLAUDE.md'), 'utf8')
310
+
311
+ assert(/theme:/.test(guideSpec) && /DEFAULT IS DARK/i.test(guideSpec),
312
+ 'guide-spec.md meta skeleton must include the theme field with the dark-default warning')
313
+ assert(/Theme:\s+light \| dark/.test(workflow),
314
+ 'workflow.md build brief template must include a Theme line')
315
+ assert(/default theme is DARK/i.test(checklist),
316
+ 'checklist.md Critical section must state the dark default')
317
+ assert(/theme:\s+'light'/.test(claudeMd) && /default is DARK/i.test(claudeMd),
318
+ 'CLAUDE.md spec meta block must show theme with the dark-default warning')
265
319
  })
266
320
 
267
321
  test('identity.md declares the creative override (Design Freedom) carve-out', () => {
package/src/mcp/server.js CHANGED
@@ -1210,11 +1210,11 @@ The stop hook compares each changed spec's mtime against this stamp. Any spec ne
1210
1210
  server.registerTool(
1211
1211
  'pulse_await_approval',
1212
1212
  {
1213
- description: `Pause the workflow to wait for the user's answer — call this immediately BEFORE ending your turn with a blocking question (e.g. the design-approval gate: "Happy with the layout and direction…?").
1213
+ description: `Pause the workflow to wait for the user's answer — call this immediately BEFORE asking a blocking question (e.g. the design-approval gate: "Happy with the layout and direction…?").
1214
1214
 
1215
- It writes a .pulse-awaiting-approval marker that tells the Stop hooks (missing tests, coverage, verify stamp) to let this one turn end without demanding /verify first. The marker is deleted automatically when the user sends their next message — the gates return in full force for your following turn.
1215
+ It writes a .pulse-awaiting-approval marker that tells the Stop hooks (missing tests, coverage, verify stamp) to let the turn end without demanding /verify first. The marker is deleted automatically when the user sends their next message — the gates return in full force for your following turn.
1216
1216
 
1217
- **Prefer your host's question tool if it has one** (e.g. AskUserQuestion in Claude Code): a question tool waits for the answer *without ending the turn*, so the Stop hooks never fire and this marker is unnecessary. Use pulse_await_approval only when you must ask in plain prose and end the turn.
1217
+ **Always call this before the approval question, regardless of how you ask.** In some hosts (including Claude Code) even a dedicated question tool like AskUserQuestion ends the turn, which fires the Stop hooks. The marker is harmless if the turn does not end — it is simply consumed when the user replies. Calling it first works in every host; relying on the question tool to keep the turn open does not.
1218
1218
 
1219
1219
  Do NOT use this to skip verification — it buys exactly one turn-end, paired with a question the user must answer. Calling it without asking anything just delays the gates by one message.`,
1220
1220
  inputSchema: {},
@@ -2099,6 +2099,7 @@ Returns observations grouped by category: completeness, data flow, components, s
2099
2099
  if (!content.includes('title:')) quickWins.push('`meta.title` is missing — every page needs a unique title.')
2100
2100
  if (!content.includes('description:')) quickWins.push('`meta.description` is missing — used for SEO and social previews.')
2101
2101
  if (!content.includes('styles:')) quickWins.push('`meta.styles` not set — add `[\'/pulse-ui.css\']` if you are using any UI components.')
2102
+ if (!content.includes('theme:')) quickWins.push('`meta.theme` not set — Pulse renders DARK by default. If this design is light, add `theme: \'light\'` now rather than discovering a dark page at the screenshot.')
2102
2103
  }
2103
2104
 
2104
2105
  // --- Export ---
@@ -2432,7 +2433,7 @@ Accepts antiStyle ("what should this NOT look like?") and inspiration (a site or
2432
2433
  targetUser: z.string().optional().describe('Who this is for — shapes copy tone. E.g. "busy professionals", "indie developers", "home cooks"'),
2433
2434
  palette: z.string().optional().describe('Brand colours as hex values, comma-separated. E.g. "#6366f1, #f8fafc, #1e1b4b". Used for early contrast check.'),
2434
2435
  font: z.string().optional().describe('Brand font name, or omit for system-ui'),
2435
- theme: z.enum(['light', 'dark']).optional().describe('Colour theme preference'),
2436
+ theme: z.enum(['light', 'dark']).optional().describe('Colour theme — ALWAYS ask the user for this; Pulse renders DARK when meta.theme is unset, so an unstated light design ships dark and costs a rework cycle. Pass the answer through to meta.theme in the spec.'),
2436
2437
  vibe: z.enum(['warm', 'editorial', 'playful', 'minimal', 'bold', 'brutalist', 'retro', 'corporate', 'neon', 'paper']).optional().describe('Visual personality — see pulse://guide/design-references for full descriptions. warm (rounded, inviting), editorial (serif, sharp), playful (very rounded), minimal (clean), bold (impact headings), brutalist (zero radius, raw), retro (slab serif, nostalgic), corporate (conservative), neon (monospace, futuristic), paper (organic, serif, journal)'),
2437
2438
  styleNotes: z.string().optional().describe('Free-form style direction — e.g. "like a print poster", "market stall chalkboard feel", "clinical and professional"'),
2438
2439
  antiStyle: z.string().optional().describe('"What should this NOT look like?" — negative aesthetic constraint. E.g. "not corporate SaaS", "not another Vercel dark card page", "not a startup landing page". Used to steer component and layout choices away from over-used patterns.'),
@@ -2997,6 +2998,78 @@ Returns tokens grouped by category.`,
2997
2998
  }
2998
2999
  )
2999
3000
 
3001
+ server.registerTool(
3002
+ 'pulse_check_csp',
3003
+ {
3004
+ description: `Resolve the redirect chains of external asset URLs (images, fonts, stylesheets) and return the complete set of origins your CSP must allow — as ready-to-paste config.
3005
+
3006
+ Many asset hosts redirect to a CDN origin (picsum.photos → fastly.picsum.photos): allowing only the URL you wrote still blocks the actual bytes, and the browser error names the CDN origin, which is confusing to debug. This tool follows each redirect chain (up to 5 hops) and reports every origin involved.
3007
+
3008
+ Call it with the external URLs you plan to use, before your first Lighthouse run. Suggested directive: img-src for images, font-src for font files, style-src for stylesheets.`,
3009
+ inputSchema: {
3010
+ urls: z.array(z.string()).describe('External asset URLs to check, e.g. ["https://picsum.photos/id/10/1200/600"]'),
3011
+ directive: z.string().optional().describe("CSP directive these assets belong to (default: 'img-src')"),
3012
+ },
3013
+ },
3014
+ async ({ urls, directive = 'img-src' }) => {
3015
+ const MAX_HOPS = 5
3016
+ const origins = new Set()
3017
+ const lines = []
3018
+
3019
+ for (const rawUrl of urls.slice(0, 10)) {
3020
+ let url
3021
+ try { url = new URL(rawUrl) } catch { lines.push(`✗ ${rawUrl} — not a valid URL`); continue }
3022
+
3023
+ const chain = [url.origin]
3024
+ let blocked = null
3025
+ try {
3026
+ for (let hop = 0; hop < MAX_HOPS; hop++) {
3027
+ const res = await fetch(url, { method: 'HEAD', redirect: 'manual', signal: AbortSignal.timeout(5000) })
3028
+ if (res.status >= 300 && res.status < 400 && res.headers.get('location')) {
3029
+ url = new URL(res.headers.get('location'), url)
3030
+ chain.push(url.origin)
3031
+ continue
3032
+ }
3033
+ if (res.status === 405) {
3034
+ // Host rejects HEAD — confirm with a ranged GET so we still see redirects
3035
+ const get = await fetch(url, { method: 'GET', redirect: 'manual', headers: { Range: 'bytes=0-0' }, signal: AbortSignal.timeout(5000) })
3036
+ if (get.status >= 300 && get.status < 400 && get.headers.get('location')) {
3037
+ url = new URL(get.headers.get('location'), url)
3038
+ chain.push(url.origin)
3039
+ continue
3040
+ }
3041
+ }
3042
+ break
3043
+ }
3044
+ } catch (err) {
3045
+ blocked = err?.name === 'TimeoutError' ? 'timed out' : (err?.message || 'request failed')
3046
+ }
3047
+
3048
+ const uniqueChain = [...new Set(chain)]
3049
+ for (const o of uniqueChain) origins.add(o)
3050
+ lines.push(
3051
+ blocked
3052
+ ? `⚠ ${rawUrl} — ${blocked}; allowing origin from the URL only: ${uniqueChain.join(' → ')}`
3053
+ : uniqueChain.length > 1
3054
+ ? `↪ ${rawUrl} — redirects: ${uniqueChain.join(' → ')} (allow ALL of these)`
3055
+ : `✓ ${rawUrl} — no redirect, origin: ${uniqueChain[0]}`
3056
+ )
3057
+ }
3058
+
3059
+ const config = [...origins].map(o => `'${o}'`).join(', ')
3060
+ lines.push('')
3061
+ lines.push('Ready to paste:')
3062
+ lines.push('```js')
3063
+ lines.push(`csp: {`)
3064
+ lines.push(` '${directive}': [${[...origins].map(o => `'${o}'`).join(', ')}],`)
3065
+ lines.push(`}`)
3066
+ lines.push('```')
3067
+ if (config.includes('http://')) lines.push('⚠ An origin resolved to plain http: — use https URLs in production.')
3068
+
3069
+ return text(lines.join('\n'))
3070
+ }
3071
+ )
3072
+
3000
3073
  server.registerTool(
3001
3074
  'pulse_check_contrast',
3002
3075
  {
@@ -3036,8 +3109,12 @@ Run this immediately after writing a theme file — before production build and
3036
3109
  return vars
3037
3110
  }
3038
3111
 
3112
+ // NOTE: alternations must be wrapped in a non-capturing group. An ungrouped
3113
+ // pattern (a|b) followed by '\\s*\\{...' binds the body matcher to the second
3114
+ // alternative only — [data-theme="light"] blocks then match the selector
3115
+ // alone with no body capture, silently returning zero variables.
3039
3116
  const rootVars = extractVars(source, ':root')
3040
- const lightVars = extractVars(source, '\\[data-theme=["\']light["\']\\]|[.]ui-theme-light')
3117
+ const lightVars = extractVars(source, '(?:\\[data-theme=["\']light["\']\\]|[.]ui-theme-light)')
3041
3118
 
3042
3119
  // pulse-ui.css maps --NAME → --ui-NAME (e.g. --accent → --ui-accent).
3043
3120
  // Theme files written per the guide define bare --* tokens. Synthesize the
@@ -36,7 +36,7 @@ export async function renderToString(spec, ctx = {}, pageState = {}) {
36
36
  const serverState = await resolveServerState(spec, ctx)
37
37
 
38
38
  // Merge declared store keys into server state — store keys lose to page-level keys
39
- const mergedServerState = mergeStoreKeys(spec, serverState, ctx.store)
39
+ const mergedServerState = applyCtxExtras(mergeStoreKeys(spec, serverState, ctx.store), ctx)
40
40
 
41
41
  const tData = performance.now()
42
42
 
@@ -105,7 +105,7 @@ export function renderToStream(spec, ctx = {}, nonce = '') {
105
105
 
106
106
  // Await only the fetchers the shell segments need
107
107
  const shellServerState = await awaitFetchersForSegments(spec, shell, fetcherCache)
108
- const mergedShellState = mergeStoreKeys(spec, shellServerState, ctx.store)
108
+ const mergedShellState = applyCtxExtras(mergeStoreKeys(spec, shellServerState, ctx.store), ctx)
109
109
 
110
110
  // Write shell immediately — deferred fetchers may still be in-flight
111
111
  const shellHtml = renderNamedSegments(spec, shell, clientState, mergedShellState)
@@ -122,7 +122,7 @@ export function renderToStream(spec, ctx = {}, nonce = '') {
122
122
  // Promise.all runs them concurrently — the fastest segment wins.
123
123
  await Promise.all(deferred.map(async (key) => {
124
124
  const segServerState = await awaitFetchersForSegments(spec, [key], fetcherCache)
125
- const mergedSegState = mergeStoreKeys(spec, segServerState, ctx.store)
125
+ const mergedSegState = applyCtxExtras(mergeStoreKeys(spec, segServerState, ctx.store), ctx)
126
126
  const segHtml = renderNamedSegments(spec, [key], clientState, mergedSegState)
127
127
 
128
128
  // Inline script replaces the placeholder with the rendered content
@@ -145,7 +145,7 @@ export function renderToStream(spec, ctx = {}, nonce = '') {
145
145
  const allServerState = fetcherCache.size > 0
146
146
  ? Object.fromEntries(await Promise.all([...fetcherCache.entries()].map(async ([k, p]) => [k, await p])))
147
147
  : {}
148
- const mergedAllState = mergeStoreKeys(spec, allServerState, ctx.store)
148
+ const mergedAllState = applyCtxExtras(mergeStoreKeys(spec, allServerState, ctx.store), ctx)
149
149
  if (spec.hydrate && Object.keys(mergedAllState).length > 0) {
150
150
  controller.enqueue(encode(
151
151
  `<script nonce="${nonce}">window.__PULSE_SERVER__ = ${JSON.stringify(mergedAllState)};</script>`
@@ -218,7 +218,7 @@ export function renderToNavStream(spec, ctx = {}, resolvedMeta = {}) {
218
218
 
219
219
  // 2. Await shell fetchers then send shell HTML
220
220
  const shellServerState = await awaitFetchersForSegments(spec, shell, fetcherCache)
221
- const mergedShellState = mergeStoreKeys(spec, shellServerState, ctx.store)
221
+ const mergedShellState = applyCtxExtras(mergeStoreKeys(spec, shellServerState, ctx.store), ctx)
222
222
  let shellHtml = renderNamedSegments(spec, shell, clientState, mergedShellState)
223
223
 
224
224
  // Append <pulse-deferred> placeholders so the client knows where to inject deferred content
@@ -231,7 +231,7 @@ export function renderToNavStream(spec, ctx = {}, resolvedMeta = {}) {
231
231
  // 3. Stream each deferred segment as soon as its own fetchers resolve
232
232
  await Promise.all(deferred.map(async (key) => {
233
233
  const segServerState = await awaitFetchersForSegments(spec, [key], fetcherCache)
234
- const mergedSegState = mergeStoreKeys(spec, segServerState, ctx.store)
234
+ const mergedSegState = applyCtxExtras(mergeStoreKeys(spec, segServerState, ctx.store), ctx)
235
235
  const segHtml = renderNamedSegments(spec, [key], clientState, mergedSegState)
236
236
  writeLine({ type: 'deferred', id: key, html: segHtml })
237
237
  }))
@@ -240,7 +240,7 @@ export function renderToNavStream(spec, ctx = {}, resolvedMeta = {}) {
240
240
  const allServerState = fetcherCache.size > 0
241
241
  ? Object.fromEntries(await Promise.all([...fetcherCache.entries()].map(async ([k, p]) => [k, await p])))
242
242
  : {}
243
- const mergedAll = mergeStoreKeys(spec, allServerState, ctx.store)
243
+ const mergedAll = applyCtxExtras(mergeStoreKeys(spec, allServerState, ctx.store), ctx)
244
244
 
245
245
  writeLine({
246
246
  type: 'done',
@@ -426,6 +426,29 @@ function mergeStoreKeys(spec, serverState, storeState) {
426
426
  return { ...slice, ...serverState }
427
427
  }
428
428
 
429
+ /**
430
+ * Merge per-request extras set by the server into the view's server state:
431
+ *
432
+ * ctx._form → serverState.form — the return value of spec.submit() on a
433
+ * POST re-render (validation errors, submitted values, etc.)
434
+ * ctx._csrfField → serverState.csrf — ready-to-embed hidden <input> for CSRF;
435
+ * views include it inside <form method="POST"> as ${server.csrf}
436
+ *
437
+ * Only set by the server for specs that declare `submit` — other pages see
438
+ * neither key. Explicit server fetcher keys with the same names win.
439
+ *
440
+ * @param {Object} serverState
441
+ * @param {Object} ctx
442
+ * @returns {Object}
443
+ */
444
+ function applyCtxExtras(serverState, ctx) {
445
+ if (ctx?._form === undefined && ctx?._csrfField === undefined) return serverState
446
+ const extras = {}
447
+ if (ctx._form !== undefined) extras.form = ctx._form
448
+ if (ctx._csrfField !== undefined) extras.csrf = ctx._csrfField
449
+ return { ...extras, ...serverState }
450
+ }
451
+
429
452
  // ---------------------------------------------------------------------------
430
453
  // Internal — server state resolution
431
454
  // ---------------------------------------------------------------------------