@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.
- package/.claude/commands/build-page.md +1 -1
- package/.claude/commands/verify.md +6 -4
- package/.claude/pulse-checklist.md +10 -0
- package/CLAUDE.md +56 -2
- package/docs/.claude/pulse-checklist.md +10 -0
- package/docs/public/pulse-ui.css +8 -2
- package/docs/server.js +2 -0
- package/docs/src/lib/nav.js +2 -0
- package/docs/src/pages/actions.js +2 -0
- package/docs/src/pages/error-pages.js +98 -0
- package/docs/src/pages/forms.js +128 -0
- package/docs/src/pages/guard.js +1 -0
- package/docs/src/pages/routing.js +7 -9
- package/docs/src/pages/server-api.js +2 -1
- package/examples/.claude/pulse-checklist.md +10 -0
- package/examples/public/pulse-ui.css +52 -2
- package/package.json +2 -2
- package/public/.pulse-ui-version +1 -1
- package/public/pulse-ui.css +8 -2
- package/scripts/build.js +1 -204
- package/scripts/css-purge.js +241 -0
- package/scripts/css-purge.test.js +94 -0
- package/src/agent/checklist.md +10 -0
- package/src/agent/commands/verify.md +6 -4
- package/src/agent/guide-routing.md +21 -0
- package/src/agent/guide-server.md +34 -0
- package/src/agent/guide-spec.md +25 -0
- package/src/agent/guide-styles.md +3 -1
- package/src/agent/skills/build-page/SKILL.md +1 -1
- package/src/agent/skills/verify/SKILL.md +2 -2
- package/src/agent/workflow.md +15 -3
- package/src/mcp/mcp.test.js +55 -1
- package/src/mcp/server.js +82 -5
- package/src/runtime/ssr.js +30 -7
- package/src/server/index.js +119 -16
- package/src/server/server.test.js +200 -0
- package/src/spec/schema.js +20 -3
- package/src/spec/schema.test.js +33 -0
- package/src/ui/drop-cap.js +1 -1
- package/src/ui/gallery.js +5 -1
- package/src/ui/ui.test.js +34 -0
- package/types/schema.d.ts +31 -2
- package/types/server.d.ts +7 -0
package/src/agent/checklist.md
CHANGED
|
@@ -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.
|
|
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
|
-
|
|
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:
|
|
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
|
|
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.
|
package/src/agent/guide-spec.md
CHANGED
|
@@ -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?"*
|
|
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.
|
|
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
|
-
|
|
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
|
|
package/src/agent/workflow.md
CHANGED
|
@@ -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
|
-
- **
|
|
246
|
-
-
|
|
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:
|
package/src/mcp/mcp.test.js
CHANGED
|
@@ -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:
|
|
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
|
|
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
|
|
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
|
-
**
|
|
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
|
|
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, '
|
|
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
|
package/src/runtime/ssr.js
CHANGED
|
@@ -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
|
// ---------------------------------------------------------------------------
|