@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.
- package/.claude/commands/build-page.md +5 -1
- package/.claude/commands/verify.md +23 -13
- package/docs/.claude/pulse-checklist.md +5 -3
- package/docs/public/.pulse-ui-version +1 -1
- package/docs/public/docs.css +236 -0
- package/docs/public/home-brut.css +998 -0
- package/docs/public/menu.js +237 -6
- package/docs/public/pulse-ui.css +44 -0
- package/docs/public/theme.css +8 -0
- package/docs/server.js +24 -0
- package/docs/src/lib/layout.js +51 -3
- package/docs/src/pages/getting-started.js +1 -1
- package/docs/src/pages/home.js +231 -312
- package/docs/src/pages/how-it-works.js +67 -12
- package/examples/.claude/commands/pulse-build.md +7 -0
- package/examples/.claude/commands/pulse-dev.md +7 -0
- package/examples/.claude/commands/pulse-start.md +9 -0
- package/examples/.claude/commands/pulse-stop.md +5 -0
- package/examples/.claude/commands/verify.md +84 -0
- package/examples/.claude/coverage-check.js +127 -0
- package/examples/.claude/pulse-checklist.md +177 -0
- package/examples/public/.pulse-ui-version +1 -0
- package/examples/public/pulse-ui.css +4031 -0
- package/examples/public/pulse-ui.js +257 -0
- package/package.json +1 -1
- package/public/.pulse-ui-version +1 -1
- package/public/pulse-ui.css +44 -0
- package/scripts/build.js +127 -21
- package/src/agent/checklist.md +5 -3
- package/src/agent/guide-components.md +2 -2
- package/src/agent/guide-examples.md +310 -32
- package/src/agent/guide-styles.md +15 -0
- package/src/agent/guide-templates.md +3 -0
- package/src/agent/identity.md +2 -1
- package/src/agent/skills/verify/SKILL.md +64 -28
- package/src/agent/workflow.md +53 -17
- package/src/cli/dev.js +49 -13
- package/src/cli/index.js +4 -10
- package/src/cli/logger.js +145 -0
- package/src/cli/scaffold.js +55 -1
- package/src/mcp/server.js +174 -26
- package/src/mcp/validate-worker.js +21 -6
- package/src/server/index.js +20 -16
- package/src/spec/schema.js +19 -3
- package/src/spec/schema.test.js +28 -0
- package/src/ui/button.js +1 -1
|
@@ -1,54 +1,332 @@
|
|
|
1
|
-
##
|
|
1
|
+
## Pattern: action with validation and field errors
|
|
2
2
|
|
|
3
|
-
|
|
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
|
-
|
|
9
|
-
|
|
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
|
-
|
|
19
|
+
submit: {
|
|
18
20
|
onStart: (state, formData) => ({
|
|
19
|
-
status:
|
|
20
|
-
|
|
21
|
-
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/
|
|
26
|
-
if (!res.ok)
|
|
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) => ({
|
|
30
|
-
|
|
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) =>
|
|
37
|
-
|
|
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
|
-
?
|
|
41
|
-
: `<form data-action="
|
|
42
|
-
${
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
${
|
|
47
|
-
|
|
48
|
-
|
|
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.
|
package/src/agent/identity.md
CHANGED
|
@@ -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. **
|
|
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.
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
68
|
+
### 8. Performance
|
|
33
69
|
|
|
34
|
-
|
|
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
|
-
|
|
72
|
+
### 9. Console errors
|
|
37
73
|
|
|
38
|
-
|
|
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
|
-
###
|
|
76
|
+
### 10. Code review
|
|
41
77
|
|
|
42
|
-
|
|
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
|
-
###
|
|
80
|
+
### 11. Close the browser
|
|
45
81
|
|
|
46
|
-
|
|
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
|
-
###
|
|
84
|
+
### 12. Write verification stamp
|
|
49
85
|
|
|
50
|
-
|
|
86
|
+
```bash
|
|
87
|
+
date +%s > .pulse-verified
|
|
88
|
+
```
|
|
51
89
|
|
|
52
|
-
|
|
90
|
+
**This must be the last write before stopping.** Do not edit any spec file after writing the stamp.
|
|
53
91
|
|
|
54
|
-
|
|
92
|
+
### 13. Report
|
|
55
93
|
|
|
56
|
-
|
|
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
|
-
|
|
59
|
-
- Validation:
|
|
60
|
-
-
|
|
61
|
-
- Lighthouse
|
|
62
|
-
-
|
|
63
|
-
-
|
|
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
|
-
|
|
103
|
+
If any gate fails, fix and run `/verify` again.
|