@invisibleloop/pulse 0.14.2 → 0.15.1
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/verify.md +10 -4
- 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/package.json +1 -1
- package/public/.pulse-ui-version +1 -1
- package/scripts/build.js +127 -21
- package/src/agent/commands/verify.md +2 -6
- package/src/agent/guide-components.md +1 -1
- package/src/agent/guide-examples.md +310 -32
- package/src/agent/skills/verify/SKILL.md +2 -4
- package/src/agent/workflow.md +3 -1
- package/src/cli/dev.js +43 -0
- package/src/mcp/server.js +79 -27
- package/src/mcp/validate-worker.js +6 -0
- package/src/agent/guide.md +0 -1087
|
@@ -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
|
|
@@ -83,11 +83,9 @@ Run `mcp__chrome-devtools__list_pages`, then `mcp__chrome-devtools__close_page`
|
|
|
83
83
|
|
|
84
84
|
### 12. Write verification stamp
|
|
85
85
|
|
|
86
|
-
|
|
87
|
-
date +%s > .pulse-verified
|
|
88
|
-
```
|
|
86
|
+
Call `pulse_stamp`. This writes `.pulse-verified` via the MCP server, which is more reliable than `date +%s > .pulse-verified` — the MCP write always lands after all spec edits, avoiding the mtime race that causes the stop hook to block immediately after verification.
|
|
89
87
|
|
|
90
|
-
**This must be the last
|
|
88
|
+
**This must be the last operation before stopping.** Do not edit any spec file after calling `pulse_stamp`.
|
|
91
89
|
|
|
92
90
|
### 13. Report
|
|
93
91
|
|
package/src/agent/workflow.md
CHANGED
|
@@ -298,7 +298,9 @@ Run the tests. Fix every failure.
|
|
|
298
298
|
|
|
299
299
|
Only invoke `pulse_review` after phases 4–6 all pass. It checks the code for correctness, security, accessibility, DRY violations, and checklist adherence.
|
|
300
300
|
|
|
301
|
-
|
|
301
|
+
**Ordering:** `pulse_review` runs **after** Lighthouse, not before. Lighthouse catches contrast failures, missing landmarks, and Best Practices issues that `pulse_review` cannot detect statically. Fixing a Lighthouse failure may require spec edits — so the code review always comes last, against the final version of the code.
|
|
302
|
+
|
|
303
|
+
> Note: `/verify` includes the review step in the correct position — Lighthouse first (steps 6–7), then `pulse_review` (step 10). If you ran `/verify` and it passed, `pulse_review` has already run against the Lighthouse-passing version of the code.
|
|
302
304
|
|
|
303
305
|
---
|
|
304
306
|
|
package/src/cli/dev.js
CHANGED
|
@@ -140,6 +140,48 @@ process.on('SIGINT', closeReloadClients)
|
|
|
140
140
|
let reloadTimer = null
|
|
141
141
|
let lastReload = 0
|
|
142
142
|
|
|
143
|
+
/**
|
|
144
|
+
* Check rendered HTML class names against CSS files in public/ and warn about
|
|
145
|
+
* any classes that are used in templates but not defined in any stylesheet.
|
|
146
|
+
* Only checks project-owned CSS (not pulse-ui.css) to avoid noise.
|
|
147
|
+
*/
|
|
148
|
+
async function checkUndefinedClasses(specs) {
|
|
149
|
+
try {
|
|
150
|
+
// Collect all class names defined in project CSS files
|
|
151
|
+
const cssDir = path.join(ROOT, 'public')
|
|
152
|
+
const definedClasses = new Set()
|
|
153
|
+
if (fs.existsSync(cssDir)) {
|
|
154
|
+
for (const file of fs.readdirSync(cssDir).filter(f => f.endsWith('.css') && f !== 'pulse-ui.css')) {
|
|
155
|
+
const css = fs.readFileSync(path.join(cssDir, file), 'utf8')
|
|
156
|
+
for (const [, cls] of css.matchAll(/\.([a-zA-Z][a-zA-Z0-9_-]*)\s*[{,:\[]/g)) {
|
|
157
|
+
definedClasses.add(cls)
|
|
158
|
+
}
|
|
159
|
+
}
|
|
160
|
+
}
|
|
161
|
+
if (definedClasses.size === 0) return
|
|
162
|
+
|
|
163
|
+
// Render each spec and collect used class names from the HTML output
|
|
164
|
+
const { renderToString } = await import('../runtime/ssr.js')
|
|
165
|
+
for (const spec of specs) {
|
|
166
|
+
if (!spec.view) continue
|
|
167
|
+
try {
|
|
168
|
+
const { html } = await renderToString(spec, {})
|
|
169
|
+
const usedInSpec = new Set()
|
|
170
|
+
for (const [, classes] of html.matchAll(/class="([^"]+)"/g)) {
|
|
171
|
+
for (const cls of classes.trim().split(/\s+/)) {
|
|
172
|
+
if (cls && !cls.startsWith('ui-')) usedInSpec.add(cls)
|
|
173
|
+
}
|
|
174
|
+
}
|
|
175
|
+
const undefined_ = [...usedInSpec].filter(cls => !definedClasses.has(cls))
|
|
176
|
+
if (undefined_.length > 0) {
|
|
177
|
+
const route = spec.route ?? spec.filePath ?? '(unknown)'
|
|
178
|
+
log.warn(`${route}: class${undefined_.length === 1 ? '' : 'es'} used but not defined in any CSS: ${undefined_.slice(0, 5).map(c => `.${c}`).join(', ')}${undefined_.length > 5 ? ` … +${undefined_.length - 5} more` : ''}`)
|
|
179
|
+
}
|
|
180
|
+
} catch { /* render failures are not our concern here */ }
|
|
181
|
+
}
|
|
182
|
+
} catch { /* never crash the dev server over a lint warning */ }
|
|
183
|
+
}
|
|
184
|
+
|
|
143
185
|
async function triggerReload(label = 'File changed') {
|
|
144
186
|
clearTimeout(reloadTimer)
|
|
145
187
|
reloadTimer = setTimeout(async () => {
|
|
@@ -151,6 +193,7 @@ async function triggerReload(label = 'File changed') {
|
|
|
151
193
|
const fresh = await loadPages(ROOT, now)
|
|
152
194
|
updateSpecs(fresh)
|
|
153
195
|
log.info(`${label} — specs reloaded`)
|
|
196
|
+
checkUndefinedClasses(fresh)
|
|
154
197
|
} catch (err) {
|
|
155
198
|
log.error(`Spec reload failed: ${err.message}`)
|
|
156
199
|
}
|
package/src/mcp/server.js
CHANGED
|
@@ -18,6 +18,7 @@
|
|
|
18
18
|
* pulse_create_page — create a new page spec with proper template
|
|
19
19
|
* pulse_create_component — create a reusable component
|
|
20
20
|
* pulse_validate — validate a spec against the schema
|
|
21
|
+
* pulse_stamp — write the .pulse-verified stamp (call as last step of /verify)
|
|
21
22
|
* pulse_check_version — installed vs static vs npm latest
|
|
22
23
|
* pulse_update — re-copy pulse-ui assets from package → public/
|
|
23
24
|
*/
|
|
@@ -1042,12 +1043,16 @@ ${(() => {
|
|
|
1042
1043
|
}
|
|
1043
1044
|
|
|
1044
1045
|
// Check spec source
|
|
1045
|
-
// Hex colours in view —
|
|
1046
|
-
//
|
|
1046
|
+
// Hex colours in view — strip anchor hrefs and id attributes before checking so
|
|
1047
|
+
// href="#fixtures" / id="main-content" / href="#join" are never flagged as hex colours.
|
|
1048
|
+
// Only flag actual colour values: # followed by exactly 3 or 6 hex chars.
|
|
1047
1049
|
const viewBlock = source.slice(source.indexOf('view:'))
|
|
1048
|
-
const
|
|
1050
|
+
const viewBlockStripped = viewBlock
|
|
1051
|
+
.replace(/href=["']#[^"']*["']/g, 'href="#"') // href="#anchor" → href="#"
|
|
1052
|
+
.replace(/id=["'][^"']*["']/g, 'id=""') // id="foo" → id=""
|
|
1053
|
+
const hexInView = /#[0-9a-fA-F]{6}\b|#[0-9a-fA-F]{3}\b(?![0-9a-fA-F])/.test(viewBlockStripped)
|
|
1049
1054
|
if (hexInView) {
|
|
1050
|
-
checks.push('⚠ **Possible hex colour in view** — use var(--ui-*) tokens only
|
|
1055
|
+
checks.push('⚠ **Possible hex colour in view** — use var(--ui-*) tokens only')
|
|
1051
1056
|
} else {
|
|
1052
1057
|
checks.push('✓ No obvious hex colours in view')
|
|
1053
1058
|
}
|
|
@@ -1141,12 +1146,39 @@ Fix every issue you find. Then confirm what was changed.
|
|
|
1141
1146
|
|
|
1142
1147
|
**After confirming fixes: you are back in builder mode. Continue to the verification workflow — navigate to the page in the browser, take a screenshot, run Lighthouse desktop audit, run Lighthouse mobile audit. Do not stop at the review.**`)
|
|
1143
1148
|
|
|
1144
|
-
|
|
1145
|
-
|
|
1146
|
-
|
|
1149
|
+
}
|
|
1150
|
+
)
|
|
1151
|
+
|
|
1152
|
+
// ---------------------------------------------------------------------------
|
|
1153
|
+
// pulse_stamp — write the .pulse-verified stamp
|
|
1154
|
+
// ---------------------------------------------------------------------------
|
|
1155
|
+
// Called as the final step of /verify, after all Lighthouse gates pass and
|
|
1156
|
+
// pulse_review is complete. Using the MCP tool avoids the mtime race that can
|
|
1157
|
+
// occur when `date +%s > .pulse-verified` runs in the same second as the last
|
|
1158
|
+
// spec write, making the stamp appear older than the spec.
|
|
1159
|
+
//
|
|
1160
|
+
// The stop hook compares spec mtimeMs against stamp mtimeMs — not the content.
|
|
1161
|
+
// Writing via MCP ensures the stamp is always written after all spec edits.
|
|
1162
|
+
|
|
1163
|
+
server.registerTool(
|
|
1164
|
+
'pulse_stamp',
|
|
1165
|
+
{
|
|
1166
|
+
description: `Write the .pulse-verified stamp to clear the stop-hook verification gate.
|
|
1167
|
+
|
|
1168
|
+
Call this as the **last step** of /verify — after Lighthouse desktop + mobile both pass 100/100/100, pulse_review is complete, and no further spec edits will be made.
|
|
1169
|
+
|
|
1170
|
+
The stop hook compares each changed spec's mtime against this stamp. Any spec newer than the stamp blocks the session. Do NOT call this before Lighthouse or before fixing issues found in pulse_review.`,
|
|
1171
|
+
inputSchema: {},
|
|
1172
|
+
},
|
|
1173
|
+
() => {
|
|
1174
|
+
const stampPath = path.join(ROOT, '.pulse-verified')
|
|
1175
|
+
const ts = String(Math.floor(Date.now() / 1000))
|
|
1147
1176
|
try {
|
|
1148
|
-
fs.writeFileSync(
|
|
1149
|
-
|
|
1177
|
+
fs.writeFileSync(stampPath, ts, 'utf8')
|
|
1178
|
+
return text(`✓ .pulse-verified written (${ts}). Stop hook cleared — session can end.`)
|
|
1179
|
+
} catch (err) {
|
|
1180
|
+
return text(`Error writing .pulse-verified: ${err.message}\nFallback: run \`date +%s > .pulse-verified\` in Bash.`)
|
|
1181
|
+
}
|
|
1150
1182
|
}
|
|
1151
1183
|
)
|
|
1152
1184
|
|
|
@@ -2220,21 +2252,24 @@ The extracted values map directly to pulse_intake fields (palette, vibe, styleNo
|
|
|
2220
2252
|
({ source, focus = 'all' }) => {
|
|
2221
2253
|
const lines = []
|
|
2222
2254
|
lines.push(`# Inspiration Extraction — ${source}\n`)
|
|
2223
|
-
lines.push('
|
|
2224
|
-
lines.push('
|
|
2255
|
+
lines.push('**You must fill in this template now** — do not return it blank to the user.')
|
|
2256
|
+
lines.push('Use your browsing tool (for URLs) or vision capability (for images) to observe the source and populate every field you can determine.')
|
|
2257
|
+
lines.push('Leave a cell blank only if it is genuinely unobservable. When done, call `pulse_intake` with the extracted values.\n')
|
|
2225
2258
|
lines.push('---\n')
|
|
2226
2259
|
|
|
2227
2260
|
const isUrl = /^https?:\/\//.test(source) || /\.(com|io|co|app|design|net|org|dev)\b/.test(source)
|
|
2228
2261
|
|
|
2229
2262
|
if (isUrl) {
|
|
2230
|
-
lines.push('## Step 1 — Visit the site')
|
|
2263
|
+
lines.push('## Step 1 — Visit the site (do this now)')
|
|
2231
2264
|
lines.push(`Navigate to: **${source}**`)
|
|
2232
2265
|
lines.push('Observe the rendered page at desktop width (~1440px) and again at mobile (~375px).')
|
|
2233
|
-
lines.push('Take note of the above-the-fold area, then scroll through the full page
|
|
2266
|
+
lines.push('Take note of the above-the-fold area, then scroll through the full page.')
|
|
2267
|
+
lines.push('**Fill in the sections below based on what you observe. Return a completed extraction, not an empty template.**\n')
|
|
2234
2268
|
} else {
|
|
2235
|
-
lines.push('## Step 1 — Analyse the source')
|
|
2269
|
+
lines.push('## Step 1 — Analyse the source (do this now)')
|
|
2236
2270
|
lines.push(`Source: **${source}**`)
|
|
2237
|
-
lines.push('Use your vision capability or knowledge of this reference to fill in the template below
|
|
2271
|
+
lines.push('Use your vision capability or knowledge of this reference to fill in the template below.')
|
|
2272
|
+
lines.push('**Return a completed extraction with your observations filled in — not a blank template.**\n')
|
|
2238
2273
|
}
|
|
2239
2274
|
|
|
2240
2275
|
lines.push('---\n')
|
|
@@ -2322,7 +2357,9 @@ The extracted values map directly to pulse_intake fields (palette, vibe, styleNo
|
|
|
2322
2357
|
lines.push('})')
|
|
2323
2358
|
lines.push('```')
|
|
2324
2359
|
lines.push('')
|
|
2325
|
-
lines.push('
|
|
2360
|
+
lines.push('**Now fill in the template above with your observations, then call `pulse_sketch` — pass the layout direction observed above as context in your `brief`.**')
|
|
2361
|
+
lines.push('')
|
|
2362
|
+
lines.push('> Do not return this template blank. The user is waiting for a completed extraction.')
|
|
2326
2363
|
|
|
2327
2364
|
return text(lines.join('\n'))
|
|
2328
2365
|
}
|
|
@@ -2989,15 +3026,21 @@ Run this immediately after writing a theme file — before production build and
|
|
|
2989
3026
|
}
|
|
2990
3027
|
|
|
2991
3028
|
// Standard pairings to check — [foreground token, background token, label, isLargeText]
|
|
3029
|
+
// isLargeText=true → WCAG AA large text threshold (3:1); isLargeText=false → body text (4.5:1)
|
|
3030
|
+
// Muted text is checked at both thresholds: body text pairings always need 4.5:1.
|
|
3031
|
+
// When a muted pairing fails 4.5:1 but passes 3:1, the warning distinguishes the two
|
|
3032
|
+
// so designers know it can be used for captions/eyebrows (large) but not body copy (small).
|
|
2992
3033
|
const PAIRINGS = [
|
|
2993
|
-
['--ui-text', '--ui-bg', 'Body text on page background',
|
|
2994
|
-
['--ui-muted', '--ui-bg', 'Muted text on page background', false],
|
|
2995
|
-
['--ui-
|
|
2996
|
-
['--ui-
|
|
2997
|
-
['--ui-
|
|
2998
|
-
['--ui-
|
|
2999
|
-
['--ui-
|
|
3000
|
-
['--ui-text',
|
|
3034
|
+
['--ui-text', '--ui-bg', 'Body text on page background', false],
|
|
3035
|
+
['--ui-muted', '--ui-bg', 'Muted/secondary text on page background (body size)', false],
|
|
3036
|
+
['--ui-muted', '--ui-bg', 'Muted text on page background (large text / captions)', true],
|
|
3037
|
+
['--ui-accent', '--ui-bg', 'Accent text on page background', false],
|
|
3038
|
+
['--ui-text', '--ui-surface', 'Body text on card/surface', false],
|
|
3039
|
+
['--ui-muted', '--ui-surface', 'Muted text on card/surface (body size)', false],
|
|
3040
|
+
['--ui-muted', '--ui-surface', 'Muted text on card/surface (large text / captions)', true],
|
|
3041
|
+
['--ui-accent-text', '--ui-accent', 'Button text on accent background', false],
|
|
3042
|
+
['--ui-accent', '--ui-surface', 'Accent text on surface', false],
|
|
3043
|
+
['--ui-text', '--ui-surface-2','Text on nested surface', false],
|
|
3001
3044
|
]
|
|
3002
3045
|
|
|
3003
3046
|
const checkSet = (vars, context) => {
|
|
@@ -3082,12 +3125,19 @@ Run this immediately after writing a theme file — before production build and
|
|
|
3082
3125
|
lines.push(`**${r.label}** — ${r.context}`)
|
|
3083
3126
|
lines.push(` ${r.fg} on ${r.bg}`)
|
|
3084
3127
|
lines.push(` Ratio: ${r.ratio}:1 · Needed: ${r.threshold}:1 · Level: ${r.level}`)
|
|
3085
|
-
|
|
3128
|
+
|
|
3129
|
+
// Distinguish between body and large-text failures for --ui-muted
|
|
3130
|
+
if (r.label.includes('large text') || r.label.includes('captions')) {
|
|
3131
|
+
lines.push(` Context: large text / captions (3:1 threshold) — fails even the relaxed threshold.`)
|
|
3132
|
+
} else if (r.label.includes('body size') || r.threshold === 4.5) {
|
|
3133
|
+
lines.push(` Context: body text (4.5:1 threshold) — only use this colour for large text (≥18pt / ≥14pt bold) or decorative elements, not for body copy or captions.`)
|
|
3134
|
+
}
|
|
3135
|
+
|
|
3086
3136
|
// Suggest a corrected hex value
|
|
3087
3137
|
if (r.fgHex && r.bgLum !== null) {
|
|
3088
3138
|
const suggested = suggestContrastFix(r.fgHex, r.bgLum, r.threshold)
|
|
3089
3139
|
if (suggested) {
|
|
3090
|
-
lines.push(`
|
|
3140
|
+
lines.push(` Suggested fix: use ${suggested} instead of ${r.fgHex}`)
|
|
3091
3141
|
}
|
|
3092
3142
|
}
|
|
3093
3143
|
lines.push('')
|
|
@@ -3105,8 +3155,9 @@ Run this immediately after writing a theme file — before production build and
|
|
|
3105
3155
|
if (failures.length > 0) {
|
|
3106
3156
|
lines.push('---')
|
|
3107
3157
|
lines.push('**Fix guidance:**')
|
|
3158
|
+
lines.push('• **WCAG AA thresholds:** 4.5:1 for body text (≤18pt regular / ≤14pt bold); 3:1 for large text (≥18pt / ≥14pt bold), UI components, and decorative elements.')
|
|
3108
3159
|
lines.push('• Mid-tone accents on near-white backgrounds often fail 4.5:1. Darken the accent token or use it only for large text/icons (3:1 threshold).')
|
|
3109
|
-
lines.push('• For `--ui-muted` failures: muted text on `--ui-bg` needs
|
|
3160
|
+
lines.push('• For `--ui-muted` "body size" failures: muted text on `--ui-bg` needs 4.5:1 for body copy. If you only use this colour for eyebrows, captions, or large labels (≥18pt), the 3:1 "large text" row is the relevant gate — check whether that row passes.')
|
|
3110
3161
|
lines.push('• Light-theme `--ui-accent` must be distinctly darker than the page background — many default accent hues are too light.')
|
|
3111
3162
|
lines.push('• After fixing, run this tool again before `pulse_build`.')
|
|
3112
3163
|
}
|
|
@@ -3410,6 +3461,7 @@ const PULSE_GUIDE_INDEX = `# Pulse Framework Guide
|
|
|
3410
3461
|
- \`pulse_create_store\` — register a pulse.store.js you wrote with the Write tool. Write the file first, then call this.
|
|
3411
3462
|
- \`pulse_create_action\` — generate a correctly-structured action snippet.
|
|
3412
3463
|
- \`pulse_run_tests\` — run the project test suite (npm test). Use after writing or editing specs.
|
|
3464
|
+
- \`pulse_stamp\` — write the \`.pulse-verified\` stamp. Call as the **last step** of \`/verify\`, after Lighthouse and \`pulse_review\` both pass. Using this MCP tool avoids the mtime race that \`date +%s > .pulse-verified\` can cause when the stamp and the last spec write land in the same filesystem second.
|
|
3413
3465
|
- \`pulse_fetch_page(url)\` — HTTP GET the dev server URL. Use to verify SSR output.
|
|
3414
3466
|
- \`pulse_restart_server\` — stop and restart the dev server.
|
|
3415
3467
|
- \`pulse_build\` — production build + starts prod server on devPort+1 for Lighthouse. Returns the URL. Call \`pulse_restart_server\` after to return to dev. **Slow — takes 30–60 s. Tell the user before calling.**
|
|
@@ -150,6 +150,12 @@ try {
|
|
|
150
150
|
warnings.push('Inline <style> block detected in view — Pulse\'s CSP (style-src with nonces) will silently block these styles at runtime. Move styles to public/app.css instead.')
|
|
151
151
|
}
|
|
152
152
|
|
|
153
|
+
// Inline <script> blocks in view — blocked by Pulse CSP nonce policy
|
|
154
|
+
// meta.scripts is the correct way to add JS: it receives the server nonce automatically.
|
|
155
|
+
if (/<script[\s>]/i.test(html)) {
|
|
156
|
+
warnings.push('Inline <script> block detected in view — Pulse\'s CSP (script-src with nonces) will block these scripts at runtime. Move JavaScript to a file in public/ and reference it via meta.scripts: [\'/my-script.js\'] in the spec instead.')
|
|
157
|
+
}
|
|
158
|
+
|
|
153
159
|
// Hardcoded hex colours in view HTML — must use var(--ui-*) tokens instead.
|
|
154
160
|
// Matches: style="...color:#hex..." or style="...background:#hex..."
|
|
155
161
|
// Allows: href="#section-id" (anchor links are not colours)
|