@invisibleloop/pulse 0.14.1 → 0.15.0

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 (46) hide show
  1. package/.claude/commands/build-page.md +5 -1
  2. package/.claude/commands/verify.md +23 -13
  3. package/docs/.claude/pulse-checklist.md +5 -3
  4. package/docs/public/.pulse-ui-version +1 -1
  5. package/docs/public/docs.css +236 -0
  6. package/docs/public/home-brut.css +998 -0
  7. package/docs/public/menu.js +237 -6
  8. package/docs/public/pulse-ui.css +44 -0
  9. package/docs/public/theme.css +8 -0
  10. package/docs/server.js +24 -0
  11. package/docs/src/lib/layout.js +51 -3
  12. package/docs/src/pages/getting-started.js +1 -1
  13. package/docs/src/pages/home.js +231 -312
  14. package/docs/src/pages/how-it-works.js +67 -12
  15. package/examples/.claude/commands/pulse-build.md +7 -0
  16. package/examples/.claude/commands/pulse-dev.md +7 -0
  17. package/examples/.claude/commands/pulse-start.md +9 -0
  18. package/examples/.claude/commands/pulse-stop.md +5 -0
  19. package/examples/.claude/commands/verify.md +84 -0
  20. package/examples/.claude/coverage-check.js +127 -0
  21. package/examples/.claude/pulse-checklist.md +177 -0
  22. package/examples/public/.pulse-ui-version +1 -0
  23. package/examples/public/pulse-ui.css +4031 -0
  24. package/examples/public/pulse-ui.js +257 -0
  25. package/package.json +1 -1
  26. package/public/.pulse-ui-version +1 -1
  27. package/public/pulse-ui.css +44 -0
  28. package/scripts/build.js +127 -21
  29. package/src/agent/checklist.md +5 -3
  30. package/src/agent/guide-components.md +2 -2
  31. package/src/agent/guide-examples.md +310 -32
  32. package/src/agent/guide-styles.md +15 -0
  33. package/src/agent/guide-templates.md +3 -0
  34. package/src/agent/identity.md +2 -1
  35. package/src/agent/skills/verify/SKILL.md +64 -28
  36. package/src/agent/workflow.md +53 -17
  37. package/src/cli/dev.js +49 -13
  38. package/src/cli/index.js +4 -10
  39. package/src/cli/logger.js +145 -0
  40. package/src/cli/scaffold.js +55 -1
  41. package/src/mcp/server.js +174 -26
  42. package/src/mcp/validate-worker.js +21 -6
  43. package/src/server/index.js +20 -16
  44. package/src/spec/schema.js +19 -3
  45. package/src/spec/schema.test.js +28 -0
  46. package/src/ui/button.js +1 -1
@@ -1,54 +1,332 @@
1
- ## Example page — contact form
1
+ ## Pattern: action with validation and field errors
2
2
 
3
- ```js
4
- import { nav, section, input, textarea, button, alert, container } from '@invisibleloop/pulse/ui'
5
- import { layout } from '../components/layout.js'
3
+ The correct wiring for a form action with per-field validation errors. `onStart` captures `FormData` before `validate` runs (uncontrolled inputs — never use `data-event` to mirror input values). `validate: true` runs the spec's `validation` rules before `run`. Field errors are surfaced via `state.errors`.
6
4
 
5
+ ```js
7
6
  export default {
8
- meta: {
9
- title: 'Contact',
10
- styles: ['/app.css', '/pulse-ui.css'],
11
- },
7
+ route: '/signup',
8
+ meta: { title: 'Sign up', styles: ['/pulse-ui.css', '/app.css'] },
12
9
  state: {
13
- status: 'idle',
10
+ status: 'idle', // 'idle' | 'loading' | 'success' | 'error'
14
11
  errors: [],
12
+ fields: { name: '', email: '' },
13
+ },
14
+ validation: {
15
+ 'fields.name': { required: true, minLength: 2 },
16
+ 'fields.email': { required: true, format: 'email' },
15
17
  },
16
18
  actions: {
17
- send: {
19
+ submit: {
18
20
  onStart: (state, formData) => ({
19
- status: 'loading',
20
- name: formData.get('name'),
21
- email: formData.get('email'),
22
- message: formData.get('message'),
21
+ status: 'loading',
22
+ errors: [],
23
+ fields: { name: formData.get('name') ?? '', email: formData.get('email') ?? '' },
23
24
  }),
25
+ validate: true,
24
26
  run: async (state, serverState, formData) => {
25
- const res = await fetch('/api/contact', { method: 'POST', body: formData })
26
- if (!res.ok) throw new Error(await res.text())
27
+ const res = await fetch('/api/signup', { method: 'POST', body: formData })
28
+ if (!res.ok) {
29
+ let message = `Request failed: ${res.status}`
30
+ try { const j = await res.json(); message = j.message || j.error || message } catch {}
31
+ throw new Error(message)
32
+ }
27
33
  return await res.json()
28
34
  },
29
- onSuccess: (state, result) => ({ status: 'success' }),
30
- onError: (state, err) => ({
35
+ onSuccess: (state, result) => ({
36
+ status: 'success',
37
+ _toast: { message: 'Account created!', variant: 'success' },
38
+ }),
39
+ onError: (state, err) => ({
31
40
  status: 'error',
32
41
  errors: err?.validation ?? [{ message: err.message }],
42
+ _toast: { message: 'Please check the form', variant: 'error' },
33
43
  }),
34
44
  },
35
45
  },
36
- view: (state) => layout(`
37
- ${container({ content: `
38
- ${section({ title: 'Contact Us', subtitle: 'We will get back to you within 24 hours.' })}
46
+ view: (state) => `
47
+ <main id="main-content">
39
48
  ${state.status === 'success'
40
- ? alert({ type: 'success', message: 'Message sent!' })
41
- : `<form data-action="send" class="u-flex u-flex-col u-gap-4">
42
- ${grid({ cols: 2, gap: 'md', content: `
43
- ${input({ name: 'name', label: 'Name', placeholder: 'Your name', required: true })}
44
- ${input({ name: 'email', label: 'Email', type: 'email', placeholder: 'you@example.com', required: true })}
45
- ` })}
46
- ${textarea({ name: 'message', label: 'Message', rows: 5, required: true })}
47
- ${state.errors.length ? alert({ type: 'error', message: state.errors[0].message }) : ''}
48
- ${button({ label: state.status === 'loading' ? 'Sending…' : 'Send', type: 'submit', variant: 'primary', fullWidth: true })}
49
+ ? `<p>You're signed up.</p>`
50
+ : `<form data-action="submit" class="u-flex u-flex-col u-gap-4">
51
+ ${input({ name: 'name', label: 'Name', value: state.fields.name,
52
+ error: state.errors.find(e => e.field === 'fields.name')?.message })}
53
+ ${input({ name: 'email', label: 'Email', type: 'email', value: state.fields.email,
54
+ error: state.errors.find(e => e.field === 'fields.email')?.message })}
55
+ ${button({ label: state.status === 'loading' ? 'Saving…' : 'Sign up',
56
+ type: 'submit', variant: 'primary',
57
+ attrs: state.status === 'loading' ? 'aria-busy="true" disabled' : '' })}
49
58
  </form>`
50
59
  }
51
- ` })}
52
- `),
60
+ </main>
61
+ `,
62
+ }
63
+ ```
64
+
65
+ **Key points:**
66
+ - `onStart` captures `FormData` into state so field values survive the re-render
67
+ - `validate: true` runs before `run()` — validation errors arrive in `onError` as `err.validation`
68
+ - Always check `err?.validation` first; fall back to `err.message`
69
+ - `aria-busy="true"` on the submit button while loading — spinner alone is not sufficient for accessibility
70
+ - `res.ok` checked before parsing; never use `await res.text()` as an error message
71
+
72
+ ---
73
+
74
+ ## Pattern: server data with dynamic route and missing-data fallback
75
+
76
+ The correct pattern for a dynamic route that loads data from a server fetcher. Always add `onViewError` — a missing or invalid param must not produce a 500.
77
+
78
+ ```js
79
+ import { md } from '@invisibleloop/pulse/md'
80
+ import { prose } from '@invisibleloop/pulse/ui'
81
+
82
+ const post = md('content/blog/:slug.md')
83
+
84
+ export default {
85
+ route: '/blog/:slug',
86
+ meta: {
87
+ title: async (ctx) => (await post(ctx)).frontmatter.title ?? 'Post not found',
88
+ description: async (ctx) => (await post(ctx)).frontmatter.description ?? '',
89
+ },
90
+ server: { post },
91
+ view: (state, server) => `
92
+ <main id="main-content">
93
+ <h1>${server.post.frontmatter.title}</h1>
94
+ ${prose({ content: server.post.html })}
95
+ </main>
96
+ `,
97
+ onViewError: (err) => `
98
+ <main id="main-content">
99
+ <p>Post not found. <a href="/blog">Back to blog</a></p>
100
+ </main>
101
+ `,
102
+ }
103
+ ```
104
+
105
+ **Key points:**
106
+ - Call the same `md()` fetcher in both `meta` and `server` — it caches on `ctx._mdCache`, file is read once
107
+ - `onViewError` is required on any dynamic route where the view can receive null/missing data
108
+ - For database-backed routes: validate `ctx.params.id` before use; return a 404-equivalent via `onViewError` if not found
109
+ - Use `?.` and `??` throughout the view when server data can be null
110
+
111
+ ---
112
+
113
+ ## Pattern: streaming SSR with scoped fetchers
114
+
115
+ Use streaming when a page has slow data (feeds, lists, analytics) and you want the shell to paint instantly. `scope` prevents slow fetchers from blocking the shell.
116
+
117
+ ```js
118
+ export default {
119
+ route: '/dashboard',
120
+ meta: { title: 'Dashboard', styles: ['/pulse-ui.css', '/app.css'] },
121
+ state: {},
122
+ server: {
123
+ user: async (ctx) => db.users.findByCookie(ctx.cookies.session), // fast
124
+ feed: async () => fetchFeed(), // slow
125
+ ads: async () => fetchAds(), // slow
126
+ },
127
+ stream: {
128
+ shell: ['header'],
129
+ deferred: ['feed'],
130
+ scope: {
131
+ header: ['user'], // shell sends as soon as 'user' resolves
132
+ feed: ['feed', 'ads'], // deferred sends when both resolve
133
+ },
134
+ },
135
+ view: {
136
+ header: (state, server) => `
137
+ <header>
138
+ <p>Hello, ${server.user?.name ?? 'Guest'}</p>
139
+ </header>
140
+ `,
141
+ feed: (state, server) => `
142
+ <ul>
143
+ ${server.feed.map(item => `<li data-key="${item.id}">${item.title}</li>`).join('')}
144
+ </ul>
145
+ `,
146
+ },
147
+ }
148
+ ```
149
+
150
+ **Key points:**
151
+ - `view` must be a named object of functions — not a single function — when using `stream`
152
+ - `scope` is optional but important: without it the shell waits for all fetchers
153
+ - `data-key` on list items enables O(1) keyed reconciliation when the list updates client-side
154
+ - A fetcher listed in `scope.header` but slow will delay the shell — keep shell fetchers fast
155
+
156
+ ---
157
+
158
+ ## Pattern: global store subscription and `_storeUpdate`
159
+
160
+ Use the global store for data shared across pages (user session, cart, theme). Read store data via `spec.store`; push changes back with `_storeUpdate` from `onSuccess`.
161
+
162
+ ```js
163
+ // Page that reads from store and updates it on save
164
+ export default {
165
+ route: '/settings',
166
+ store: ['user', 'settings'], // declares which store keys this page subscribes to
167
+ meta: { title: 'Settings', styles: ['/pulse-ui.css', '/app.css'] },
168
+ state: { status: 'idle' },
169
+ actions: {
170
+ save: {
171
+ onStart: (state, formData) => ({ status: 'loading' }),
172
+ run: async (state, serverState, formData) => {
173
+ const theme = formData.get('theme')
174
+ const res = await fetch('/api/settings', {
175
+ method: 'POST',
176
+ body: JSON.stringify({ theme }),
177
+ headers: { 'Content-Type': 'application/json' },
178
+ })
179
+ if (!res.ok) throw new Error(`Save failed: ${res.status}`)
180
+ return { theme }
181
+ },
182
+ onSuccess: (state, { theme }) => ({
183
+ status: 'idle',
184
+ _storeUpdate: { settings: { theme } }, // merged into store; all subscribed pages re-render
185
+ _toast: { message: 'Saved', variant: 'success' },
186
+ }),
187
+ onError: (state, err) => ({
188
+ status: 'error',
189
+ _toast: { message: err.message, variant: 'error' },
190
+ }),
191
+ },
192
+ },
193
+ view: (state, server) => `
194
+ <main id="main-content">
195
+ <form data-action="save" class="u-flex u-flex-col u-gap-4">
196
+ ${select({ name: 'theme', label: 'Theme',
197
+ options: [{ value: 'dark', label: 'Dark' }, { value: 'light', label: 'Light' }],
198
+ value: server.settings?.theme ?? 'dark' })}
199
+ ${button({ label: state.status === 'loading' ? 'Saving…' : 'Save', type: 'submit', variant: 'primary' })}
200
+ </form>
201
+ </main>
202
+ `,
203
+ }
204
+ ```
205
+
206
+ **Key points:**
207
+ - `spec.store` declares subscriptions — store data appears in the view's `server` argument
208
+ - `_storeUpdate` is stripped from page state and merged into the store — do not store it
209
+ - All pages subscribed to the affected store keys re-render without a server round-trip
210
+ - Never use a full page reload to propagate shared state changes — use `_storeUpdate`
211
+
212
+ ---
213
+
214
+ ## Pattern: guard with auth redirect
215
+
216
+ Use `guard` to protect routes. It runs before server fetchers. Return `{ redirect }` to send unauthenticated users elsewhere.
217
+
218
+ ```js
219
+ export default {
220
+ route: '/account',
221
+ meta: { title: 'Account', styles: ['/pulse-ui.css', '/app.css'] },
222
+ server: {
223
+ user: async (ctx) => db.users.find(ctx.cookies.session),
224
+ },
225
+ guard: async (ctx) => {
226
+ const user = await db.users.findByCookie(ctx.cookies.session)
227
+ if (!user) return { redirect: '/login?next=/account' }
228
+ },
229
+ state: {},
230
+ view: (state, server) => `
231
+ <main id="main-content">
232
+ <h1>Hello, ${server.user?.name}</h1>
233
+ </main>
234
+ `,
235
+ }
236
+ ```
237
+
238
+ **Key points:**
239
+ - `guard` runs before server fetchers — returning `{ redirect }` short-circuits the entire request
240
+ - Return nothing (or `undefined`) to allow the request through
241
+ - For shared auth logic, extract to a helper and call it from `guard` on each protected spec
242
+ - `ctx.cookies` is available in `guard` — check session cookies here, not in the view
243
+
244
+ ---
245
+
246
+ ## Pattern: keyed list with client mutations
247
+
248
+ Use `data-key` on repeated elements whenever a list can be reordered, filtered, or have items added/removed client-side.
249
+
250
+ ```js
251
+ export default {
252
+ route: '/todos',
253
+ meta: { title: 'Todos', styles: ['/pulse-ui.css', '/app.css'] },
254
+ state: {
255
+ items: [
256
+ { id: 1, text: 'Buy milk', done: false },
257
+ { id: 2, text: 'Write spec', done: false },
258
+ ],
259
+ filter: 'all', // 'all' | 'active' | 'done'
260
+ },
261
+ mutations: {
262
+ toggle: (state, e) => {
263
+ const id = Number(e.target.closest('[data-id]').dataset.id)
264
+ return { items: state.items.map(i => i.id === id ? { ...i, done: !i.done } : i) }
265
+ },
266
+ setFilter: (state, e) => ({ filter: e.target.value }),
267
+ },
268
+ view: (state) => {
269
+ const visible = state.items.filter(i =>
270
+ state.filter === 'all' ? true : state.filter === 'done' ? i.done : !i.done
271
+ )
272
+ return `
273
+ <main id="main-content">
274
+ <ul>
275
+ ${visible.map(item => `
276
+ <li data-key="${item.id}" data-id="${item.id}">
277
+ <button data-event="toggle" aria-pressed="${item.done}">${item.text}</button>
278
+ </li>
279
+ `).join('')}
280
+ </ul>
281
+ <select data-event="change:setFilter" aria-label="Filter todos">
282
+ <option value="all">All</option>
283
+ <option value="active">Active</option>
284
+ <option value="done">Done</option>
285
+ </select>
286
+ </main>
287
+ `
288
+ },
289
+ }
290
+ ```
291
+
292
+ **Key points:**
293
+ - `data-key` must be on every sibling element in the container — mixed keyed/unkeyed falls back to position matching
294
+ - Use `e.target.closest('[data-id]')` in mutations — never assume `e.target` is the element with the attribute
295
+ - `aria-pressed` on toggle buttons communicates state to screen readers
296
+ - Mutations are pure — no fetch, no DOM access, return partial state only
297
+
298
+ ---
299
+
300
+ ## Pattern: persist — what belongs and what doesn't
301
+
302
+ `persist` survives page reload via `localStorage`. Only serialisable, user-owned state that should survive a refresh belongs here.
303
+
304
+ ```js
305
+ export default {
306
+ route: '/builder',
307
+ meta: { title: 'Builder', styles: ['/pulse-ui.css', '/app.css'] },
308
+ state: {
309
+ // ✓ persist these — user-owned, meaningful across sessions
310
+ items: [],
311
+ activeStep: 0,
312
+ draft: { name: '', description: '' },
313
+
314
+ // ✗ do NOT persist these — ephemeral UI state
315
+ status: 'idle',
316
+ errors: [],
317
+ modalOpen: false,
318
+ },
319
+ persist: ['items', 'activeStep', 'draft'],
320
+ mutations: {
321
+ nextStep: (state) => ({ activeStep: Math.min(state.activeStep + 1, 3) }),
322
+ prevStep: (state) => ({ activeStep: Math.max(state.activeStep - 1, 0) }),
323
+ },
324
+ view: (state) => `<main id="main-content">…</main>`,
53
325
  }
54
326
  ```
327
+
328
+ **Key points:**
329
+ - Never persist `status`, `errors`, loading flags, or modal open state — these are session-only
330
+ - Never persist large objects or arrays that could grow unbounded — `localStorage` has a ~5MB limit
331
+ - Restored persist values override spec defaults on mount and trigger a re-render if different
332
+ - `persist` keys must exist in `state` — persisting a key not in state has no effect
@@ -89,6 +89,15 @@ meta: {
89
89
 
90
90
  **Colour tokens:** `--ui-bg`, `--ui-surface`, `--ui-surface-2`, `--ui-border`, `--ui-text`, `--ui-muted`, `--ui-accent`, `--ui-accent-hover`, `--ui-accent-dim`, `--ui-accent-text`, `--ui-green`, `--ui-red`, `--ui-yellow`, `--ui-blue`, `--ui-radius`, `--ui-radius-sm`, `--ui-font`, `--ui-font-display`, `--ui-mono`. Never hardcode hex values — override the tokens.
91
91
 
92
+ > **⚠ `--ui-muted` contrast on warm/light palettes** — The default `--ui-muted` value (`#60607a` in light theme) achieves 4.5:1 on the default white `--ui-bg`. When you set a warm or coloured background (`--bg: #f5e6d3` etc.) the luminance difference shrinks and `--ui-muted` can drop below the WCAG AA threshold of 4.5:1. **Always override `--muted` when changing `--bg`**, and run `pulse_check_contrast` immediately after writing your `theme.css`. A common failure mode: stat labels, nav links, and feature descriptions all use `--ui-muted` and will fail contrast silently if this token is not rechecked against your actual background colour.
93
+
94
+ > **⚠ `section--dark` in light theme** — `section({ variant: 'dark' })` uses `--ui-surface-2` as its background, which in the default light theme resolves to `#f0f0f8` — a light grey, not a dark background. The framework now auto-corrects this via a `[data-theme="light"] .ui-section--dark` override (navy `#1a1a2e`), and ghost buttons inside it get automatic light-text treatment. If you use a custom `--surface-2` override in your theme, set `--ui-dark-bg` and `--ui-dark-text` to pin the dark section to specific values:
95
+ > ```css
96
+ > [data-theme="light"] { --ui-dark-bg: #1a1a2e; --ui-dark-text: #e8e0d0; }
97
+ > ```
98
+
99
+ > **Ghost buttons on dark backgrounds** — use `variant: 'ghost-light'` instead of `variant: 'ghost'` whenever the button sits on a dark background (custom hero div, `section--dark`, navy sections). `ghost-light` renders white text with a subtle white border, maintaining 4.5:1 contrast regardless of the surrounding theme. `ghost` uses `--ui-muted` text which fails on dark backgrounds in light theme.
100
+
92
101
  **Display font token:** `--ui-font-display` is used for hero titles and section headings. By default it inherits `--ui-font`. Override it separately to get a display/heading face that differs from body text:
93
102
  ```css
94
103
  /* app.css */
@@ -219,6 +228,12 @@ createServer(specs, {
219
228
 
220
229
  > **picsum CDN note:** `picsum.photos` redirects image requests through `fastly.picsum.photos`. You must whitelist **both** domains — whitelisting only `https://picsum.photos` will still block the actual image bytes and the browser error will point to the `fastly.picsum.photos` URL, which is confusing to debug.
221
230
 
231
+ > **⚠ Unsplash and third-party cookies — Lighthouse Best Practices failure:** `images.unsplash.com` sets tracking cookies (`azk`, `azk-ss`) on every image request. These are classified as third-party cookies and cause Lighthouse Best Practices to drop to ~77, failing the 100 pass bar. **For production builds, download images locally instead of referencing Unsplash URLs directly.** Use `curl` to save images to `public/images/`:
232
+ > ```bash
233
+ > curl -sL "https://images.unsplash.com/photo-ID?w=900&q=85" -o public/images/hero.jpg
234
+ > ```
235
+ > Then reference `/images/hero.jpg` in your spec. This also improves performance (no external DNS lookup) and avoids rate limiting. `picsum.photos` does **not** set tracking cookies and is safe to use via CDN for prototypes.
236
+
222
237
  Without this, external images will be blocked in production and Lighthouse will flag a Best Practices failure. Use the same pattern for other external resource types (`media-src` for video, `connect-src` for fetch/XHR to external APIs).
223
238
 
224
239
  For multiple weights or italic variants, separate them with a semicolon in the URL:
@@ -89,9 +89,12 @@ Run `pulse_intake` before presenting the confirmation. It collects the product d
89
89
  6. "Any specific font, or system-ui?"
90
90
  7. "Light or dark theme preference?"
91
91
  8. "How should it feel visually? (e.g. warm and friendly, editorial and sharp, playful, minimal, bold — or describe in your own words)"
92
+ 9. "Do you have any design inspiration — a site you love, a screenshot, a mood board image? Drop images into `public/intake/` or paste a URL and I'll extract the design intent before we start."
92
93
 
93
94
  Ask each question and wait for the answer before asking the next. Do not present a list of preset choices for any of these — the answers are arbitrary text.
94
95
 
96
+ **Question 9 is not optional.** Always ask it. If the user provides an image in `public/intake/`, call `pulse_extract_inspiration` on it before calling `pulse_intake`. If they provide a URL, call `pulse_extract_inspiration` with the URL. If they say no or skip it, proceed without — but always ask.
97
+
95
98
  Once you have the answers, call `pulse_intake` with the collected details. It returns a product brief + early contrast warnings.
96
99
 
97
100
  **If `pulse_intake` is unavailable**, gather the answers via the questions above and use them directly when adapting the template — replace every placeholder with real content.
@@ -51,7 +51,8 @@ Every build task follows a fixed sequence of phases with explicit pass gates. Fe
51
51
  2. **Plan** — present your plan, wait for user confirmation (skip only for trivially small tasks)
52
52
  3. **Build** — write the spec and related files
53
53
  4. **Validate** — `pulse_validate` must be clean before continuing
54
- 5. **Browser** — screenshot + Lighthouse desktop + Lighthouse mobile, all 100/100/100 before continuing
54
+ 5. **Design approval** *(new builds only)* — take a screenshot, show the user, **wait for their go-ahead on layout and direction** before running any slow checks. For edits/fixes, skip this.
55
+ 5. **Verify** — `pulse_design_review` → `pulse_layout_review` → Lighthouse desktop + mobile, all 100/100/100 before continuing
55
56
  6. **Tests** — write and run tests, all must pass before continuing
56
57
  7. **Review Agent** — invoke only after phases 4–6 all pass
57
58
  8. **Fix** — fix every review issue, re-run any affected gates
@@ -1,12 +1,14 @@
1
1
  ---
2
2
  name: verify
3
- description: "Run the full verification loop on the current page or the page specified in $ARGUMENTS. If $ARGUMENTS is provided, use it as the file path or route. Otherwise, identify the most recently edited spec file from context. Use `pulse_validate` with the spec file content. If validation fails, report every error clearly and stop — do not proceed to screenshot until the spec is valid."
3
+ description: "Run the full verification loop on the current page or the page specified in $ARGUMENTS. If $ARGUMENTS is provided, use it as the file path or route. Otherwise, identify the most recently edited spec file from context."
4
4
  ---
5
5
 
6
6
  # Verify
7
7
 
8
8
  Run the full verification loop on the current page or the page specified in $ARGUMENTS.
9
9
 
10
+ **Output style:** be terse throughout. One sentence per step. Do not quote raw tool output — summarise findings only. Reserve detail for failures that need explaining.
11
+
10
12
  ## Steps
11
13
 
12
14
  ### 1. Identify the target
@@ -15,53 +17,87 @@ If $ARGUMENTS is provided, use it as the file path or route. Otherwise, identify
15
17
 
16
18
  ### 2. Validate the spec
17
19
 
18
- Use `pulse_validate` with the spec file content. If validation fails, report every error clearly and stop — do not proceed to screenshot until the spec is valid.
20
+ Run `pulse_validate` on the spec file. Report clean or list errors. Stop if there are errors — do not continue until the spec is valid.
19
21
 
20
22
  ### 3. Check the dev server
21
23
 
22
- Use `pulse_fetch_page` to confirm the server is responding for the route. If it errors, use `pulse_restart_server` and retry once.
24
+ Run `pulse_fetch_page` to confirm the server is responding. If it errors, run `pulse_restart_server` and retry once. Report HTTP status only — do not quote the HTML body.
25
+
26
+ **If the response has no `X-Pulse` header**, the wrong server is running on that port — stop and tell the user before continuing.
23
27
 
24
28
  ### 4. Screenshot
25
29
 
26
- Use the chrome-devtools navigate_page tool to load the page route, then take_screenshot to capture the result. Describe what you see — layout, content, any obvious rendering issues.
30
+ Navigate to the page route with `mcp__chrome-devtools__navigate_page`, then take a screenshot with `mcp__chrome-devtools__take_screenshot`. Describe what you see in 2–3 plain sentences — layout structure, visual tone, key sections. Do not list features or code details.
31
+
32
+ ### 4a. Design approval gate *(new builds only)*
33
+
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
+
36
+ 1. Show the screenshot to the user.
37
+ 2. Ask: *"Happy with the layout and direction, or any changes before I run Lighthouse?"*
38
+ 3. **Stop and wait for their response.** Do not proceed to step 4b or Lighthouse until the user explicitly approves.
39
+
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.
41
+
42
+ **For edits, bug fixes, or "add X to existing Y"** — skip this step and go straight to 4b.
43
+
44
+ ### 4b. Design review *(if `pulse_intake` ran)*
45
+
46
+ Run `pulse_design_review`. Work through every signal it returns. Fix any Fail before continuing. Report pass/fail count only — do not quote the full output.
47
+
48
+ ### 4c. Layout review
49
+
50
+ Run `pulse_layout_review` with the page URL. It checks 390/768/1280px viewports for overflow, broken images, and collapsed sections. Fix any failures before continuing. Report pass or list failures only.
51
+
52
+ ### 5. Production build
53
+
54
+ Run `pulse_build` to start the production server on port 3001. Tell the user: "Building for production — ~30s…" before calling it.
55
+
56
+ ### 6. Lighthouse — desktop
57
+
58
+ Navigate to `http://localhost:3001/<route>` with `mcp__chrome-devtools__navigate_page`, then run `mcp__chrome-devtools__lighthouse_audit` with `device: "desktop"`.
59
+
60
+ **Pass bar: Accessibility, Best Practices, and SEO must all be 100.** Report scores as a single line, e.g. `Accessibility 100 · Best Practices 100 · SEO 100`. If any score is below 100, identify the failing audit(s), fix, and restart from step 2.
61
+
62
+ ### 7. Lighthouse — mobile
27
63
 
28
- ### 5. Lighthouse — desktop
64
+ Run `mcp__chrome-devtools__lighthouse_audit` with `device: "mobile"` against the same production URL. Same pass bar. Report scores as a single line. Fix failures and restart from step 2 if needed.
29
65
 
30
- Run lighthouse_audit on the route in desktop mode.
66
+ **To inspect mobile layout** use `mcp__chrome-devtools__emulate` with `viewport: "390x844x2,mobile,touch"` — not `resize_page`. Reset with `"1440x900x1"` afterward.
31
67
 
32
- **Pass bar: Accessibility, Best Practices, and SEO must all be 100.** Report the actual scores. If any score is below 100, identify the failing audit(s), fix the issue, and restart from step 2.
68
+ ### 8. Performance
33
69
 
34
- ### 6. Lighthouse — mobile
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.
35
71
 
36
- Run lighthouse_audit on the same route in mobile mode.
72
+ ### 9. Console errors
37
73
 
38
- **Same pass bar: all three categories must be 100.** Report the actual scores. If any score is below 100, fix and restart from step 2.
74
+ Run `mcp__chrome-devtools__list_console_messages` filtered to errors and warnings. Report count only if clean. List any errors found.
39
75
 
40
- ### 7. Performance
76
+ ### 10. Code review
41
77
 
42
- Navigate to the page route, then run 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.).
78
+ Run `pulse_review` with the absolute path to the spec file. Work through every item in the checklist it returns. Fix every issue before proceeding. Report pass or list what was fixed — do not quote the full review output.
43
79
 
44
- ### 8. Console errors
80
+ ### 11. Close the browser
45
81
 
46
- Use list_console_messages — report any errors or unexpected warnings.
82
+ Run `mcp__chrome-devtools__list_pages`, then `mcp__chrome-devtools__close_page` for every page ID returned. `pageId` must be a JSON number, not a string.
47
83
 
48
- ### 9. Code review
84
+ ### 12. Write verification stamp
49
85
 
50
- Use `pulse_review` to switch into reviewer mode. Read the spec against the checklist in `.github/instructions/pulse-checklist.instructions.md`. Work through every item. Fix anything that fails before proceeding.
86
+ ```bash
87
+ date +%s > .pulse-verified
88
+ ```
51
89
 
52
- ### 10. Close the browser
90
+ **This must be the last write before stopping.** Do not edit any spec file after writing the stamp.
53
91
 
54
- Use list_pages to get all open pages, then call close_page for every page ID returned.
92
+ ### 13. Report
55
93
 
56
- ### 11. Report
94
+ One short paragraph covering: validation, Lighthouse desktop/mobile scores, LCP, CLS, console, review. Flag anything that needed fixing. No raw tool output.
57
95
 
58
- Summarise:
59
- - Validation: pass or fail (with errors if any)
60
- - Visual: what was visible in the screenshot
61
- - Lighthouse desktop: scores for all three categories
62
- - Lighthouse mobile: scores for all three categories
63
- - Performance: LCP and CLS values, any flagged insights
64
- - Console: any errors
65
- - Review: pass or issues found and fixed
96
+ **Pass bar — all must be met:**
97
+ - Validation: clean
98
+ - Lighthouse desktop: Accessibility, Best Practices, SEO all 100
99
+ - Lighthouse mobile: same
100
+ - CLS: 0.00
101
+ - Console: no errors
66
102
 
67
- Only confirm the page is good when validation passes, both Lighthouse runs are 100/100/100, CLS is 0.00, and there are no console errors. Otherwise, fix and run `/verify` again.
103
+ If any gate fails, fix and run `/verify` again.