@invisibleloop/pulse 0.12.2 → 0.14.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 +9 -5
- package/.claude/pulse-checklist.md +21 -1
- package/.github/workflows/publish.yml +1 -0
- package/.pulse-verified +1 -0
- package/CLAUDE.md +56 -15
- package/docs/.claude/commands/verify.md +4 -0
- package/docs/.claude/pulse-checklist.md +36 -2
- package/docs/public/.pulse-ui-version +1 -1
- package/docs/public/andypixel.css +237 -0
- package/docs/public/bookworms.css +422 -0
- package/docs/public/coutumes.css +584 -0
- package/docs/public/images/bookworms/hero-books.jpg +0 -0
- package/docs/public/images/bookworms/how-we-read-now.jpg +0 -0
- package/docs/public/images/bookworms/lantern-keepers.jpg +0 -0
- package/docs/public/images/bookworms/provincial-bookseller.jpg +0 -0
- package/docs/public/images/journey/178.jpg +0 -0
- package/docs/public/images/journey/29.jpg +0 -0
- package/docs/public/images/retrowheels/hero.jpg +0 -0
- package/docs/public/images/shop/devbook-pro.jpg +0 -0
- package/docs/public/images/suzukibento/1015.jpg +0 -0
- package/docs/public/images/suzukibento/1025.jpg +0 -0
- package/docs/public/images/suzukibento/1043.jpg +0 -0
- package/docs/public/images/suzukibento/1059.jpg +0 -0
- package/docs/public/images/suzukibento/1062.jpg +0 -0
- package/docs/public/images/suzukibento/1084.jpg +0 -0
- package/docs/public/images/swish/1469334031218-e382a71b716b.jpg +0 -0
- package/docs/public/images/swish/1483985988355-763728e1935b.jpg +0 -0
- package/docs/public/images/swish/1485518882345-15568b007407.jpg +0 -0
- package/docs/public/images/swish/1490481651871-ab68de25d43d.jpg +0 -0
- package/docs/public/images/swish/1496747611176-843222e1e57c.jpg +0 -0
- package/docs/public/images/swish/1502716119720-b23a93e5fe1b.jpg +0 -0
- package/docs/public/images/swish/1539109136881-3be0616acf4b.jpg +0 -0
- package/docs/public/images/swish/1551803091-e20673f15770.jpg +0 -0
- package/docs/public/images/swish/1558769132-cb1aea458c5e.jpg +0 -0
- package/docs/public/images/swish/1572804013309-59a88b7e92f1.jpg +0 -0
- package/docs/public/journey.css +528 -0
- package/docs/public/pulse-ui.css +1051 -25
- package/docs/public/pulse-ui.js +6 -0
- package/docs/public/retrowheels.css +402 -0
- package/docs/public/shop.css +235 -0
- package/docs/public/sparkys.css +349 -0
- package/docs/public/suzukibento.css +517 -0
- package/docs/public/swish.css +394 -0
- package/docs/server.js +19 -4
- package/docs/src/lib/nav.js +14 -3
- package/docs/src/pages/components/decorate.js +74 -0
- package/docs/src/pages/components/gallery.js +102 -0
- package/docs/src/pages/components/hero.js +1 -0
- package/docs/src/pages/components/marquee.js +76 -0
- package/docs/src/pages/components/phone-frame.js +26 -7
- package/docs/src/pages/components/photo-card.js +74 -0
- package/docs/src/pages/components/section.js +40 -1
- package/docs/src/pages/meta.js +26 -0
- package/docs/src/pages/templates/andypixel.js +260 -0
- package/docs/src/pages/templates/bookworms.js +227 -0
- package/docs/src/pages/templates/coutumes.js +226 -0
- package/docs/src/pages/templates/journey.js +186 -0
- package/docs/src/pages/templates/retrowheels.js +342 -0
- package/docs/src/pages/templates/shop.js +257 -0
- package/docs/src/pages/templates/sparkys.js +249 -0
- package/docs/src/pages/templates/suzukibento.js +200 -0
- package/docs/src/pages/templates/swish.js +313 -0
- package/docs/src/pages/templates.js +313 -2
- package/docs/src/pages/testing.js +2 -1
- package/examples/andypixel.css +237 -0
- package/examples/andypixel.js +258 -0
- package/examples/bookworms.css +422 -0
- package/examples/bookworms.js +225 -0
- package/examples/contact.js +1 -0
- package/examples/counter.js +1 -0
- package/examples/dev.server.js +17 -0
- package/examples/journey.css +528 -0
- package/examples/journey.js +186 -0
- package/examples/pricing.js +1 -0
- package/examples/quiz.js +1 -0
- package/examples/retrowheels.css +402 -0
- package/examples/retrowheels.js +340 -0
- package/examples/shared.js +6 -0
- package/examples/suzukibento.css +513 -0
- package/examples/suzukibento.js +200 -0
- package/examples/swish.css +394 -0
- package/examples/swish.js +311 -0
- package/examples/todos.js +1 -0
- package/package.json +1 -1
- package/public/.pulse-ui-version +1 -1
- package/public/images/bookworms/hero-books.jpg +0 -0
- package/public/images/bookworms/how-we-read-now.jpg +0 -0
- package/public/images/bookworms/lantern-keepers.jpg +0 -0
- package/public/images/bookworms/provincial-bookseller.jpg +0 -0
- package/public/images/journey/178.jpg +0 -0
- package/public/images/journey/29.jpg +0 -0
- package/public/images/retrowheels/hero.jpg +0 -0
- package/public/images/shop/devbook-pro.jpg +0 -0
- package/public/images/suzukibento/1015.jpg +0 -0
- package/public/images/suzukibento/1025.jpg +0 -0
- package/public/images/suzukibento/1043.jpg +0 -0
- package/public/images/suzukibento/1059.jpg +0 -0
- package/public/images/suzukibento/1062.jpg +0 -0
- package/public/images/suzukibento/1084.jpg +0 -0
- package/public/images/swish/1469334031218-e382a71b716b.jpg +0 -0
- package/public/images/swish/1483985988355-763728e1935b.jpg +0 -0
- package/public/images/swish/1485518882345-15568b007407.jpg +0 -0
- package/public/images/swish/1490481651871-ab68de25d43d.jpg +0 -0
- package/public/images/swish/1496747611176-843222e1e57c.jpg +0 -0
- package/public/images/swish/1502716119720-b23a93e5fe1b.jpg +0 -0
- package/public/images/swish/1539109136881-3be0616acf4b.jpg +0 -0
- package/public/images/swish/1551803091-e20673f15770.jpg +0 -0
- package/public/images/swish/1558769132-cb1aea458c5e.jpg +0 -0
- package/public/images/swish/1572804013309-59a88b7e92f1.jpg +0 -0
- package/public/pulse-ui.css +1051 -22
- package/public/pulse-ui.js +6 -0
- package/scripts/build.js +72 -18
- package/src/agent/checklist.md +20 -2
- package/src/agent/commands/verify.md +4 -0
- package/src/agent/guide-components.md +146 -8
- package/src/agent/guide-design-gallery.md +288 -0
- package/src/agent/guide-design-references.md +172 -0
- package/src/agent/guide-explore.md +313 -0
- package/src/agent/guide-styles.md +197 -11
- package/src/agent/guide-templates.md +220 -10
- package/src/agent/guide.md +9 -4
- package/src/agent/workflow.md +208 -58
- package/src/cli/dev.js +39 -7
- package/src/cli/fmt.js +113 -0
- package/src/cli/index.js +63 -30
- package/src/cli/scaffold.js +2 -2
- package/src/html.js +6 -1
- package/src/mcp/server.js +2415 -101
- package/src/mcp/validate-worker.js +34 -5
- package/src/runtime/ssr.js +4 -1
- package/src/server/index.js +22 -2
- package/src/spec/schema.js +5 -0
- package/src/testing/html.js +130 -4
- package/src/testing/testing.test.js +58 -0
- package/src/ui/banner.js +24 -9
- package/src/ui/brand-statement.js +58 -0
- package/src/ui/button.js +12 -1
- package/src/ui/card.js +20 -1
- package/src/ui/colophon.js +29 -0
- package/src/ui/contact-row.js +41 -0
- package/src/ui/cta.js +18 -4
- package/src/ui/decorate.js +55 -0
- package/src/ui/display-heading.js +44 -0
- package/src/ui/drop-cap.js +29 -0
- package/src/ui/editorial-hero.js +63 -0
- package/src/ui/entry-list.js +36 -0
- package/src/ui/feature.js +20 -3
- package/src/ui/footer.js +78 -9
- package/src/ui/gallery.js +56 -0
- package/src/ui/hero.js +68 -18
- package/src/ui/image-pair.js +72 -0
- package/src/ui/index.js +20 -0
- package/src/ui/input.js +1 -0
- package/src/ui/marquee.js +48 -0
- package/src/ui/nav.js +19 -9
- package/src/ui/phone-frame.js +6 -5
- package/src/ui/photo-card.js +44 -0
- package/src/ui/product-card.js +65 -0
- package/src/ui/pullquote.js +14 -3
- package/src/ui/search.js +1 -0
- package/src/ui/section-label.js +38 -0
- package/src/ui/section.js +1 -1
- package/src/ui/service-card.js +47 -0
- package/src/ui/stat-grid.js +40 -0
- package/src/ui/stat.js +16 -2
- package/src/ui/testimonial.js +17 -6
- package/src/ui/textarea.js +1 -0
- package/src/ui/top-strip.js +47 -0
- package/src/ui/trust-list.js +46 -0
- package/src/ui/ui.test.js +35 -0
package/src/mcp/server.js
CHANGED
|
@@ -8,12 +8,18 @@
|
|
|
8
8
|
* pulse://guide — complete guide: spec format, UI components, CSS rules, patterns
|
|
9
9
|
*
|
|
10
10
|
* Tools:
|
|
11
|
-
*
|
|
12
|
-
*
|
|
11
|
+
* pulse_intent — intent engine: describe what to build, get archetype + scaffold
|
|
12
|
+
* pulse_suggest — draft-mode contextual feedback on partial specs
|
|
13
|
+
* pulse_intake — product intake: capture app details before scaffolding
|
|
14
|
+
* pulse_sketch — generate 3 structural layout directions before writing code
|
|
15
|
+
* pulse_list_icons — list all available icon names, grouped by category
|
|
16
|
+
* pulse_check_contrast — static WCAG contrast check on theme CSS colors
|
|
17
|
+
* pulse_list_structure — list all pages and components
|
|
18
|
+
* pulse_create_page — create a new page spec with proper template
|
|
13
19
|
* pulse_create_component — create a reusable component
|
|
14
|
-
* pulse_validate
|
|
15
|
-
* pulse_check_version
|
|
16
|
-
* pulse_update
|
|
20
|
+
* pulse_validate — validate a spec against the schema
|
|
21
|
+
* pulse_check_version — installed vs static vs npm latest
|
|
22
|
+
* pulse_update — re-copy pulse-ui assets from package → public/
|
|
17
23
|
*/
|
|
18
24
|
|
|
19
25
|
import { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js'
|
|
@@ -43,6 +49,48 @@ const PKG_VERSION = JSON.parse(
|
|
|
43
49
|
fs.readFileSync(new URL('../../package.json', import.meta.url).pathname, 'utf8')
|
|
44
50
|
).version
|
|
45
51
|
|
|
52
|
+
// Common synonym → canonical vibe normalisation
|
|
53
|
+
// Prevents hard enum errors when agents use intuitive names like "modern-minimal"
|
|
54
|
+
const VIBE_SYNONYMS = {
|
|
55
|
+
'modern': 'minimal',
|
|
56
|
+
'modern-minimal': 'minimal',
|
|
57
|
+
'clean': 'minimal',
|
|
58
|
+
'sleek': 'minimal',
|
|
59
|
+
'stark': 'minimal',
|
|
60
|
+
'newspaper': 'editorial',
|
|
61
|
+
'magazine': 'editorial',
|
|
62
|
+
'typographic': 'editorial',
|
|
63
|
+
'serif': 'editorial',
|
|
64
|
+
'fun': 'playful',
|
|
65
|
+
'playful-bold': 'playful',
|
|
66
|
+
'energetic': 'bold',
|
|
67
|
+
'impactful': 'bold',
|
|
68
|
+
'strong': 'bold',
|
|
69
|
+
'raw': 'brutalist',
|
|
70
|
+
'grunge': 'brutalist',
|
|
71
|
+
'vintage': 'retro',
|
|
72
|
+
'nostalgic': 'retro',
|
|
73
|
+
'classic': 'retro',
|
|
74
|
+
'futuristic': 'neon',
|
|
75
|
+
'cyber': 'neon',
|
|
76
|
+
'dark-tech': 'neon',
|
|
77
|
+
'journal': 'paper',
|
|
78
|
+
'organic': 'paper',
|
|
79
|
+
'handmade': 'paper',
|
|
80
|
+
'friendly': 'warm',
|
|
81
|
+
'cosy': 'warm',
|
|
82
|
+
'cozy': 'warm',
|
|
83
|
+
'professional': 'corporate',
|
|
84
|
+
'business': 'corporate',
|
|
85
|
+
'enterprise': 'corporate',
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
function normaliseVibe(v) {
|
|
89
|
+
if (!v) return v
|
|
90
|
+
const lower = v.toLowerCase().replace(/[_\s]+/g, '-')
|
|
91
|
+
return VIBE_SYNONYMS[lower] || v
|
|
92
|
+
}
|
|
93
|
+
|
|
46
94
|
// ---------------------------------------------------------------------------
|
|
47
95
|
// Server
|
|
48
96
|
// ---------------------------------------------------------------------------
|
|
@@ -110,6 +158,27 @@ const GUIDE_RESOURCES = [
|
|
|
110
158
|
description: 'Ready-made page templates. Fetch this when the user asks to build a landing page, marketing site, or branded template — it lists triggers, pre-build questions, and adaptation rules for each template.',
|
|
111
159
|
content: () => GUIDE_TEMPLATES,
|
|
112
160
|
},
|
|
161
|
+
{
|
|
162
|
+
name: 'guide-design-references',
|
|
163
|
+
uri: 'pulse://guide/design-references',
|
|
164
|
+
title: 'Pulse Guide — Design Directions & Aesthetic Vocabulary',
|
|
165
|
+
description: '12 named design directions (warm local business, editorial, brutalist, event, portfolio, etc.) with vibe presets, component combinations, palette patterns, and signature moves. Fetch when choosing an aesthetic approach for a new project.',
|
|
166
|
+
content: () => GUIDE_DESIGN_REF,
|
|
167
|
+
},
|
|
168
|
+
{
|
|
169
|
+
name: 'guide-design-gallery',
|
|
170
|
+
uri: 'pulse://guide/design-gallery',
|
|
171
|
+
title: 'Pulse Guide — Design Gallery & Prop Reference',
|
|
172
|
+
description: 'Curated catalogue of all 6 templates with visual descriptions, vibes, CSS themes, and key components. Also includes component combination recipes (image card, article card, stat strip, credentials list, booking form) and a critical prop-name reference (content vs children, name vs author, question vs title, etc.).',
|
|
173
|
+
content: () => GUIDE_DESIGN_GALL,
|
|
174
|
+
},
|
|
175
|
+
{
|
|
176
|
+
name: 'guide-explore',
|
|
177
|
+
uri: 'pulse://guide/explore',
|
|
178
|
+
title: 'Pulse Guide — Blank-Canvas Layout Thinking',
|
|
179
|
+
description: 'Escape template defaults: zone-based layout thinking, emotional intent, 7 structural gestures (full-bleed, asymmetric split, typography-only, editorial flow, dense grid, story scroll, content-first), raw HTML patterns with zero components, CSS token reference, and an anti-pattern checklist. Read when the user wants something truly distinctive or pulse_sketch returns an unusual direction.',
|
|
180
|
+
content: () => GUIDE_EXPLORE,
|
|
181
|
+
},
|
|
113
182
|
]
|
|
114
183
|
|
|
115
184
|
for (const { name, uri, title, description, content } of GUIDE_RESOURCES) {
|
|
@@ -186,7 +255,10 @@ server.registerTool(
|
|
|
186
255
|
].filter(Boolean)
|
|
187
256
|
|
|
188
257
|
const tagStr = tags.length ? ` [${tags.join(' | ')}]` : ''
|
|
189
|
-
|
|
258
|
+
const hydrateStr = spec.hydrate
|
|
259
|
+
? path.relative(ROOT, spec.hydrate.replace('/src/', 'src/'))
|
|
260
|
+
: '(server-only)'
|
|
261
|
+
lines.push(` ${route.padEnd(24)} → ${hydrateStr}${tagStr}`)
|
|
190
262
|
}
|
|
191
263
|
}
|
|
192
264
|
|
|
@@ -220,6 +292,145 @@ server.registerTool(
|
|
|
220
292
|
}
|
|
221
293
|
)
|
|
222
294
|
|
|
295
|
+
// ---------------------------------------------------------------------------
|
|
296
|
+
// pulse_status
|
|
297
|
+
// ---------------------------------------------------------------------------
|
|
298
|
+
|
|
299
|
+
server.registerTool(
|
|
300
|
+
'pulse_status',
|
|
301
|
+
{
|
|
302
|
+
description: 'Project health snapshot — pages, routes, server status, last build. Call at the start of a session to orient quickly without reading files.',
|
|
303
|
+
inputSchema: {},
|
|
304
|
+
},
|
|
305
|
+
async () => {
|
|
306
|
+
const lines = []
|
|
307
|
+
|
|
308
|
+
// ── Pages ──────────────────────────────────────────────────────────────
|
|
309
|
+
const specs = await loadPages(ROOT)
|
|
310
|
+
const components = findComponents()
|
|
311
|
+
lines.push(`Pages: ${specs.length}`)
|
|
312
|
+
for (const s of specs) {
|
|
313
|
+
const tags = [
|
|
314
|
+
s.server && 'server',
|
|
315
|
+
s.mutations && 'mutations',
|
|
316
|
+
s.actions && 'actions',
|
|
317
|
+
s.store && 'store',
|
|
318
|
+
s.stream && 'streaming',
|
|
319
|
+
].filter(Boolean)
|
|
320
|
+
const tagStr = tags.length ? ` [${tags.join(', ')}]` : ''
|
|
321
|
+
lines.push(` ${(s.route || '?').padEnd(26)}${tagStr}`)
|
|
322
|
+
}
|
|
323
|
+
|
|
324
|
+
lines.push(`\nComponents: ${components.length}`)
|
|
325
|
+
if (components.length > 0) {
|
|
326
|
+
lines.push(components.map(c => ` ${c.name}`).join('\n'))
|
|
327
|
+
}
|
|
328
|
+
|
|
329
|
+
// ── Dev server ─────────────────────────────────────────────────────────
|
|
330
|
+
let port = 3000
|
|
331
|
+
const configPath = path.join(ROOT, 'pulse.config.js')
|
|
332
|
+
if (fs.existsSync(configPath)) {
|
|
333
|
+
try {
|
|
334
|
+
const mod = await import(`${configPath}?t=${Date.now()}`)
|
|
335
|
+
if (mod.default?.port) port = mod.default.port
|
|
336
|
+
} catch { /* use default */ }
|
|
337
|
+
}
|
|
338
|
+
const serverRunning = await new Promise(res => {
|
|
339
|
+
const req = http.get(`http://localhost:${port}/`, r => { r.resume(); res(true) })
|
|
340
|
+
req.on('error', () => res(false))
|
|
341
|
+
req.setTimeout(1500, () => { req.destroy(); res(false) })
|
|
342
|
+
})
|
|
343
|
+
lines.push(`\nDev server: ${serverRunning ? `running on port ${port}` : `not running (port ${port})`}`)
|
|
344
|
+
|
|
345
|
+
// ── Last build ─────────────────────────────────────────────────────────
|
|
346
|
+
const manifestPath = path.join(ROOT, 'public', 'dist', 'manifest.json')
|
|
347
|
+
if (fs.existsSync(manifestPath)) {
|
|
348
|
+
const mtime = fs.statSync(manifestPath).mtime
|
|
349
|
+
const age = Math.round((Date.now() - mtime) / 1000)
|
|
350
|
+
const ageStr = age < 60 ? `${age}s ago`
|
|
351
|
+
: age < 3600 ? `${Math.round(age / 60)}m ago`
|
|
352
|
+
: `${Math.round(age / 3600)}h ago`
|
|
353
|
+
|
|
354
|
+
const manifest = JSON.parse(fs.readFileSync(manifestPath, 'utf8'))
|
|
355
|
+
const bundles = Object.values(manifest).filter(v => v.endsWith('.js') && !v.includes('runtime') && !v.includes('menu') && !v.includes('pulse-ui'))
|
|
356
|
+
const cssFiles = Object.values(manifest).filter(v => v.endsWith('.css'))
|
|
357
|
+
lines.push(`\nLast build: ${ageStr}`)
|
|
358
|
+
lines.push(` ${bundles.length} JS bundle${bundles.length !== 1 ? 's' : ''}, ${cssFiles.length} CSS file${cssFiles.length !== 1 ? 's' : ''}`)
|
|
359
|
+
} else {
|
|
360
|
+
lines.push('\nLast build: never (run pulse build first)')
|
|
361
|
+
}
|
|
362
|
+
|
|
363
|
+
// ── pulse-ui version ───────────────────────────────────────────────────
|
|
364
|
+
const stampPath = path.join(ROOT, 'public', '.pulse-ui-version')
|
|
365
|
+
const syncedVersion = fs.existsSync(stampPath) ? fs.readFileSync(stampPath, 'utf8').trim() : null
|
|
366
|
+
const versionMatch = syncedVersion === PKG_VERSION
|
|
367
|
+
lines.push(`\npulse-ui: ${versionMatch ? `v${PKG_VERSION} ✓` : `OUT OF DATE (project: ${syncedVersion || 'unknown'}, installed: v${PKG_VERSION})`}`)
|
|
368
|
+
|
|
369
|
+
// ── Last verified stamp ────────────────────────────────────────────────
|
|
370
|
+
const verifyStampPath = path.join(ROOT, '.pulse-verified')
|
|
371
|
+
if (fs.existsSync(verifyStampPath)) {
|
|
372
|
+
const stampSecs = parseInt(fs.readFileSync(verifyStampPath, 'utf8').trim(), 10)
|
|
373
|
+
const stampAge = Math.round((Date.now() / 1000) - stampSecs)
|
|
374
|
+
const stampStr = stampAge < 60 ? `${stampAge}s ago`
|
|
375
|
+
: stampAge < 3600 ? `${Math.round(stampAge / 60)}m ago`
|
|
376
|
+
: `${Math.round(stampAge / 3600)}h ago`
|
|
377
|
+
|
|
378
|
+
// Check if any spec was edited after the stamp
|
|
379
|
+
const pagesDir = path.join(ROOT, 'src', 'pages')
|
|
380
|
+
let staleSince = null
|
|
381
|
+
if (fs.existsSync(pagesDir)) {
|
|
382
|
+
const checkDir = (dir) => {
|
|
383
|
+
for (const entry of fs.readdirSync(dir)) {
|
|
384
|
+
const full = path.join(dir, entry)
|
|
385
|
+
if (fs.statSync(full).isDirectory()) { checkDir(full); continue }
|
|
386
|
+
if (!entry.endsWith('.js') || entry.endsWith('.test.js')) continue
|
|
387
|
+
const mtime = fs.statSync(full).mtimeMs / 1000
|
|
388
|
+
if (mtime > stampSecs && (!staleSince || mtime > staleSince)) staleSince = mtime
|
|
389
|
+
}
|
|
390
|
+
}
|
|
391
|
+
try { checkDir(pagesDir) } catch { /* ignore */ }
|
|
392
|
+
}
|
|
393
|
+
if (staleSince) {
|
|
394
|
+
const editAge = Math.round((Date.now() / 1000) - staleSince)
|
|
395
|
+
const editStr = editAge < 60 ? `${editAge}s ago` : editAge < 3600 ? `${Math.round(editAge / 60)}m ago` : `${Math.round(editAge / 3600)}h ago`
|
|
396
|
+
lines.push(`\nLast verified: ${stampStr} ⚠ spec edited ${editStr} after stamp — run /verify`)
|
|
397
|
+
} else {
|
|
398
|
+
lines.push(`\nLast verified: ${stampStr} ✓`)
|
|
399
|
+
}
|
|
400
|
+
} else {
|
|
401
|
+
lines.push('\nLast verified: never — run /verify before marking work done')
|
|
402
|
+
}
|
|
403
|
+
|
|
404
|
+
// ── Served title check ─────────────────────────────────────────────────
|
|
405
|
+
// Fetch the served HTML and compare its <title> against the spec's meta.title.
|
|
406
|
+
// A mismatch means the dev server is serving a different project — a common
|
|
407
|
+
// confusion source when multiple Pulse projects share the same port.
|
|
408
|
+
if (serverRunning && specs.length > 0) {
|
|
409
|
+
const rootSpec = specs.find(s => s.route === '/' || s.route === '')
|
|
410
|
+
if (rootSpec?.meta?.title && typeof rootSpec.meta.title === 'string') {
|
|
411
|
+
try {
|
|
412
|
+
const servedHtml = await new Promise((resolve, reject) => {
|
|
413
|
+
let body = ''
|
|
414
|
+
const req = http.get(`http://localhost:${port}/`, res => {
|
|
415
|
+
res.on('data', chunk => { body += chunk; if (body.length > 4096) { req.destroy(); resolve(body) } })
|
|
416
|
+
res.on('end', () => resolve(body))
|
|
417
|
+
})
|
|
418
|
+
req.on('error', reject)
|
|
419
|
+
req.setTimeout(2000, () => { req.destroy(); reject(new Error('timeout')) })
|
|
420
|
+
})
|
|
421
|
+
const titleMatch = servedHtml.match(/<title>([^<]*)<\/title>/)
|
|
422
|
+
const servedTitle = titleMatch?.[1]?.trim()
|
|
423
|
+
if (servedTitle && servedTitle !== rootSpec.meta.title) {
|
|
424
|
+
lines.push(`\n⚠ Server title mismatch: served "${servedTitle}" but spec says "${rootSpec.meta.title}". The dev server may be serving a different project — run pulse_restart_server.`)
|
|
425
|
+
}
|
|
426
|
+
} catch { /* ignore — network error, non-critical */ }
|
|
427
|
+
}
|
|
428
|
+
}
|
|
429
|
+
|
|
430
|
+
return text(lines.join('\n'))
|
|
431
|
+
}
|
|
432
|
+
)
|
|
433
|
+
|
|
223
434
|
// ---------------------------------------------------------------------------
|
|
224
435
|
// pulse_validate
|
|
225
436
|
// ---------------------------------------------------------------------------
|
|
@@ -228,9 +439,28 @@ server.registerTool(
|
|
|
228
439
|
'pulse_validate',
|
|
229
440
|
{
|
|
230
441
|
description: 'Validate a Pulse spec before writing it. Returns errors or confirms the spec is valid.',
|
|
231
|
-
inputSchema: {
|
|
442
|
+
inputSchema: {
|
|
443
|
+
content: z.string().optional().describe('JavaScript spec content to validate'),
|
|
444
|
+
file: z.string().optional().describe('Absolute path to spec file to validate'),
|
|
445
|
+
},
|
|
232
446
|
},
|
|
233
|
-
async ({ content }) =>
|
|
447
|
+
async ({ content, file }) => {
|
|
448
|
+
if (!content && !file) {
|
|
449
|
+
return text('Error: must provide either content or file')
|
|
450
|
+
}
|
|
451
|
+
if (content && file) {
|
|
452
|
+
return text('Error: provide only one of content or file, not both')
|
|
453
|
+
}
|
|
454
|
+
|
|
455
|
+
if (file) {
|
|
456
|
+
if (!fs.existsSync(file)) {
|
|
457
|
+
return text(`File not found: ${file}`)
|
|
458
|
+
}
|
|
459
|
+
content = fs.readFileSync(file, 'utf8')
|
|
460
|
+
}
|
|
461
|
+
|
|
462
|
+
return validateContent(content)
|
|
463
|
+
}
|
|
234
464
|
)
|
|
235
465
|
|
|
236
466
|
// ---------------------------------------------------------------------------
|
|
@@ -445,7 +675,8 @@ server.registerTool(
|
|
|
445
675
|
res.on('data', d => chunks.push(d))
|
|
446
676
|
res.on('end', () => {
|
|
447
677
|
const body = Buffer.concat(chunks).toString('utf-8')
|
|
448
|
-
|
|
678
|
+
const reminder = res.statusCode === 200 ? `\n\n---\n⚠ POST-BUILD CHECKLIST — run these before telling the user you're done:\n1. Take a screenshot with chrome-devtools\n2. pulse_design_review (if you ran pulse_intake)\n3. pulse_layout_review <url>\n4. /verify ← runs Lighthouse desktop + mobile + pulse_review + writes stamp\nDo NOT skip to reporting done after a screenshot.` : ''
|
|
679
|
+
resolve(text(`HTTP ${res.statusCode}\n\n${body.slice(0, 8000)}${reminder}`))
|
|
449
680
|
})
|
|
450
681
|
})
|
|
451
682
|
req.on('error', e => resolve(text(`Error fetching page: ${e.message}`)))
|
|
@@ -550,24 +781,51 @@ and returns a structured review brief. You must read everything carefully, find
|
|
|
550
781
|
issue, and fix them all before reporting back to the user. Use this after completing
|
|
551
782
|
any feature build.`,
|
|
552
783
|
inputSchema: {
|
|
553
|
-
file: z.string().describe('Absolute path to the spec file to review'),
|
|
784
|
+
file: z.string().optional().describe('Absolute path to the spec file to review'),
|
|
785
|
+
content: z.string().optional().describe('JavaScript spec content to review (alternative to file)'),
|
|
554
786
|
},
|
|
555
787
|
},
|
|
556
|
-
async ({ file }) => {
|
|
557
|
-
if (!
|
|
788
|
+
async ({ file, content }) => {
|
|
789
|
+
if (!file && !content) {
|
|
790
|
+
return text('Error: must provide either file or content')
|
|
791
|
+
}
|
|
792
|
+
if (file && content) {
|
|
793
|
+
return text('Error: provide only one of file or content, not both')
|
|
794
|
+
}
|
|
558
795
|
|
|
559
|
-
|
|
796
|
+
let source = content
|
|
797
|
+
if (file) {
|
|
798
|
+
if (!fs.existsSync(file)) return text(`File not found: ${file}`)
|
|
799
|
+
source = fs.readFileSync(file, 'utf8')
|
|
800
|
+
}
|
|
560
801
|
|
|
561
802
|
// Run the validator in a child process (same as pulse_validate)
|
|
562
|
-
const validatorScript = new URL('./validate-worker.js', import.meta.url).pathname
|
|
563
803
|
let validationResult = '(could not run validator)'
|
|
564
|
-
|
|
565
|
-
|
|
566
|
-
|
|
567
|
-
|
|
568
|
-
|
|
569
|
-
|
|
570
|
-
|
|
804
|
+
if (file) {
|
|
805
|
+
const validatorScript = new URL('./validate-worker.js', import.meta.url).pathname
|
|
806
|
+
try {
|
|
807
|
+
validationResult = execFileSync(process.execPath, [validatorScript, file], {
|
|
808
|
+
timeout: 10_000,
|
|
809
|
+
encoding: 'utf8',
|
|
810
|
+
}).trim()
|
|
811
|
+
} catch (err) {
|
|
812
|
+
validationResult = err.stdout?.trim() || err.message
|
|
813
|
+
}
|
|
814
|
+
} else {
|
|
815
|
+
// Content-only mode — write to temp file and validate
|
|
816
|
+
const tmpFile = path.join(os.tmpdir(), `pulse-review-${Date.now()}.js`)
|
|
817
|
+
fs.writeFileSync(tmpFile, source, 'utf8')
|
|
818
|
+
const validatorScript = new URL('./validate-worker.js', import.meta.url).pathname
|
|
819
|
+
try {
|
|
820
|
+
validationResult = execFileSync(process.execPath, [validatorScript, tmpFile], {
|
|
821
|
+
timeout: 10_000,
|
|
822
|
+
encoding: 'utf8',
|
|
823
|
+
}).trim()
|
|
824
|
+
} catch (err) {
|
|
825
|
+
validationResult = err.stdout?.trim() || err.message
|
|
826
|
+
} finally {
|
|
827
|
+
try { fs.unlinkSync(tmpFile) } catch {}
|
|
828
|
+
}
|
|
571
829
|
}
|
|
572
830
|
|
|
573
831
|
// Try to render the view with initial state
|
|
@@ -591,37 +849,80 @@ any feature build.`,
|
|
|
591
849
|
|
|
592
850
|
return text(`# Pulse Code Review
|
|
593
851
|
|
|
594
|
-
You are now a **senior code reviewer**.
|
|
595
|
-
|
|
596
|
-
Work through each section of the checklist below. For every issue you find, fix it immediately before moving on. Do not report issues without fixing them. When you have fixed everything, confirm what you changed.
|
|
852
|
+
You are now a **senior code reviewer**. Read the spec, find every problem, and fix them all before reporting back.
|
|
597
853
|
|
|
598
854
|
---
|
|
599
855
|
|
|
600
|
-
##
|
|
856
|
+
## Validator output
|
|
857
|
+
|
|
858
|
+
${validationResult}
|
|
859
|
+
|
|
860
|
+
${validationResult.includes('✓') ? '' : `
|
|
861
|
+
## Spec source (validation failed — showing for debugging)
|
|
601
862
|
|
|
602
863
|
\`\`\`js
|
|
603
864
|
${source}
|
|
604
865
|
\`\`\`
|
|
866
|
+
`}
|
|
605
867
|
|
|
606
|
-
|
|
868
|
+
${renderNote.includes('could not') ? `
|
|
869
|
+
## Render error
|
|
607
870
|
|
|
608
|
-
|
|
871
|
+
${renderNote}
|
|
609
872
|
|
|
610
|
-
\`\`\`
|
|
611
|
-
${
|
|
873
|
+
\`\`\`js
|
|
874
|
+
${source}
|
|
612
875
|
\`\`\`
|
|
876
|
+
` : ''}
|
|
613
877
|
|
|
614
878
|
---
|
|
615
879
|
|
|
616
|
-
##
|
|
617
|
-
|
|
618
|
-
${
|
|
880
|
+
## Auto-checked items
|
|
881
|
+
|
|
882
|
+
${(() => {
|
|
883
|
+
const checks = []
|
|
884
|
+
|
|
885
|
+
// Check rendered HTML
|
|
886
|
+
if (renderedHtml) {
|
|
887
|
+
// Positive tabindex
|
|
888
|
+
const posTabindex = /tabindex=["']?([1-9]\d*)/.test(renderedHtml)
|
|
889
|
+
checks.push(posTabindex ? '✗ **Positive tabindex found** — remove tabindex > 0, reorder DOM instead' : '✓ No positive tabindex')
|
|
890
|
+
|
|
891
|
+
// data-event on input
|
|
892
|
+
const dataEventInput = /<input[^>]*data-event/.test(renderedHtml)
|
|
893
|
+
checks.push(dataEventInput ? '✗ **data-event on <input>** — this destroys focus on every keystroke' : '✓ No data-event on text inputs')
|
|
894
|
+
|
|
895
|
+
// React patterns
|
|
896
|
+
const reactPatterns = /(className|htmlFor|onClick)=/.test(renderedHtml)
|
|
897
|
+
checks.push(reactPatterns ? '✗ **React patterns found** — use class, for, data-event instead' : '✓ No React patterns (className/htmlFor/onClick)')
|
|
898
|
+
|
|
899
|
+
// Emoji in HTML
|
|
900
|
+
const emojiRegex = /[\u{1F600}-\u{1F64F}\u{1F300}-\u{1F5FF}\u{1F680}-\u{1F6FF}\u{1F700}-\u{1F77F}\u{1F780}-\u{1F7FF}\u{1F800}-\u{1F8FF}\u{1F900}-\u{1F9FF}\u{1FA00}-\u{1FA6F}\u{1FA70}-\u{1FAFF}\u{2600}-\u{26FF}\u{2700}-\u{27BF}]/u
|
|
901
|
+
const hasEmoji = emojiRegex.test(renderedHtml)
|
|
902
|
+
checks.push(hasEmoji ? '✗ **Emoji in HTML** — use icon components or aria-label instead' : '✓ No emoji in view HTML')
|
|
903
|
+
|
|
904
|
+
// Main landmark
|
|
905
|
+
const hasMain = /<main[^>]*id=["']?main-content/.test(renderedHtml)
|
|
906
|
+
checks.push(hasMain ? '✓ <main id="main-content"> present' : '✗ **Missing main landmark** — add <main id="main-content">')
|
|
907
|
+
}
|
|
908
|
+
|
|
909
|
+
// Check spec source
|
|
910
|
+
// Hex colours in view (rough check — may have false positives from comments)
|
|
911
|
+
const hexInView = /#[0-9a-fA-F]{3,6}/.test(source) && source.includes('view:')
|
|
912
|
+
if (hexInView) {
|
|
913
|
+
checks.push('⚠ **Possible hex colour in view** — use var(--ui-*) tokens only')
|
|
914
|
+
} else {
|
|
915
|
+
checks.push('✓ No obvious hex colours in view')
|
|
916
|
+
}
|
|
917
|
+
|
|
918
|
+
return checks.join('\n')
|
|
919
|
+
})()}
|
|
619
920
|
|
|
620
921
|
---
|
|
621
922
|
|
|
622
923
|
## Review checklist
|
|
623
924
|
|
|
624
|
-
Work through every item. Fix anything that fails.
|
|
925
|
+
Work through every item. Fix anything that fails. Refer to the spec source at ${file} as needed — do not ask me to paste it.
|
|
625
926
|
|
|
626
927
|
### Structure
|
|
627
928
|
- [ ] \`route\` is set explicitly — not left to auto-discovery
|
|
@@ -640,10 +941,20 @@ Work through every item. Fix anything that fails.
|
|
|
640
941
|
|
|
641
942
|
### Components & HTML
|
|
642
943
|
- [ ] Components from the UI library are used — no hand-written \`<button>\`, \`<input>\`, \`<table>\` etc where a component exists
|
|
944
|
+
- [ ] **No reinvented component patterns** — if the view contains any of these class names, you MUST refactor to use the component:
|
|
945
|
+
- \`.hero\`, \`.-hero\`, \`__hero\` → use \`hero()\` component
|
|
946
|
+
- \`.card\`, \`.product-card\`, \`.service-card\` → use \`card()\` component
|
|
947
|
+
- Any two-column image + text layout → use \`media()\` component
|
|
948
|
+
- \`.banner\`, \`.strip\`, \`.bar\` → use \`banner()\` component
|
|
949
|
+
- \`.feature\`, \`.feature-card\` → use \`feature()\` component
|
|
950
|
+
- \`.testimonial\` → use \`testimonial()\` component
|
|
951
|
+
|
|
952
|
+
Grep the rendered HTML for these patterns. If found, rewrite using the component. Custom utility classes on top of components are fine (\`hero({ ... })\` + override CSS) — but do not write the entire structure from scratch.
|
|
643
953
|
- [ ] No \`data-event\` on text inputs — this destroys focus on every keystroke
|
|
644
954
|
- [ ] No \`className\`, \`htmlFor\`, \`onClick=\`, or other React patterns
|
|
645
955
|
- [ ] No hardcoded hex colours — only \`var(--ui-*)\` tokens
|
|
646
956
|
- [ ] No emoji in the view HTML
|
|
957
|
+
- [ ] **CTA must be wrapped in section/container** — grep the rendered HTML for \`<div class="ui-cta"\`. If it appears as a direct child of \`<main>\` or \`<div id="app">\` without a section or container wrapper, wrap it. CTA has no padding of its own.
|
|
647
958
|
|
|
648
959
|
### Accessibility
|
|
649
960
|
- [ ] \`<main id="main-content">\` is present
|
|
@@ -682,6 +993,13 @@ Work through every item. Fix anything that fails.
|
|
|
682
993
|
Fix every issue you find. Then confirm what was changed.
|
|
683
994
|
|
|
684
995
|
**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.**`)
|
|
996
|
+
|
|
997
|
+
// Write the .pulse-verified stamp from the MCP side so the stop hook
|
|
998
|
+
// is never blocked by a sub-second mtime race from a Bash touch command.
|
|
999
|
+
// This is a best-effort write — if it fails, the agent can still Bash-stamp.
|
|
1000
|
+
try {
|
|
1001
|
+
fs.writeFileSync(path.join(ROOT, '.pulse-verified'), String(Math.floor(Date.now() / 1000)), 'utf8')
|
|
1002
|
+
} catch { /* non-critical */ }
|
|
685
1003
|
}
|
|
686
1004
|
)
|
|
687
1005
|
|
|
@@ -767,97 +1085,2078 @@ server.registerTool(
|
|
|
767
1085
|
)
|
|
768
1086
|
|
|
769
1087
|
// ---------------------------------------------------------------------------
|
|
770
|
-
//
|
|
1088
|
+
// pulse_intent — Intent Engine
|
|
771
1089
|
// ---------------------------------------------------------------------------
|
|
772
1090
|
|
|
1091
|
+
const ARCHETYPES = {
|
|
1092
|
+
dashboard: {
|
|
1093
|
+
keywords: ['dashboard', 'analytics', 'metrics', 'stats', 'overview', 'admin', 'monitor', 'report', 'kpi', 'insight', 'data'],
|
|
1094
|
+
components: ['nav', 'stat', 'card', 'grid', 'barChart', 'lineChart', 'donutChart', 'table', 'badge', 'section', 'container', 'empty', 'spinner'],
|
|
1095
|
+
description: 'Data-rich admin or analytics page with metrics, charts, and tabular data',
|
|
1096
|
+
stateHint: "{ period: 'week', loading: false }",
|
|
1097
|
+
serverHint: "{ stats: async (ctx) => fetchStats(), rows: async (ctx) => fetchRows() }",
|
|
1098
|
+
scaffold: `import { nav, stat, card, grid, barChart, table, section, container, badge, empty, spinner } from '@invisibleloop/pulse/ui'
|
|
1099
|
+
|
|
1100
|
+
export default {
|
|
1101
|
+
route: '/dashboard',
|
|
1102
|
+
meta: {
|
|
1103
|
+
title: 'Dashboard',
|
|
1104
|
+
styles: ['/pulse-ui.css'],
|
|
1105
|
+
},
|
|
1106
|
+
server: {
|
|
1107
|
+
stats: async (ctx) => ({ total: 0, change: 0 }),
|
|
1108
|
+
rows: async (ctx) => [],
|
|
1109
|
+
},
|
|
1110
|
+
state: { period: 'week' },
|
|
1111
|
+
mutations: {
|
|
1112
|
+
setPeriod: (state, e) => ({ period: e.target.value }),
|
|
1113
|
+
},
|
|
1114
|
+
view: (state, server) => \`
|
|
1115
|
+
<main id="main-content">
|
|
1116
|
+
\${nav({ logo: 'Dashboard', links: [] })}
|
|
1117
|
+
\${section({ content: container({ content: \`
|
|
1118
|
+
\${grid({ cols: 4, content:
|
|
1119
|
+
stat({ label: 'Total', value: String(server.stats.total), trend: 'up', change: server.stats.change + '%' })
|
|
1120
|
+
})}
|
|
1121
|
+
\${card({ title: 'Recent activity', content:
|
|
1122
|
+
server.rows.length
|
|
1123
|
+
? table({ head: ['Name', 'Status'], rows: server.rows.map(r => [r.name, badge({ label: r.status })]) })
|
|
1124
|
+
: empty({ message: 'No activity yet' })
|
|
1125
|
+
})}
|
|
1126
|
+
\` }) })}
|
|
1127
|
+
</main>
|
|
1128
|
+
\`,
|
|
1129
|
+
}`,
|
|
1130
|
+
guides: ['pulse://guide/components', 'pulse://guide/spec', 'pulse://guide/server'],
|
|
1131
|
+
},
|
|
1132
|
+
|
|
1133
|
+
landing: {
|
|
1134
|
+
keywords: ['landing', 'marketing', 'homepage', 'product', 'saas', 'startup', 'website', 'launch', 'hero', 'promotional'],
|
|
1135
|
+
components: ['nav', 'hero', 'feature', 'stat', 'pricing', 'testimonial', 'cta', 'footer', 'grid', 'section', 'container', 'button', 'appBadge'],
|
|
1136
|
+
description: 'Marketing or product landing page with hero, features, and calls to action',
|
|
1137
|
+
stateHint: 'null — purely server-rendered, no client state needed',
|
|
1138
|
+
serverHint: 'none — all content is static in the view',
|
|
1139
|
+
scaffold: `import { nav, hero, feature, stat, cta, footer, grid, section, container, button, iconZap, iconShield, iconCheck } from '@invisibleloop/pulse/ui'
|
|
1140
|
+
|
|
1141
|
+
export default {
|
|
1142
|
+
route: '/',
|
|
1143
|
+
meta: {
|
|
1144
|
+
title: 'Product — Tagline',
|
|
1145
|
+
description: 'One clear sentence describing the product.',
|
|
1146
|
+
styles: ['/pulse-ui.css'],
|
|
1147
|
+
},
|
|
1148
|
+
view: () => \`
|
|
1149
|
+
<main id="main-content">
|
|
1150
|
+
\${nav({ logo: 'Product', links: [
|
|
1151
|
+
{ label: 'Features', href: '#features' },
|
|
1152
|
+
{ label: 'Pricing', href: '#pricing' },
|
|
1153
|
+
], actions: button({ label: 'Get started', size: 'sm' }) })}
|
|
1154
|
+
\${hero({ title: 'Your headline here', subtitle: 'Supporting copy that explains the value.', gradient: 'purple',
|
|
1155
|
+
actions: button({ label: 'Get started', size: 'lg' }) })}
|
|
1156
|
+
\${section({ id: 'features', content: container({ content: \`
|
|
1157
|
+
\${grid({ cols: 3, content:
|
|
1158
|
+
feature({ icon: iconZap(), title: 'Fast', body: 'Describe the benefit.' }) +
|
|
1159
|
+
feature({ icon: iconShield(), title: 'Secure', body: 'Describe the benefit.' }) +
|
|
1160
|
+
feature({ icon: iconCheck(), title: 'Reliable', body: 'Describe the benefit.' })
|
|
1161
|
+
})}
|
|
1162
|
+
\` }) })}
|
|
1163
|
+
\${cta({ title: 'Ready to start?', body: 'Sign up in seconds.', actions: button({ label: 'Get started' }) })}
|
|
1164
|
+
\${footer({ logo: 'Product', links: [] })}
|
|
1165
|
+
</main>
|
|
1166
|
+
\`,
|
|
1167
|
+
}`,
|
|
1168
|
+
guides: ['pulse://guide/components', 'pulse://guide/templates'],
|
|
1169
|
+
},
|
|
1170
|
+
|
|
1171
|
+
crud: {
|
|
1172
|
+
keywords: ['list', 'crud', 'table', 'records', 'manage', 'items', 'inventory', 'data', 'collection', 'browse', 'search'],
|
|
1173
|
+
components: ['nav', 'table', 'button', 'modal', 'input', 'select', 'pagination', 'alert', 'empty', 'badge', 'search', 'spinner'],
|
|
1174
|
+
description: 'Browsable list of records with search, filter, and row actions',
|
|
1175
|
+
stateHint: "{ query: '', page: 1, status: 'idle' }",
|
|
1176
|
+
serverHint: "{ items: async (ctx) => fetchItems(ctx.query), total: async (ctx) => countItems() }",
|
|
1177
|
+
scaffold: `import { nav, table, button, input, alert, empty, badge, section, container, spinner } from '@invisibleloop/pulse/ui'
|
|
1178
|
+
|
|
1179
|
+
export default {
|
|
1180
|
+
route: '/items',
|
|
1181
|
+
meta: {
|
|
1182
|
+
title: 'Items',
|
|
1183
|
+
styles: ['/pulse-ui.css'],
|
|
1184
|
+
},
|
|
1185
|
+
server: {
|
|
1186
|
+
items: async (ctx) => [],
|
|
1187
|
+
total: async (ctx) => 0,
|
|
1188
|
+
},
|
|
1189
|
+
state: { query: '', status: 'idle', deleteId: null },
|
|
1190
|
+
mutations: {
|
|
1191
|
+
setQuery: (state, e) => ({ query: e.target.value }),
|
|
1192
|
+
},
|
|
1193
|
+
actions: {
|
|
1194
|
+
deleteItem: {
|
|
1195
|
+
onStart: (state, formData) => ({ status: 'loading', deleteId: formData.get('id') }),
|
|
1196
|
+
run: async (state, serverState, formData) => {
|
|
1197
|
+
const res = await fetch(\`/api/items/\${formData.get('id')}\`, { method: 'DELETE' })
|
|
1198
|
+
if (!res.ok) { let m = \`Error: \${res.status}\`; try { const j = await res.json(); m = j.message || m } catch {} throw new Error(m) }
|
|
1199
|
+
},
|
|
1200
|
+
onSuccess: (state) => ({ status: 'success', deleteId: null, _toast: { message: 'Deleted', variant: 'success' } }),
|
|
1201
|
+
onError: (state, err) => ({ status: 'error', deleteId: null, _toast: { message: err.message, variant: 'error' } }),
|
|
1202
|
+
},
|
|
1203
|
+
},
|
|
1204
|
+
view: (state, server) => \`
|
|
1205
|
+
<main id="main-content">
|
|
1206
|
+
\${nav({ logo: 'Items', links: [] })}
|
|
1207
|
+
\${section({ content: container({ content: \`
|
|
1208
|
+
<div class="u-flex u-gap-3 u-mb-4">
|
|
1209
|
+
\${input({ label: 'Search', name: 'query', placeholder: 'Search…', value: state.query })}
|
|
1210
|
+
</div>
|
|
1211
|
+
\${server.items.length
|
|
1212
|
+
? table({ head: ['Name', 'Status', ''], rows: server.items.map(item => [
|
|
1213
|
+
item.name,
|
|
1214
|
+
badge({ label: item.status }),
|
|
1215
|
+
\`<form data-action="deleteItem">\${button({ label: 'Delete', variant: 'danger', size: 'sm', type: 'submit' })}<input type="hidden" name="id" value="\${item.id}"></form>\`
|
|
1216
|
+
])})
|
|
1217
|
+
: empty({ message: 'No items found' })
|
|
1218
|
+
}
|
|
1219
|
+
\` }) })}
|
|
1220
|
+
</main>
|
|
1221
|
+
\`,
|
|
1222
|
+
}`,
|
|
1223
|
+
guides: ['pulse://guide/spec', 'pulse://guide/components'],
|
|
1224
|
+
},
|
|
1225
|
+
|
|
1226
|
+
form: {
|
|
1227
|
+
keywords: ['form', 'submit', 'contact', 'register', 'signup', 'onboarding', 'wizard', 'checkout', 'enquiry', 'apply'],
|
|
1228
|
+
components: ['card', 'input', 'select', 'textarea', 'checkbox', 'radio', 'button', 'alert', 'fieldset', 'stepper', 'section', 'container'],
|
|
1229
|
+
description: 'Form page with validation, submission, and success/error states',
|
|
1230
|
+
stateHint: "{ status: 'idle', errors: [] }",
|
|
1231
|
+
serverHint: 'none — forms are typically client-only with a server action endpoint',
|
|
1232
|
+
scaffold: `import { card, input, textarea, button, alert, section, container, heading } from '@invisibleloop/pulse/ui'
|
|
1233
|
+
|
|
1234
|
+
export default {
|
|
1235
|
+
route: '/contact',
|
|
1236
|
+
meta: {
|
|
1237
|
+
title: 'Contact',
|
|
1238
|
+
styles: ['/pulse-ui.css'],
|
|
1239
|
+
},
|
|
1240
|
+
state: { status: 'idle', errors: [] },
|
|
1241
|
+
validation: {
|
|
1242
|
+
'fields.email': { required: true, format: 'email' },
|
|
1243
|
+
'fields.name': { required: true, minLength: 2 },
|
|
1244
|
+
},
|
|
1245
|
+
actions: {
|
|
1246
|
+
submit: {
|
|
1247
|
+
onStart: (state, formData) => ({ status: 'loading', errors: [], fields: { name: formData.get('name'), email: formData.get('email') } }),
|
|
1248
|
+
validate: true,
|
|
1249
|
+
run: async (state, serverState, formData) => {
|
|
1250
|
+
const res = await fetch('/api/contact', { method: 'POST', body: formData })
|
|
1251
|
+
if (!res.ok) { let m = \`Error: \${res.status}\`; try { const j = await res.json(); m = j.message || m } catch {} throw new Error(m) }
|
|
1252
|
+
return await res.json()
|
|
1253
|
+
},
|
|
1254
|
+
onSuccess: (state) => ({ status: 'success', _toast: { message: 'Message sent!', variant: 'success' } }),
|
|
1255
|
+
onError: (state, err) => ({ status: 'error', errors: err?.validation ?? [{ message: err.message }], _toast: { message: 'Please check the form', variant: 'error' } }),
|
|
1256
|
+
},
|
|
1257
|
+
},
|
|
1258
|
+
view: (state) => \`
|
|
1259
|
+
<main id="main-content">
|
|
1260
|
+
\${section({ content: container({ size: 'sm', content:
|
|
1261
|
+
state.status === 'success'
|
|
1262
|
+
? \`<p class="u-text-center u-py-8">Thanks! We'll be in touch.</p>\`
|
|
1263
|
+
: card({ title: 'Get in touch', content: \`
|
|
1264
|
+
\${state.errors.length ? alert({ variant: 'error', message: state.errors.map(e => e.message).join(', ') }) : ''}
|
|
1265
|
+
<form data-action="submit" novalidate>
|
|
1266
|
+
\${input({ label: 'Your name', name: 'name', required: true, error: state.errors.find(e => e.field === 'name')?.message })}
|
|
1267
|
+
\${input({ label: 'Email', name: 'email', type: 'email', required: true, error: state.errors.find(e => e.field === 'email')?.message })}
|
|
1268
|
+
\${textarea({ label: 'Message', name: 'message' })}
|
|
1269
|
+
\${button({ label: state.status === 'loading' ? 'Sending\u2026' : 'Send message', type: 'submit', attrs: state.status === 'loading' ? { 'aria-busy': 'true', disabled: '' } : {} })}
|
|
1270
|
+
</form>
|
|
1271
|
+
\` })
|
|
1272
|
+
}) })}
|
|
1273
|
+
</main>
|
|
1274
|
+
\`,
|
|
1275
|
+
}`,
|
|
1276
|
+
guides: ['pulse://guide/spec', 'pulse://guide/components'],
|
|
1277
|
+
},
|
|
1278
|
+
|
|
1279
|
+
settings: {
|
|
1280
|
+
keywords: ['settings', 'preferences', 'profile', 'account', 'configuration', 'options', 'edit profile', 'personal details'],
|
|
1281
|
+
components: ['nav', 'card', 'input', 'select', 'switch', 'button', 'alert', 'avatar', 'section', 'container', 'fieldset'],
|
|
1282
|
+
description: 'Settings or preferences page with multiple form sections and save actions',
|
|
1283
|
+
stateHint: "{ status: 'idle', saved: false }",
|
|
1284
|
+
serverHint: "{ user: async (ctx) => getUser(ctx), settings: async (ctx) => getSettings(ctx) }",
|
|
1285
|
+
scaffold: `import { nav, card, input, select, button, alert, section, container, avatar } from '@invisibleloop/pulse/ui'
|
|
1286
|
+
|
|
1287
|
+
export default {
|
|
1288
|
+
route: '/settings',
|
|
1289
|
+
meta: {
|
|
1290
|
+
title: 'Settings',
|
|
1291
|
+
styles: ['/pulse-ui.css'],
|
|
1292
|
+
},
|
|
1293
|
+
server: {
|
|
1294
|
+
user: async (ctx) => null,
|
|
1295
|
+
},
|
|
1296
|
+
state: { status: 'idle' },
|
|
1297
|
+
actions: {
|
|
1298
|
+
save: {
|
|
1299
|
+
onStart: (state) => ({ status: 'loading' }),
|
|
1300
|
+
run: async (state, serverState, formData) => {
|
|
1301
|
+
const res = await fetch('/api/settings', { method: 'POST', body: formData })
|
|
1302
|
+
if (!res.ok) { let m = \`Error: \${res.status}\`; try { const j = await res.json(); m = j.message || m } catch {} throw new Error(m) }
|
|
1303
|
+
return await res.json()
|
|
1304
|
+
},
|
|
1305
|
+
onSuccess: (state) => ({ status: 'success', _toast: { message: 'Settings saved', variant: 'success' } }),
|
|
1306
|
+
onError: (state, err) => ({ status: 'error', _toast: { message: err.message, variant: 'error' } }),
|
|
1307
|
+
},
|
|
1308
|
+
},
|
|
1309
|
+
view: (state, server) => \`
|
|
1310
|
+
<main id="main-content">
|
|
1311
|
+
\${nav({ logo: 'App', links: [] })}
|
|
1312
|
+
\${section({ content: container({ size: 'sm', content: \`
|
|
1313
|
+
<form data-action="save">
|
|
1314
|
+
\${card({ title: 'Profile', content: \`
|
|
1315
|
+
\${input({ label: 'Display name', name: 'name', value: server.user?.name ?? '' })}
|
|
1316
|
+
\${input({ label: 'Email', name: 'email', type: 'email', value: server.user?.email ?? '' })}
|
|
1317
|
+
\` })}
|
|
1318
|
+
<div class="u-mt-4 u-flex u-justify-end">
|
|
1319
|
+
\${button({ label: state.status === 'loading' ? 'Saving\u2026' : 'Save changes', type: 'submit', attrs: state.status === 'loading' ? { 'aria-busy': 'true', disabled: '' } : {} })}
|
|
1320
|
+
</div>
|
|
1321
|
+
</form>
|
|
1322
|
+
\` }) })}
|
|
1323
|
+
</main>
|
|
1324
|
+
\`,
|
|
1325
|
+
}`,
|
|
1326
|
+
guides: ['pulse://guide/server', 'pulse://guide/spec', 'pulse://guide/components'],
|
|
1327
|
+
},
|
|
1328
|
+
|
|
1329
|
+
blog: {
|
|
1330
|
+
keywords: ['blog', 'article', 'post', 'content', 'editorial', 'news', 'publication', 'markdown', 'text', 'read', 'writing'],
|
|
1331
|
+
components: ['nav', 'hero', 'prose', 'section', 'container', 'footer', 'badge'],
|
|
1332
|
+
description: 'Content page rendering markdown or rich text from a file or CMS',
|
|
1333
|
+
stateHint: 'null — purely server-rendered, no client state needed',
|
|
1334
|
+
serverHint: "{ post: md('content/blog/:slug.md') } — use @invisibleloop/pulse/md",
|
|
1335
|
+
scaffold: `import { nav, hero, prose, section, container, footer } from '@invisibleloop/pulse/ui'
|
|
1336
|
+
import { md } from '@invisibleloop/pulse/md'
|
|
1337
|
+
|
|
1338
|
+
const post = md('content/blog/:slug.md')
|
|
1339
|
+
|
|
1340
|
+
export default {
|
|
1341
|
+
route: '/blog/:slug',
|
|
1342
|
+
meta: {
|
|
1343
|
+
title: async (ctx) => (await post(ctx)).frontmatter.title,
|
|
1344
|
+
description: async (ctx) => (await post(ctx)).frontmatter.description,
|
|
1345
|
+
styles: ['/pulse-ui.css'],
|
|
1346
|
+
},
|
|
1347
|
+
server: { post },
|
|
1348
|
+
view: (state, server) => \`
|
|
1349
|
+
<main id="main-content">
|
|
1350
|
+
\${nav({ logo: 'Blog', links: [{ label: 'Home', href: '/' }] })}
|
|
1351
|
+
\${hero({ size: 'sm', title: server.post.frontmatter.title, subtitle: server.post.frontmatter.description })}
|
|
1352
|
+
\${section({ content: container({ size: 'sm', content: prose({ content: server.post.html }) }) })}
|
|
1353
|
+
\${footer({ logo: 'Blog', links: [] })}
|
|
1354
|
+
</main>
|
|
1355
|
+
\`,
|
|
1356
|
+
onViewError: (err) => \`<main id="main-content"><p class="u-p-4">Post not found.</p></main>\`,
|
|
1357
|
+
}`,
|
|
1358
|
+
guides: ['pulse://guide/spec', 'pulse://guide/components'],
|
|
1359
|
+
},
|
|
1360
|
+
|
|
1361
|
+
auth: {
|
|
1362
|
+
keywords: ['login', 'signin', 'sign in', 'signup', 'sign up', 'register', 'auth', 'password', 'forgot', 'reset', 'verify', 'otp'],
|
|
1363
|
+
components: ['card', 'input', 'button', 'alert', 'section', 'container', 'heading'],
|
|
1364
|
+
description: 'Authentication flow — login, signup, or password reset',
|
|
1365
|
+
stateHint: "{ status: 'idle', errors: [] }",
|
|
1366
|
+
serverHint: 'none for login; guard for protected pages',
|
|
1367
|
+
scaffold: `import { card, input, button, alert, section, container } from '@invisibleloop/pulse/ui'
|
|
1368
|
+
|
|
1369
|
+
export default {
|
|
1370
|
+
route: '/login',
|
|
1371
|
+
meta: {
|
|
1372
|
+
title: 'Sign in',
|
|
1373
|
+
styles: ['/pulse-ui.css'],
|
|
1374
|
+
},
|
|
1375
|
+
state: { status: 'idle', errors: [] },
|
|
1376
|
+
validation: {
|
|
1377
|
+
'fields.email': { required: true, format: 'email' },
|
|
1378
|
+
'fields.password': { required: true, minLength: 8 },
|
|
1379
|
+
},
|
|
1380
|
+
actions: {
|
|
1381
|
+
login: {
|
|
1382
|
+
onStart: (state) => ({ status: 'loading', errors: [] }),
|
|
1383
|
+
validate: true,
|
|
1384
|
+
run: async (state, serverState, formData) => {
|
|
1385
|
+
const res = await fetch('/api/auth/login', { method: 'POST', body: formData })
|
|
1386
|
+
if (!res.ok) { let m = \`Error: \${res.status}\`; try { const j = await res.json(); m = j.message || m } catch {} throw new Error(m) }
|
|
1387
|
+
return await res.json()
|
|
1388
|
+
},
|
|
1389
|
+
onSuccess: (state) => ({ status: 'success', _redirect: '/dashboard' }),
|
|
1390
|
+
onError: (state, err) => ({ status: 'error', errors: err?.validation ?? [{ message: err.message }] }),
|
|
1391
|
+
},
|
|
1392
|
+
},
|
|
1393
|
+
view: (state) => \`
|
|
1394
|
+
<main id="main-content">
|
|
1395
|
+
\${section({ content: container({ size: 'xs', content:
|
|
1396
|
+
card({ title: 'Sign in', content: \`
|
|
1397
|
+
\${state.errors.length ? alert({ variant: 'error', message: state.errors.map(e => e.message).join(', ') }) : ''}
|
|
1398
|
+
<form data-action="login" novalidate>
|
|
1399
|
+
\${input({ label: 'Email', name: 'email', type: 'email', required: true })}
|
|
1400
|
+
\${input({ label: 'Password', name: 'password', type: 'password', required: true })}
|
|
1401
|
+
\${button({ label: state.status === 'loading' ? 'Signing in\u2026' : 'Sign in', type: 'submit', fullWidth: true, attrs: state.status === 'loading' ? { 'aria-busy': 'true', disabled: '' } : {} })}
|
|
1402
|
+
</form>
|
|
1403
|
+
<p class="u-text-sm u-text-center u-mt-3"><a href="/forgot">Forgot password?</a></p>
|
|
1404
|
+
\` })
|
|
1405
|
+
}) })}
|
|
1406
|
+
</main>
|
|
1407
|
+
\`,
|
|
1408
|
+
}`,
|
|
1409
|
+
guides: ['pulse://guide/spec', 'pulse://guide/server', 'pulse://guide/components'],
|
|
1410
|
+
},
|
|
1411
|
+
|
|
1412
|
+
profile: {
|
|
1413
|
+
keywords: ['profile', 'user page', 'bio', 'portfolio', 'about', 'member', 'author', 'personal page', 'public profile'],
|
|
1414
|
+
components: ['nav', 'avatar', 'card', 'stat', 'badge', 'grid', 'section', 'container', 'button', 'timeline', 'prose'],
|
|
1415
|
+
description: 'User or entity profile page with metadata and activity',
|
|
1416
|
+
stateHint: 'null — purely server-rendered',
|
|
1417
|
+
serverHint: "{ user: async (ctx) => getUser(ctx.params.id), activity: async (ctx) => getActivity(ctx.params.id) }",
|
|
1418
|
+
scaffold: `import { nav, avatar, card, stat, badge, grid, section, container } from '@invisibleloop/pulse/ui'
|
|
1419
|
+
|
|
1420
|
+
export default {
|
|
1421
|
+
route: '/users/:id',
|
|
1422
|
+
meta: {
|
|
1423
|
+
title: async (ctx) => \`User \${ctx.params.id}\`,
|
|
1424
|
+
styles: ['/pulse-ui.css'],
|
|
1425
|
+
},
|
|
1426
|
+
server: {
|
|
1427
|
+
user: async (ctx) => null,
|
|
1428
|
+
activity: async (ctx) => [],
|
|
1429
|
+
},
|
|
1430
|
+
view: (state, server) => \`
|
|
1431
|
+
<main id="main-content">
|
|
1432
|
+
\${nav({ logo: 'App', links: [] })}
|
|
1433
|
+
\${section({ content: container({ content: \`
|
|
1434
|
+
\${card({ content: \`
|
|
1435
|
+
<div class="u-flex u-gap-4 u-items-center">
|
|
1436
|
+
\${avatar({ name: server.user?.name ?? 'Unknown', size: 'lg' })}
|
|
1437
|
+
<div>
|
|
1438
|
+
<h1 class="u-text-2xl u-font-bold">\${server.user?.name ?? 'Unknown'}</h1>
|
|
1439
|
+
<p class="u-text-muted">\${server.user?.bio ?? ''}</p>
|
|
1440
|
+
</div>
|
|
1441
|
+
</div>
|
|
1442
|
+
\` })}
|
|
1443
|
+
\${grid({ cols: 3, content:
|
|
1444
|
+
stat({ label: 'Posts', value: String(server.user?.posts ?? 0) }) +
|
|
1445
|
+
stat({ label: 'Followers', value: String(server.user?.followers ?? 0) }) +
|
|
1446
|
+
stat({ label: 'Following', value: String(server.user?.following ?? 0) })
|
|
1447
|
+
})}
|
|
1448
|
+
\` }) })}
|
|
1449
|
+
</main>
|
|
1450
|
+
\`,
|
|
1451
|
+
onViewError: () => \`<main id="main-content"><p class="u-p-4">User not found.</p></main>\`,
|
|
1452
|
+
}`,
|
|
1453
|
+
guides: ['pulse://guide/components', 'pulse://guide/spec', 'pulse://guide/server'],
|
|
1454
|
+
},
|
|
1455
|
+
|
|
1456
|
+
pricing: {
|
|
1457
|
+
keywords: ['pricing', 'plans', 'subscription', 'tiers', 'billing', 'upgrade', 'compare', 'packages'],
|
|
1458
|
+
components: ['nav', 'pricing', 'feature', 'cta', 'accordion', 'footer', 'section', 'grid', 'button', 'badge'],
|
|
1459
|
+
description: 'Pricing page with plan comparison, feature lists, and upgrade CTA',
|
|
1460
|
+
stateHint: "{ billing: 'monthly' } — for toggle between monthly/annual",
|
|
1461
|
+
serverHint: 'none — content is typically static',
|
|
1462
|
+
scaffold: `import { nav, pricing, cta, accordion, footer, section, container, button } from '@invisibleloop/pulse/ui'
|
|
1463
|
+
|
|
1464
|
+
export default {
|
|
1465
|
+
route: '/pricing',
|
|
1466
|
+
meta: {
|
|
1467
|
+
title: 'Pricing',
|
|
1468
|
+
styles: ['/pulse-ui.css'],
|
|
1469
|
+
},
|
|
1470
|
+
state: { billing: 'monthly' },
|
|
1471
|
+
mutations: {
|
|
1472
|
+
setBilling: (state, e) => ({ billing: e.target.value }),
|
|
1473
|
+
},
|
|
1474
|
+
view: (state) => \`
|
|
1475
|
+
<main id="main-content">
|
|
1476
|
+
\${nav({ logo: 'App', links: [] })}
|
|
1477
|
+
\${section({ content: container({ content: \`
|
|
1478
|
+
<h1 class="u-text-4xl u-font-bold u-text-center u-mb-2">Simple pricing</h1>
|
|
1479
|
+
<p class="u-text-center u-text-muted u-mb-8">No hidden fees. Cancel any time.</p>
|
|
1480
|
+
\${pricing({
|
|
1481
|
+
plans: [
|
|
1482
|
+
{ name: 'Free', price: '\$0', period: 'forever', features: ['Feature A', 'Feature B'], cta: button({ label: 'Get started', variant: 'secondary' }) },
|
|
1483
|
+
{ name: 'Pro', price: '\$12', period: '/month', features: ['Everything in Free', 'Feature C', 'Feature D'], featured: true, cta: button({ label: 'Start free trial' }) },
|
|
1484
|
+
{ name: 'Team', price: '\$49', period: '/month', features: ['Everything in Pro', 'Feature E', 'Unlimited seats'], cta: button({ label: 'Contact sales', variant: 'secondary' }) },
|
|
1485
|
+
]
|
|
1486
|
+
})}
|
|
1487
|
+
\${accordion({ items: [
|
|
1488
|
+
{ title: 'Can I cancel any time?', content: 'Yes — no lock-in, cancel from your account settings.' },
|
|
1489
|
+
{ title: 'What payment methods do you accept?', content: 'Visa, Mastercard, Amex, and PayPal.' },
|
|
1490
|
+
]})}
|
|
1491
|
+
\` }) })}
|
|
1492
|
+
\${cta({ title: 'Still have questions?', body: 'Talk to the team.', actions: button({ label: 'Contact us', variant: 'secondary' }) })}
|
|
1493
|
+
\${footer({ logo: 'App', links: [] })}
|
|
1494
|
+
</main>
|
|
1495
|
+
\`,
|
|
1496
|
+
}`,
|
|
1497
|
+
guides: ['pulse://guide/components', 'pulse://guide/templates'],
|
|
1498
|
+
},
|
|
1499
|
+
}
|
|
1500
|
+
|
|
773
1501
|
server.registerTool(
|
|
774
|
-
'
|
|
1502
|
+
'pulse_intent',
|
|
775
1503
|
{
|
|
776
|
-
description:
|
|
777
|
-
|
|
1504
|
+
description: `Intent engine — describe what you want to build and get back a matched archetype, recommended components, a ready-to-adapt spec scaffold, and which guide sections to read first.
|
|
1505
|
+
|
|
1506
|
+
Use this at the start of any build task instead of (or before) fetching pulse://workflow. It short-circuits the "what components should I use?" and "what does the state shape look like?" questions by detecting your intent and giving you a tailored starting point.
|
|
1507
|
+
|
|
1508
|
+
Examples:
|
|
1509
|
+
"a dark analytics dashboard with live stats and a data table"
|
|
1510
|
+
"a contact form with email validation and a success screen"
|
|
1511
|
+
"a pricing page for a SaaS product with three tiers"
|
|
1512
|
+
"a blog post page using markdown with a newsletter signup"`,
|
|
1513
|
+
inputSchema: {
|
|
1514
|
+
description: z.string().describe('Plain-language description of the page or feature you want to build'),
|
|
1515
|
+
},
|
|
778
1516
|
},
|
|
779
|
-
|
|
780
|
-
|
|
781
|
-
|
|
782
|
-
|
|
783
|
-
|
|
784
|
-
|
|
785
|
-
return
|
|
1517
|
+
({ description }) => {
|
|
1518
|
+
const desc = description.toLowerCase()
|
|
1519
|
+
|
|
1520
|
+
// Score each archetype by how many keywords appear in the description
|
|
1521
|
+
const scored = Object.entries(ARCHETYPES).map(([key, arch]) => {
|
|
1522
|
+
const matches = arch.keywords.filter(kw => desc.includes(kw))
|
|
1523
|
+
return { key, arch, score: matches.length, matches }
|
|
1524
|
+
}).filter(({ score }) => score > 0)
|
|
1525
|
+
.sort((a, b) => b.score - a.score)
|
|
1526
|
+
|
|
1527
|
+
if (scored.length === 0) {
|
|
1528
|
+
// No archetype matched — return generic starting advice
|
|
1529
|
+
return text(`No archetype match found for: "${description}"
|
|
1530
|
+
|
|
1531
|
+
Try describing what the page *does* rather than how it looks. Examples:
|
|
1532
|
+
"a list of users I can search and delete"
|
|
1533
|
+
"a login form with email and password"
|
|
1534
|
+
"a dashboard with charts and a recent activity table"
|
|
1535
|
+
"a landing page for a mobile app"
|
|
1536
|
+
|
|
1537
|
+
If your page genuinely doesn't fit a pattern, start from pulse://guide/spec and build from scratch.`)
|
|
786
1538
|
}
|
|
787
1539
|
|
|
788
|
-
|
|
789
|
-
const
|
|
790
|
-
const publicDir = path.join(ROOT, 'public')
|
|
791
|
-
const assets = ['pulse-ui.css', 'pulse-ui.js', '.pulse-ui-version']
|
|
792
|
-
const updated = []
|
|
1540
|
+
const best = scored[0]
|
|
1541
|
+
const { key, arch, matches } = best
|
|
793
1542
|
|
|
794
|
-
|
|
795
|
-
|
|
796
|
-
|
|
797
|
-
|
|
798
|
-
|
|
1543
|
+
// Build the response
|
|
1544
|
+
const lines = []
|
|
1545
|
+
|
|
1546
|
+
lines.push(`## Detected intent: ${key}`)
|
|
1547
|
+
lines.push(``)
|
|
1548
|
+
lines.push(`${arch.description}`)
|
|
1549
|
+
lines.push(`Matched on: ${matches.join(', ')}`)
|
|
1550
|
+
|
|
1551
|
+
if (scored.length > 1) {
|
|
1552
|
+
const alts = scored.slice(1, 3).map(s => `${s.key} (${s.matches.join(', ')})`).join(' · ')
|
|
1553
|
+
lines.push(`Alternative archetypes: ${alts}`)
|
|
799
1554
|
}
|
|
800
1555
|
|
|
801
|
-
|
|
802
|
-
|
|
803
|
-
|
|
804
|
-
|
|
805
|
-
|
|
806
|
-
|
|
1556
|
+
lines.push(``)
|
|
1557
|
+
lines.push(`## Recommended components`)
|
|
1558
|
+
lines.push(``)
|
|
1559
|
+
lines.push(`\`\`\`js`)
|
|
1560
|
+
lines.push(`import { ${arch.components.join(', ')} } from '@invisibleloop/pulse/ui'`)
|
|
1561
|
+
lines.push(`\`\`\``)
|
|
1562
|
+
|
|
1563
|
+
lines.push(``)
|
|
1564
|
+
lines.push(`## State & server hints`)
|
|
1565
|
+
lines.push(``)
|
|
1566
|
+
lines.push(`State: ${arch.stateHint}`)
|
|
1567
|
+
lines.push(`Server: ${arch.serverHint}`)
|
|
1568
|
+
|
|
1569
|
+
lines.push(``)
|
|
1570
|
+
lines.push(`## Guide sections to read`)
|
|
1571
|
+
lines.push(``)
|
|
1572
|
+
for (const g of arch.guides) {
|
|
1573
|
+
lines.push(` ${g}`)
|
|
807
1574
|
}
|
|
808
1575
|
|
|
809
|
-
|
|
810
|
-
|
|
811
|
-
|
|
1576
|
+
lines.push(``)
|
|
1577
|
+
lines.push(`## Starter spec scaffold`)
|
|
1578
|
+
lines.push(``)
|
|
1579
|
+
lines.push(`Adapt this — it is a complete but minimal working page. Replace placeholder content, add your real data fetchers, and adjust state as needed.`)
|
|
1580
|
+
lines.push(``)
|
|
1581
|
+
lines.push(`\`\`\`js`)
|
|
1582
|
+
lines.push(arch.scaffold)
|
|
1583
|
+
lines.push(`\`\`\``)
|
|
1584
|
+
|
|
1585
|
+
lines.push(``)
|
|
1586
|
+
lines.push(`## Next steps`)
|
|
1587
|
+
lines.push(``)
|
|
1588
|
+
lines.push(`1. Fetch \`pulse://workflow\` to understand the build phases`)
|
|
1589
|
+
for (const g of arch.guides) {
|
|
1590
|
+
lines.push(`2. Fetch \`${g}\` for the full component and spec reference`)
|
|
1591
|
+
}
|
|
1592
|
+
lines.push(`3. Call \`pulse_list_structure\` to see what already exists`)
|
|
1593
|
+
lines.push(`4. Adapt the scaffold above and write it to \`src/pages/your-name.js\``)
|
|
1594
|
+
lines.push(`5. Call \`pulse_suggest\` on your draft at any point for early feedback`)
|
|
1595
|
+
|
|
1596
|
+
return text(lines.join('\n'))
|
|
812
1597
|
}
|
|
813
1598
|
)
|
|
814
1599
|
|
|
815
1600
|
// ---------------------------------------------------------------------------
|
|
816
|
-
//
|
|
1601
|
+
// pulse_layout_review — Multi-viewport layout check
|
|
817
1602
|
// ---------------------------------------------------------------------------
|
|
818
1603
|
|
|
819
|
-
|
|
820
|
-
|
|
821
|
-
|
|
822
|
-
|
|
823
|
-
|
|
824
|
-
|
|
825
|
-
|
|
826
|
-
|
|
827
|
-
|
|
828
|
-
|
|
829
|
-
|
|
830
|
-
}
|
|
831
|
-
|
|
832
|
-
|
|
1604
|
+
server.registerTool(
|
|
1605
|
+
'pulse_layout_review',
|
|
1606
|
+
{
|
|
1607
|
+
description: `Run a multi-viewport layout review on the current page. Returns a structured set of browser steps to execute using chrome-devtools tools.
|
|
1608
|
+
|
|
1609
|
+
Use this AFTER the initial screenshot but BEFORE Lighthouse. It catches layout issues (overflow, collapsed sections, broken images) that Lighthouse doesn't check and that are invisible at a single viewport width.
|
|
1610
|
+
|
|
1611
|
+
Call this as part of the Phase 5 browser check:
|
|
1612
|
+
screenshot → pulse_design_review → pulse_layout_review → Lighthouse`,
|
|
1613
|
+
inputSchema: {
|
|
1614
|
+
url: z.string().describe('The full URL to check, e.g. http://localhost:3001/about'),
|
|
1615
|
+
},
|
|
1616
|
+
},
|
|
1617
|
+
async ({ url }) => {
|
|
1618
|
+
const steps = `
|
|
1619
|
+
## pulse_layout_review — run these steps in sequence
|
|
1620
|
+
|
|
1621
|
+
**URL:** ${url}
|
|
1622
|
+
|
|
1623
|
+
Execute the following in order using chrome-devtools tools:
|
|
1624
|
+
|
|
1625
|
+
### 1. Mobile (390 × 844)
|
|
1626
|
+
\`\`\`
|
|
1627
|
+
chrome-devtools-resize_page: { width: 390, height: 844 }
|
|
1628
|
+
chrome-devtools-navigate_page: { type: "url", url: "${url}" }
|
|
1629
|
+
chrome-devtools-take_screenshot
|
|
1630
|
+
\`\`\`
|
|
1631
|
+
Then run this scan:
|
|
1632
|
+
\`\`\`js
|
|
1633
|
+
chrome-devtools-evaluate_script: () => {
|
|
1634
|
+
const issues = []
|
|
1635
|
+
if (document.documentElement.scrollWidth > document.documentElement.clientWidth) {
|
|
1636
|
+
issues.push('OVERFLOW: page has horizontal overflow at 390px — some element is wider than the viewport')
|
|
833
1637
|
}
|
|
834
|
-
|
|
1638
|
+
document.querySelectorAll('img').forEach((img, i) => {
|
|
1639
|
+
if (img.complete && img.naturalWidth === 0) {
|
|
1640
|
+
issues.push('BROKEN IMAGE: img[' + i + '] src="' + img.src + '" failed to load (naturalWidth === 0)')
|
|
1641
|
+
}
|
|
1642
|
+
})
|
|
1643
|
+
document.querySelectorAll('section, main, header, footer, [class*="hero"], [class*="feature"], [class*="grid"]').forEach(el => {
|
|
1644
|
+
if (el.offsetHeight === 0) {
|
|
1645
|
+
issues.push('COLLAPSED: <' + el.tagName.toLowerCase() + (el.className ? ' class="' + el.className + '"' : '') + '> has zero height — check visibility, display, or missing content')
|
|
1646
|
+
}
|
|
1647
|
+
})
|
|
1648
|
+
return issues.length ? issues : ['No layout issues found at 390px']
|
|
835
1649
|
}
|
|
1650
|
+
\`\`\`
|
|
836
1651
|
|
|
837
|
-
|
|
838
|
-
|
|
839
|
-
|
|
840
|
-
|
|
841
|
-
|
|
842
|
-
|
|
1652
|
+
### 2. Tablet (768 × 1024)
|
|
1653
|
+
\`\`\`
|
|
1654
|
+
chrome-devtools-resize_page: { width: 768, height: 1024 }
|
|
1655
|
+
chrome-devtools-navigate_page: { type: "url", url: "${url}" }
|
|
1656
|
+
chrome-devtools-take_screenshot
|
|
1657
|
+
\`\`\`
|
|
1658
|
+
Run the same JS scan. Report any issues found.
|
|
843
1659
|
|
|
844
|
-
|
|
845
|
-
|
|
846
|
-
|
|
847
|
-
|
|
848
|
-
|
|
849
|
-
|
|
850
|
-
|
|
851
|
-
|
|
852
|
-
|
|
853
|
-
|
|
854
|
-
|
|
855
|
-
|
|
856
|
-
|
|
857
|
-
|
|
1660
|
+
### 3. Desktop (1280 × 800)
|
|
1661
|
+
\`\`\`
|
|
1662
|
+
chrome-devtools-resize_page: { width: 1280, height: 800 }
|
|
1663
|
+
chrome-devtools-navigate_page: { type: "url", url: "${url}" }
|
|
1664
|
+
chrome-devtools-take_screenshot
|
|
1665
|
+
\`\`\`
|
|
1666
|
+
Run the same JS scan. Report any issues found.
|
|
1667
|
+
|
|
1668
|
+
### 4. Restore desktop size
|
|
1669
|
+
\`\`\`
|
|
1670
|
+
chrome-devtools-resize_page: { width: 1280, height: 800 }
|
|
1671
|
+
\`\`\`
|
|
1672
|
+
|
|
1673
|
+
### 5. Report
|
|
1674
|
+
|
|
1675
|
+
Fill in this table before proceeding to Lighthouse:
|
|
1676
|
+
|
|
1677
|
+
| Viewport | Overflow | Broken images | Collapsed sections | Screenshot |
|
|
1678
|
+
|---|---|---|---|---|
|
|
1679
|
+
| 390px mobile | ✅ / ❌ | ✅ / ❌ | ✅ / ❌ | [describe] |
|
|
1680
|
+
| 768px tablet | ✅ / ❌ | ✅ / ❌ | ✅ / ❌ | [describe] |
|
|
1681
|
+
| 1280px desktop | ✅ / ❌ | ✅ / ❌ | ✅ / ❌ | [describe] |
|
|
1682
|
+
|
|
1683
|
+
**If any cell is ❌**, fix the issue and re-run pulse_layout_review before proceeding to Lighthouse.
|
|
1684
|
+
`.trim()
|
|
1685
|
+
|
|
1686
|
+
return text(steps)
|
|
1687
|
+
}
|
|
1688
|
+
)
|
|
1689
|
+
|
|
1690
|
+
// ---------------------------------------------------------------------------
|
|
1691
|
+
// pulse_design_review — Visual design review against the original brief
|
|
1692
|
+
// ---------------------------------------------------------------------------
|
|
1693
|
+
|
|
1694
|
+
|
|
1695
|
+
server.registerTool(
|
|
1696
|
+
'pulse_design_review',
|
|
1697
|
+
{
|
|
1698
|
+
description: `Review the built page's visual design against the original product brief from pulse_intake.
|
|
1699
|
+
|
|
1700
|
+
Use this AFTER taking a screenshot but BEFORE the code review gate. It checks whether the design looks and feels appropriate for the stated product, audience, and vibe — catching mismatches like "builders merchant site that looks like a magazine" or "children's app that feels corporate".
|
|
1701
|
+
|
|
1702
|
+
Returns a structured design critique the agent must address.`,
|
|
1703
|
+
inputSchema: {
|
|
1704
|
+
route: z.string().optional().describe('Route to screenshot, e.g. "/" or "/about". Defaults to "/".'),
|
|
1705
|
+
screenshot: z.string().optional().describe('Description of what you observed in the screenshot (optional — helps focus the review).'),
|
|
1706
|
+
},
|
|
1707
|
+
},
|
|
1708
|
+
({ route = '/', screenshot }) => {
|
|
1709
|
+
const briefFile = path.join(ROOT, '.pulse', 'brief.json')
|
|
1710
|
+
const hasBrief = fs.existsSync(briefFile)
|
|
1711
|
+
const brief = hasBrief ? JSON.parse(fs.readFileSync(briefFile, 'utf8')) : null
|
|
1712
|
+
|
|
1713
|
+
const lines = []
|
|
1714
|
+
lines.push('# Design review — brief vs reality\n')
|
|
1715
|
+
|
|
1716
|
+
if (!brief) {
|
|
1717
|
+
lines.push('> **No brief found** — `.pulse/brief.json` does not exist. Either `pulse_intake` was not run for this project, or the brief was not saved.')
|
|
1718
|
+
lines.push('> You can still do a qualitative design review — describe the product type and target user to assess below.\n')
|
|
1719
|
+
} else {
|
|
1720
|
+
lines.push('## The original brief\n')
|
|
1721
|
+
lines.push(`| Field | Value |`)
|
|
1722
|
+
lines.push(`|---|---|`)
|
|
1723
|
+
lines.push(`| Product | **${brief.name}** — ${brief.pitch} |`)
|
|
1724
|
+
if (brief.targetUser) lines.push(`| Target user | ${brief.targetUser} |`)
|
|
1725
|
+
if (brief.vibe) lines.push(`| Vibe | ${brief.vibe} |`)
|
|
1726
|
+
if (brief.theme) lines.push(`| Theme | ${brief.theme} |`)
|
|
1727
|
+
if (brief.antiStyle) lines.push(`| Anti-style | ${brief.antiStyle} |`)
|
|
1728
|
+
if (brief.styleNotes) lines.push(`| Style notes | ${brief.styleNotes} |`)
|
|
1729
|
+
if (brief.features?.length) lines.push(`| Features | ${brief.features.join(', ')} |`)
|
|
1730
|
+
lines.push('')
|
|
1731
|
+
}
|
|
1732
|
+
|
|
1733
|
+
if (screenshot) {
|
|
1734
|
+
lines.push('## Your screenshot observation\n')
|
|
1735
|
+
lines.push(`> ${screenshot}\n`)
|
|
1736
|
+
}
|
|
1737
|
+
|
|
1738
|
+
lines.push('## Your task\n')
|
|
1739
|
+
lines.push('You are now a **design reviewer with no knowledge of the code**. Look only at the screenshot.')
|
|
1740
|
+
lines.push('Work through each signal below. Give an honest verdict for each — do not skip any.\n')
|
|
1741
|
+
|
|
1742
|
+
// Build product-type signals from brief if available
|
|
1743
|
+
const productType = brief ? `${brief.name} (${brief.pitch})` : 'this product'
|
|
1744
|
+
const audience = brief?.targetUser ?? 'the stated target user'
|
|
1745
|
+
const vibe = brief?.vibe ?? null
|
|
1746
|
+
const antiStyle = brief?.antiStyle ?? null
|
|
1747
|
+
|
|
1748
|
+
lines.push('### 1. First impression — audience fit')
|
|
1749
|
+
lines.push(`Show the screenshot to someone unfamiliar with the brief. Would they immediately guess this is **${productType}** aimed at **${audience}**?`)
|
|
1750
|
+
lines.push('- What three words does this design communicate at a glance?')
|
|
1751
|
+
lines.push(`- Do those words match what **${audience}** would expect and trust?\n`)
|
|
1752
|
+
|
|
1753
|
+
lines.push('### 2. Typography feel')
|
|
1754
|
+
lines.push('- **Serif vs sans**: Serifs read as editorial, literary, premium. Sans reads as functional, modern, utilitarian.')
|
|
1755
|
+
lines.push('- **Weight**: Heavy/condensed headings = bold, urgent, industrial. Light/spaced = refined, editorial.')
|
|
1756
|
+
lines.push('- **Scale**: Oversized display type = magazine/editorial. Modest headings with dense body = catalogue/functional.')
|
|
1757
|
+
if (vibe) lines.push(`- Expected for **${vibe}** vibe: ${vibeTypographyHint(vibe)}`)
|
|
1758
|
+
if (antiStyle) lines.push(`- Anti-style check: does it resemble "${antiStyle}"? If so, flag it.\n`)
|
|
1759
|
+
else lines.push('')
|
|
1760
|
+
|
|
1761
|
+
lines.push('### 3. Colour mood')
|
|
1762
|
+
lines.push('- **Warm neutrals + organic accent** = hospitality, artisan, food')
|
|
1763
|
+
lines.push('- **Industrial palette** (grey, orange, yellow, black) = trades, construction, hardware')
|
|
1764
|
+
lines.push('- **Navy/white/gold** = professional services, finance, prestige')
|
|
1765
|
+
lines.push('- **Bright primaries** = consumer, retail, energy')
|
|
1766
|
+
lines.push('- **Dark bg + neon accent** = tech, dev tools, gaming')
|
|
1767
|
+
lines.push(`- Does the palette match what **${audience}** would associate with this category?\n`)
|
|
1768
|
+
|
|
1769
|
+
lines.push('### 4. Layout density and hierarchy')
|
|
1770
|
+
lines.push('- **Sparse, generous whitespace, large imagery** → magazine, editorial, luxury')
|
|
1771
|
+
lines.push('- **Dense, product grid, clear categories** → e-commerce, catalogue, trades')
|
|
1772
|
+
lines.push('- **Dashboard grid, data-forward** → SaaS, B2B, professional tools')
|
|
1773
|
+
lines.push('- **Single focus per section, story-driven scroll** → startup landing, consumer app')
|
|
1774
|
+
lines.push(`- Which pattern does this layout use? Is that right for **${productType}**?\n`)
|
|
1775
|
+
|
|
1776
|
+
lines.push('### 5. Imagery and visual language')
|
|
1777
|
+
lines.push('- **Lifestyle/aspirational photography** → consumer, food, travel, luxury')
|
|
1778
|
+
lines.push('- **Product/catalogue photos** → e-commerce, hardware, trades')
|
|
1779
|
+
lines.push('- **Abstract/geometric** → tech, SaaS, fintech')
|
|
1780
|
+
lines.push('- **Illustration** → consumer apps, education, healthcare')
|
|
1781
|
+
lines.push('- **No imagery, type-only** → brutalist, editorial, developer tools')
|
|
1782
|
+
lines.push(`- Does the visual language feel appropriate for **${audience}**?\n`)
|
|
1783
|
+
|
|
1784
|
+
lines.push('### 6. CTA and copy tone')
|
|
1785
|
+
lines.push('- **"Get a quote", "Order now", "Find a branch"** → trades, construction, B2B')
|
|
1786
|
+
lines.push('- **"Get started", "Try free", "Sign up"** → SaaS, consumer app')
|
|
1787
|
+
lines.push('- **"Explore", "Discover", "See the collection"** → editorial, luxury, fashion')
|
|
1788
|
+
lines.push('- **"Book", "Reserve", "Check availability"** → hospitality, events, services')
|
|
1789
|
+
lines.push(`- Do the CTAs sound like something **${audience}** would respond to?\n`)
|
|
1790
|
+
|
|
1791
|
+
lines.push('### 7. Trust signals for this audience')
|
|
1792
|
+
lines.push(`What would make **${audience}** trust this site immediately? Check if those elements are present and prominent:`)
|
|
1793
|
+
if (audience.match(/trade|builder|contractor|professional|B2B/i)) {
|
|
1794
|
+
lines.push('- Trade account / account login visible?')
|
|
1795
|
+
lines.push('- Product categories or catalogue entry point prominent?')
|
|
1796
|
+
lines.push('- Delivery/collection info surfaced early?')
|
|
1797
|
+
lines.push('- No unnecessary lifestyle imagery pushing catalogue below fold?')
|
|
1798
|
+
} else if (audience.match(/consumer|shopper|customer|general/i)) {
|
|
1799
|
+
lines.push('- Social proof (reviews, ratings, testimonials) visible?')
|
|
1800
|
+
lines.push('- Clear pricing or "from £X" signals?')
|
|
1801
|
+
lines.push('- Easy navigation to product categories?')
|
|
1802
|
+
} else {
|
|
1803
|
+
lines.push('- Are the primary conversion actions immediately visible?')
|
|
1804
|
+
lines.push('- Does the above-fold content answer "what is this and why should I care"?')
|
|
1805
|
+
}
|
|
1806
|
+
lines.push('')
|
|
1807
|
+
|
|
1808
|
+
lines.push('## Verdict\n')
|
|
1809
|
+
lines.push('After working through every signal above, give a verdict:')
|
|
1810
|
+
lines.push('')
|
|
1811
|
+
lines.push('| Signal | Pass / Fail / Warn | Notes |')
|
|
1812
|
+
lines.push('|---|---|---|')
|
|
1813
|
+
lines.push('| Audience fit | | |')
|
|
1814
|
+
lines.push('| Typography feel | | |')
|
|
1815
|
+
lines.push('| Colour mood | | |')
|
|
1816
|
+
lines.push('| Layout density | | |')
|
|
1817
|
+
lines.push('| Imagery style | | |')
|
|
1818
|
+
lines.push('| CTA tone | | |')
|
|
1819
|
+
lines.push('| Trust signals | | |')
|
|
1820
|
+
lines.push('')
|
|
1821
|
+
lines.push('**If any signal is Fail:** stop. Describe what needs to change and why before proceeding to the code review.')
|
|
1822
|
+
lines.push('**If all Pass or Warn:** proceed to `pulse_review` for the code review gate.')
|
|
1823
|
+
|
|
1824
|
+
return text(lines.join('\n'))
|
|
1825
|
+
}
|
|
1826
|
+
)
|
|
1827
|
+
|
|
1828
|
+
function vibeTypographyHint(vibe) {
|
|
1829
|
+
const hints = {
|
|
1830
|
+
editorial: 'serif headings, tight tracking, high contrast text, editorial scale',
|
|
1831
|
+
bold: 'heavy condensed headings, impact weight, high contrast',
|
|
1832
|
+
brutalist: 'monospace or system font, raw weight, zero decoration',
|
|
1833
|
+
retro: 'slab serif or display font, warm tones, nostalgic feel',
|
|
1834
|
+
neon: 'monospace, futuristic, glowing accent on dark background',
|
|
1835
|
+
warm: 'rounded sans, generous spacing, soft palette',
|
|
1836
|
+
playful: 'rounded display font, large type, bright colours',
|
|
1837
|
+
minimal: 'light weight sans, generous whitespace, restrained scale',
|
|
1838
|
+
corporate: 'conservative sans, neutral palette, structured layout',
|
|
1839
|
+
paper: 'serif body, organic texture, journal-like rhythm',
|
|
1840
|
+
}
|
|
1841
|
+
return hints[vibe] ?? 'match the vibe description'
|
|
1842
|
+
}
|
|
1843
|
+
|
|
1844
|
+
// ---------------------------------------------------------------------------
|
|
1845
|
+
// pulse_suggest — Draft-mode contextual feedback
|
|
1846
|
+
// ---------------------------------------------------------------------------
|
|
1847
|
+
|
|
1848
|
+
server.registerTool(
|
|
1849
|
+
'pulse_suggest',
|
|
1850
|
+
{
|
|
1851
|
+
description: `Draft-mode feedback — paste a partial or complete spec and get constructive suggestions before running the hard validator.
|
|
1852
|
+
|
|
1853
|
+
Unlike pulse_validate (which fails on errors), pulse_suggest is a collaborator: it notices patterns, spots likely omissions, and offers ideas. Use it mid-build when you want a second opinion, not a gate.
|
|
1854
|
+
|
|
1855
|
+
Returns observations grouped by category: completeness, data flow, components, state shape, accessibility hints, and quick wins.`,
|
|
1856
|
+
inputSchema: {
|
|
1857
|
+
content: z.string().describe('JavaScript spec content — can be partial or incomplete'),
|
|
1858
|
+
},
|
|
1859
|
+
},
|
|
1860
|
+
({ content }) => {
|
|
1861
|
+
const suggestions = []
|
|
1862
|
+
const observations = []
|
|
1863
|
+
const quickWins = []
|
|
1864
|
+
|
|
1865
|
+
// --- Route & meta ---
|
|
1866
|
+
if (!content.includes('route:')) {
|
|
1867
|
+
suggestions.push('No route defined yet — add `route: \'/your-path\'` to register this page.')
|
|
1868
|
+
}
|
|
1869
|
+
if (!content.includes('meta:')) {
|
|
1870
|
+
quickWins.push('Add a `meta` block with `title` and `description` — needed for SEO and Lighthouse.')
|
|
1871
|
+
} else {
|
|
1872
|
+
if (!content.includes('title:')) quickWins.push('`meta.title` is missing — every page needs a unique title.')
|
|
1873
|
+
if (!content.includes('description:')) quickWins.push('`meta.description` is missing — used for SEO and social previews.')
|
|
1874
|
+
if (!content.includes('styles:')) quickWins.push('`meta.styles` not set — add `[\'/pulse-ui.css\']` if you are using any UI components.')
|
|
1875
|
+
}
|
|
1876
|
+
|
|
1877
|
+
// --- Export ---
|
|
1878
|
+
if (!content.includes('export default')) {
|
|
1879
|
+
suggestions.push('No `export default` found — specs must export default for hydration to work. Add `export default spec` or `export default { ... }` at the end.')
|
|
1880
|
+
}
|
|
1881
|
+
|
|
1882
|
+
// --- Server fetchers ---
|
|
1883
|
+
const hasServer = content.includes('server:')
|
|
1884
|
+
const hasStore = content.includes('store:')
|
|
1885
|
+
const viewRefs = (content.match(/server\.\w+/g) || []).map(r => r.slice(7))
|
|
1886
|
+
if (viewRefs.length > 0 && !hasServer && !hasStore) {
|
|
1887
|
+
suggestions.push(`View references server data (${viewRefs.slice(0, 3).join(', ')}) but no \`server\` block is defined. Add server fetchers or check if these should come from a store instead.`)
|
|
1888
|
+
}
|
|
1889
|
+
if (hasServer) {
|
|
1890
|
+
const declaredFetchers = [...content.matchAll(/^\s{4}(\w+)\s*:/gm)].map(m => m[1]).filter(f => f !== 'server' && f !== 'state' && f !== 'mutations' && f !== 'actions' && f !== 'meta' && f !== 'route' && f !== 'view' && f !== 'store' && f !== 'stream' && f !== 'persist' && f !== 'constraints' && f !== 'validation')
|
|
1891
|
+
const missingInView = viewRefs.filter(r => !content.includes(r + ':') && !declaredFetchers.includes(r))
|
|
1892
|
+
if (missingInView.length > 0) {
|
|
1893
|
+
suggestions.push(`View references \`server.${missingInView[0]}\` but no matching server fetcher found — check the spelling or add the fetcher.`)
|
|
1894
|
+
}
|
|
1895
|
+
}
|
|
1896
|
+
|
|
1897
|
+
// --- State ---
|
|
1898
|
+
const hasMutations = content.includes('mutations:')
|
|
1899
|
+
const hasActions = content.includes('actions:')
|
|
1900
|
+
const hasState = content.includes('state:')
|
|
1901
|
+
if ((hasMutations || hasActions) && !hasState) {
|
|
1902
|
+
suggestions.push('Spec has mutations/actions but no `state` — the runtime needs an initial state object. Add `state: { ... }` with your initial values.')
|
|
1903
|
+
}
|
|
1904
|
+
if (hasState && !hasMutations && !hasActions) {
|
|
1905
|
+
observations.push('State is defined but there are no mutations or actions — if this is purely server-rendered and never updated client-side, you can remove `state` for a lighter page (zero hydration JS).')
|
|
1906
|
+
}
|
|
1907
|
+
|
|
1908
|
+
// --- Forms & actions ---
|
|
1909
|
+
const hasForms = content.includes('data-action=')
|
|
1910
|
+
const hasActionsDef = content.includes('actions:')
|
|
1911
|
+
if (hasForms && !hasActionsDef) {
|
|
1912
|
+
suggestions.push('Found `data-action=` in the view but no `actions` block defined — the form submit will silently do nothing. Add an `actions` block with `onStart`, `run`, `onSuccess`, and `onError`.')
|
|
1913
|
+
}
|
|
1914
|
+
if (hasActionsDef && !content.includes('onError:')) {
|
|
1915
|
+
suggestions.push('Action found without `onError` — this will cause a runtime error on failure. Every action must define `onSuccess` AND `onError`.')
|
|
1916
|
+
}
|
|
1917
|
+
if (hasActionsDef && !content.includes('onSuccess:')) {
|
|
1918
|
+
suggestions.push('Action found without `onSuccess` — this is required. Add `onSuccess: (state, result) => ({ ... })`.')
|
|
1919
|
+
}
|
|
1920
|
+
|
|
1921
|
+
// --- Event binding on inputs ---
|
|
1922
|
+
if (content.includes('data-event=') && (content.match(/data-event="[^"]*"\s*(?:type="(?:text|email|password|search|tel|url|number)"|name=)/g) || []).length > 0) {
|
|
1923
|
+
suggestions.push('`data-event` on a text input causes re-render on every keystroke, destroying focus. Use uncontrolled inputs and read values from `FormData` in `action.onStart` instead.')
|
|
1924
|
+
}
|
|
1925
|
+
|
|
1926
|
+
// --- Validation ---
|
|
1927
|
+
if (hasActions && !content.includes('validate:') && (content.includes('required') || content.includes('format:'))) {
|
|
1928
|
+
quickWins.push('You have validation rules but no action sets `validate: true` — add `validate: true` to the action that submits the form to run validation before `run()`.')
|
|
1929
|
+
}
|
|
1930
|
+
|
|
1931
|
+
// --- View ---
|
|
1932
|
+
if (!content.includes('id="main-content"')) {
|
|
1933
|
+
quickWins.push('Add `<main id="main-content">` as the page landmark — needed for accessibility (skip link target) and Lighthouse.')
|
|
1934
|
+
}
|
|
1935
|
+
|
|
1936
|
+
// --- Components ---
|
|
1937
|
+
const hasRawButton = /<button(?!\s+[^>]*class="ui-)/.test(content)
|
|
1938
|
+
const hasRawInput = /<input(?!\s+[^>]*class="ui-)/.test(content) && !content.includes('type="hidden"')
|
|
1939
|
+
const hasRawTable = /<table(?!\s+[^>]*class="ui-)/.test(content)
|
|
1940
|
+
if (hasRawButton) quickWins.push('Raw `<button>` found — use `button({...})` from `@invisibleloop/pulse/ui` for consistent styling and accessibility.')
|
|
1941
|
+
if (hasRawInput) quickWins.push('Raw `<input>` found — use `input({...})` from `@invisibleloop/pulse/ui` for accessible labels and consistent styling.')
|
|
1942
|
+
if (hasRawTable) quickWins.push('Raw `<table>` found — use `table({ head, rows })` from `@invisibleloop/pulse/ui`.')
|
|
1943
|
+
|
|
1944
|
+
// --- Empty / error states ---
|
|
1945
|
+
if (hasServer && !content.includes('empty(') && !content.includes("'empty'") && (content.includes('.length') || content.includes('.map('))) {
|
|
1946
|
+
quickWins.push('Server data is rendered but no empty state found — add `empty({ message: \'...\' })` for when the list is empty.')
|
|
1947
|
+
}
|
|
1948
|
+
if (hasActions && !content.includes('alert(') && !content.includes("'error'")) {
|
|
1949
|
+
quickWins.push("Action defined but no error display in the view — add an `alert({ variant: 'error', message: '...' })` to show the user when something goes wrong.")
|
|
1950
|
+
}
|
|
1951
|
+
|
|
1952
|
+
// --- Loading state ---
|
|
1953
|
+
if (hasActions && !content.includes("'loading'") && !content.includes('aria-busy')) {
|
|
1954
|
+
quickWins.push("No loading state found — while an action runs, the submit button should show feedback (change label to 'Saving…' and add `aria-busy='true'` + `disabled`).")
|
|
1955
|
+
}
|
|
1956
|
+
|
|
1957
|
+
// --- onViewError ---
|
|
1958
|
+
if (hasServer && !content.includes('onViewError') && (content.includes('?.') === false) && content.includes('server.')) {
|
|
1959
|
+
quickWins.push('Consider adding `onViewError` — if a server fetcher returns unexpected data, the view can crash. A simple fallback prevents a 500 error.')
|
|
1960
|
+
}
|
|
1961
|
+
|
|
1962
|
+
// --- Constraints ---
|
|
1963
|
+
const mutationBodies = [...content.matchAll(/=>\s*\(\{[^}]*\+\s*1[^}]*\}|=>\s*\(\{[^}]*-\s*1[^}]*\}/g)]
|
|
1964
|
+
if (mutationBodies.length > 0 && !content.includes('constraints:')) {
|
|
1965
|
+
quickWins.push('Mutations with increment/decrement found but no `constraints` block — use `constraints: { field: { min: 0, max: 10 } }` instead of conditional logic inside mutations.')
|
|
1966
|
+
}
|
|
1967
|
+
|
|
1968
|
+
// --- Assemble output ---
|
|
1969
|
+
const lines = ['## Suggestions\n']
|
|
1970
|
+
|
|
1971
|
+
if (suggestions.length === 0 && observations.length === 0 && quickWins.length === 0) {
|
|
1972
|
+
lines.push('Spec looks solid — nothing obvious to flag. Run `pulse_validate` for a full schema check.')
|
|
1973
|
+
return text(lines.join('\n'))
|
|
1974
|
+
}
|
|
1975
|
+
|
|
1976
|
+
if (suggestions.length > 0) {
|
|
1977
|
+
lines.push('### Things to address\n')
|
|
1978
|
+
for (const s of suggestions) lines.push(`• ${s}\n`)
|
|
1979
|
+
}
|
|
1980
|
+
|
|
1981
|
+
if (observations.length > 0) {
|
|
1982
|
+
lines.push('### Observations\n')
|
|
1983
|
+
for (const o of observations) lines.push(`ℹ ${o}\n`)
|
|
1984
|
+
}
|
|
1985
|
+
|
|
1986
|
+
if (quickWins.length > 0) {
|
|
1987
|
+
lines.push('### Quick wins\n')
|
|
1988
|
+
for (const q of quickWins) lines.push(`→ ${q}\n`)
|
|
1989
|
+
}
|
|
1990
|
+
|
|
1991
|
+
lines.push('\nRun `pulse_validate` when ready for a full schema check, or keep iterating.')
|
|
1992
|
+
|
|
1993
|
+
return text(lines.join('\n'))
|
|
1994
|
+
}
|
|
1995
|
+
)
|
|
1996
|
+
|
|
1997
|
+
// ---------------------------------------------------------------------------
|
|
1998
|
+
// pulse_list_icons — Icon catalogue
|
|
1999
|
+
// ---------------------------------------------------------------------------
|
|
2000
|
+
|
|
2001
|
+
const ICON_CATALOGUE = {
|
|
2002
|
+
Navigation: ['iconArrowLeft', 'iconArrowRight', 'iconArrowUp', 'iconArrowDown', 'iconChevronLeft', 'iconChevronRight', 'iconChevronUp', 'iconChevronDown', 'iconExternalLink', 'iconMenu', 'iconX', 'iconMoreHorizontal', 'iconMoreVertical', 'iconHome', 'iconLogOut', 'iconLogIn'],
|
|
2003
|
+
Status: ['iconCheck', 'iconCheckCircle', 'iconXCircle', 'iconAlertCircle', 'iconAlertTriangle', 'iconInfo', 'iconLoader', 'iconBug'],
|
|
2004
|
+
Actions: ['iconPlus', 'iconMinus', 'iconEdit', 'iconTrash', 'iconCopy', 'iconSearch', 'iconFilter', 'iconDownload', 'iconUpload', 'iconRefresh', 'iconSend'],
|
|
2005
|
+
UI: ['iconEye', 'iconEyeOff', 'iconLock', 'iconUnlock', 'iconSettings', 'iconBell', 'iconGrid', 'iconBarChart'],
|
|
2006
|
+
People: ['iconUser', 'iconUsers', 'iconMail', 'iconMessageSquare', 'iconSmile', 'iconHeart', 'iconHandPointUp', 'iconHandPointDown', 'iconHandPointLeft', 'iconHandPointRight'],
|
|
2007
|
+
Media: ['iconFile', 'iconImage', 'iconLink', 'iconCode', 'iconPlay', 'iconPause', 'iconVolume'],
|
|
2008
|
+
Time: ['iconCalendar', 'iconClock'],
|
|
2009
|
+
Utility: ['iconBookmark', 'iconTag', 'iconStar', 'iconMapPin', 'iconGlobe', 'iconShield', 'iconZap', 'iconTrendingUp', 'iconTrendingDown', 'iconSun', 'iconMoon', 'iconPhone', 'iconGamepad', 'iconTelescope', 'iconAi', 'iconFeather'],
|
|
2010
|
+
Ecommerce: ['iconShoppingCart', 'iconShoppingBag', 'iconCreditCard', 'iconPackage', 'iconGift', 'iconWallet', 'iconTruck', 'iconReceipt', 'iconStore', 'iconPercent', 'iconTicket', 'iconBanknote'],
|
|
2011
|
+
Food: ['iconUtensils', 'iconCoffee', 'iconPizza', 'iconApple', 'iconCarrot', 'iconWine', 'iconCakeSlice', 'iconFish', 'iconCherry', 'iconEgg', 'iconCookie', 'iconIceCream', 'iconCroissant', 'iconSalad', 'iconWheat'],
|
|
2012
|
+
}
|
|
2013
|
+
|
|
2014
|
+
server.registerTool(
|
|
2015
|
+
'pulse_list_icons',
|
|
2016
|
+
{
|
|
2017
|
+
description: 'List all available Pulse UI icon names, grouped by category. Use this before importing icons — never guess a name or grep the source.',
|
|
2018
|
+
inputSchema: {
|
|
2019
|
+
filter: z.string().optional().describe('Optional keyword to filter icon names (e.g. "arrow", "check", "shop")'),
|
|
2020
|
+
},
|
|
2021
|
+
},
|
|
2022
|
+
({ filter }) => {
|
|
2023
|
+
const q = filter?.toLowerCase()
|
|
2024
|
+
const lines = ['# Pulse UI Icons\n', 'Import from `@invisibleloop/pulse/ui`. All icons accept `{ size, class, color }` props.\n']
|
|
2025
|
+
|
|
2026
|
+
if (!q) {
|
|
2027
|
+
lines.push('## Quick scan — one from each category\n')
|
|
2028
|
+
for (const [category, icons] of Object.entries(ICON_CATALOGUE)) {
|
|
2029
|
+
const sample = icons.slice(0, 5).join(' · ')
|
|
2030
|
+
lines.push(`**${category}** — ${sample} _(+${Math.max(0, icons.length - 5)} more)_`)
|
|
2031
|
+
}
|
|
2032
|
+
lines.push('\nUse `filter` to drill into a category: `pulse_list_icons({ filter: "arrow" })`, `pulse_list_icons({ filter: "shop" })`\n')
|
|
2033
|
+
lines.push('---\n')
|
|
2034
|
+
}
|
|
2035
|
+
|
|
2036
|
+
for (const [category, icons] of Object.entries(ICON_CATALOGUE)) {
|
|
2037
|
+
const filtered = q ? icons.filter(n => n.toLowerCase().includes(q)) : icons
|
|
2038
|
+
if (filtered.length === 0) continue
|
|
2039
|
+
lines.push(`## ${category}`)
|
|
2040
|
+
lines.push(filtered.join(' · '))
|
|
2041
|
+
lines.push('')
|
|
2042
|
+
}
|
|
2043
|
+
|
|
2044
|
+
const total = Object.values(ICON_CATALOGUE).reduce((n, arr) => n + arr.length, 0)
|
|
2045
|
+
lines.push(`---\n${total} icons total. Usage: \`iconCheck({ size: 16 })\`, \`iconZap({ size: 20, class: 'u-text-accent' })\``)
|
|
2046
|
+
|
|
2047
|
+
return text(lines.join('\n'))
|
|
2048
|
+
}
|
|
2049
|
+
)
|
|
2050
|
+
|
|
2051
|
+
// ---------------------------------------------------------------------------
|
|
2052
|
+
// pulse_extract_inspiration — Structured design extraction from URL or image
|
|
2053
|
+
// ---------------------------------------------------------------------------
|
|
2054
|
+
|
|
2055
|
+
server.registerTool(
|
|
2056
|
+
'pulse_extract_inspiration',
|
|
2057
|
+
{
|
|
2058
|
+
description: `Extract a structured design brief from a URL or image the user has shared as inspiration.
|
|
2059
|
+
|
|
2060
|
+
Call this when the user says "I like the design of X", shares a URL, or pastes/attaches an image. It returns a structured extraction template that tells you exactly what to observe and capture. You then use your own browsing or vision tools to fill in the template, and feed the result into pulse_intake.
|
|
2061
|
+
|
|
2062
|
+
Works for:
|
|
2063
|
+
- URLs: visit the site with your browser tool, observe the rendered page
|
|
2064
|
+
- Images: use your vision capability to analyse the screenshot/photo the user shared
|
|
2065
|
+
- Named references: use your knowledge of the brand/site to populate the template
|
|
2066
|
+
|
|
2067
|
+
The extracted values map directly to pulse_intake fields (palette, vibe, styleNotes, antiStyle, font).`,
|
|
2068
|
+
inputSchema: {
|
|
2069
|
+
source: z.string().describe('What to analyse — a URL (e.g. "https://stripe.com"), a site name (e.g. "linear.app"), or a description of an image the user shared (e.g. "the screenshot the user just pasted")'),
|
|
2070
|
+
focus: z.enum(['all', 'colours', 'layout', 'typography', 'feel']).optional().describe('What aspect to focus on. Default is "all". Use "colours" if user specifically wants to extract a palette, "layout" for structural decisions, "typography" for font choices, "feel" for overall tone.'),
|
|
2071
|
+
},
|
|
2072
|
+
},
|
|
2073
|
+
({ source, focus = 'all' }) => {
|
|
2074
|
+
const lines = []
|
|
2075
|
+
lines.push(`# Inspiration Extraction — ${source}\n`)
|
|
2076
|
+
lines.push('Use this template to systematically observe and capture design decisions from the inspiration source.')
|
|
2077
|
+
lines.push('Fill in every section you can observe. Leave a field blank if it cannot be determined.\n')
|
|
2078
|
+
lines.push('---\n')
|
|
2079
|
+
|
|
2080
|
+
const isUrl = /^https?:\/\//.test(source) || /\.(com|io|co|app|design|net|org|dev)\b/.test(source)
|
|
2081
|
+
|
|
2082
|
+
if (isUrl) {
|
|
2083
|
+
lines.push('## Step 1 — Visit the site')
|
|
2084
|
+
lines.push(`Navigate to: **${source}**`)
|
|
2085
|
+
lines.push('Observe the rendered page at desktop width (~1440px) and again at mobile (~375px).')
|
|
2086
|
+
lines.push('Take note of the above-the-fold area, then scroll through the full page.\n')
|
|
2087
|
+
} else {
|
|
2088
|
+
lines.push('## Step 1 — Analyse the source')
|
|
2089
|
+
lines.push(`Source: **${source}**`)
|
|
2090
|
+
lines.push('Use your vision capability or knowledge of this reference to fill in the template below.\n')
|
|
2091
|
+
}
|
|
2092
|
+
|
|
2093
|
+
lines.push('---\n')
|
|
2094
|
+
|
|
2095
|
+
if (focus === 'all' || focus === 'colours') {
|
|
2096
|
+
lines.push('## Colour palette')
|
|
2097
|
+
lines.push('Extract the dominant colours. Try to identify hex values where visible (use browser DevTools / colour picker if available).\n')
|
|
2098
|
+
lines.push('| Role | Colour | Hex (if known) |')
|
|
2099
|
+
lines.push('|---|---|---|')
|
|
2100
|
+
lines.push('| Background | | |')
|
|
2101
|
+
lines.push('| Surface / card | | |')
|
|
2102
|
+
lines.push('| Primary text | | |')
|
|
2103
|
+
lines.push('| Secondary / muted text | | |')
|
|
2104
|
+
lines.push('| Accent / brand | | |')
|
|
2105
|
+
lines.push('| Border / divider | | |')
|
|
2106
|
+
lines.push('')
|
|
2107
|
+
lines.push('**Theme:** light / dark / system')
|
|
2108
|
+
lines.push('**Colour character:** monochrome / single accent / multi-colour / gradient-heavy')
|
|
2109
|
+
lines.push('**→ Maps to:** `palette` and `theme` in pulse_intake\n')
|
|
2110
|
+
}
|
|
2111
|
+
|
|
2112
|
+
if (focus === 'all' || focus === 'typography') {
|
|
2113
|
+
lines.push('## Typography')
|
|
2114
|
+
lines.push('| Property | Observation |')
|
|
2115
|
+
lines.push('|---|---|')
|
|
2116
|
+
lines.push('| Heading font | (name or character — serif, sans, slab, monospace, display) |')
|
|
2117
|
+
lines.push('| Body font | |')
|
|
2118
|
+
lines.push('| Heading weight | (light, regular, medium, bold, black) |')
|
|
2119
|
+
lines.push('| Heading size feel | (compact, standard, oversized, huge) |')
|
|
2120
|
+
lines.push('| Letter spacing | (tight / normal / loose / very loose) |')
|
|
2121
|
+
lines.push('| Line height | (tight / comfortable / generous) |')
|
|
2122
|
+
lines.push('')
|
|
2123
|
+
lines.push('**→ Maps to:** `font` and `styleNotes` in pulse_intake\n')
|
|
2124
|
+
}
|
|
2125
|
+
|
|
2126
|
+
if (focus === 'all' || focus === 'layout') {
|
|
2127
|
+
lines.push('## Layout structure')
|
|
2128
|
+
lines.push('**Hero / above-fold:**')
|
|
2129
|
+
lines.push('- Structure: (centred / left-aligned / full-bleed image / split / typography-only)')
|
|
2130
|
+
lines.push('- Nav position: (top bar / sidebar / minimal / none visible)')
|
|
2131
|
+
lines.push('- CTA placement: (inline with heading / separate row / fixed)')
|
|
2132
|
+
lines.push('')
|
|
2133
|
+
lines.push('**Page rhythm:**')
|
|
2134
|
+
lines.push('- Section width: (full-bleed / contained / narrow column)')
|
|
2135
|
+
lines.push('- Spacing: (tight / standard / very generous)')
|
|
2136
|
+
lines.push('- Grid: (centred single column / multi-column / asymmetric)')
|
|
2137
|
+
lines.push('')
|
|
2138
|
+
lines.push('**→ Maps to:** layout direction for pulse_sketch — note which of the 7 directions this resembles:')
|
|
2139
|
+
lines.push('full-bleed-photo / asymmetric-split / typography-only / editorial-flow / dense-grid / story-scroll / content-first\n')
|
|
2140
|
+
}
|
|
2141
|
+
|
|
2142
|
+
if (focus === 'all' || focus === 'feel') {
|
|
2143
|
+
lines.push('## Overall feel')
|
|
2144
|
+
lines.push('**Emotional tone** (circle one or write your own):')
|
|
2145
|
+
lines.push('trustworthy · urgent · warm · clinical · playful · premium · raw · editorial · technical · minimal\n')
|
|
2146
|
+
lines.push('**Closest vibe** (pick from Pulse vibes):')
|
|
2147
|
+
lines.push('warm / editorial / playful / minimal / bold / brutalist / retro / corporate / neon / paper\n')
|
|
2148
|
+
lines.push('**Three words that describe the design:**')
|
|
2149
|
+
lines.push('1.')
|
|
2150
|
+
lines.push('2.')
|
|
2151
|
+
lines.push('3.\n')
|
|
2152
|
+
lines.push('**→ Maps to:** `vibe` and `styleNotes` in pulse_intake\n')
|
|
2153
|
+
}
|
|
2154
|
+
|
|
2155
|
+
lines.push('---\n')
|
|
2156
|
+
lines.push('## Signature moves')
|
|
2157
|
+
lines.push('What does this design do that most sites don\'t? Note 1–3 distinctive techniques:')
|
|
2158
|
+
lines.push('1.')
|
|
2159
|
+
lines.push('2.')
|
|
2160
|
+
lines.push('3.\n')
|
|
2161
|
+
|
|
2162
|
+
lines.push('---\n')
|
|
2163
|
+
lines.push('## Ready to use in pulse_intake\n')
|
|
2164
|
+
lines.push('Once you have filled in the template above, map the findings to pulse_intake params:')
|
|
2165
|
+
lines.push('')
|
|
2166
|
+
lines.push('```')
|
|
2167
|
+
lines.push('pulse_intake({')
|
|
2168
|
+
lines.push(' // ... name, pitch, features ...')
|
|
2169
|
+
lines.push(' palette: "<hex values extracted above, comma-separated>",')
|
|
2170
|
+
lines.push(' theme: "<light or dark>",')
|
|
2171
|
+
lines.push(' font: "<heading font name if identified>",')
|
|
2172
|
+
lines.push(' vibe: "<closest Pulse vibe>",')
|
|
2173
|
+
lines.push(' styleNotes: "<3-word summary> + signature moves observed",')
|
|
2174
|
+
lines.push(' inspiration: "<' + source + '>",')
|
|
2175
|
+
lines.push('})')
|
|
2176
|
+
lines.push('```')
|
|
2177
|
+
lines.push('')
|
|
2178
|
+
lines.push('Then call `pulse_sketch` — pass the layout direction observed above as context in your `brief`.')
|
|
2179
|
+
|
|
2180
|
+
return text(lines.join('\n'))
|
|
2181
|
+
}
|
|
2182
|
+
)
|
|
2183
|
+
|
|
2184
|
+
// ---------------------------------------------------------------------------
|
|
2185
|
+
// pulse_intake — Product intake before scaffolding
|
|
2186
|
+
// ---------------------------------------------------------------------------
|
|
2187
|
+
|
|
2188
|
+
server.registerTool(
|
|
2189
|
+
'pulse_intake',
|
|
2190
|
+
{
|
|
2191
|
+
description: `Capture product details before scaffolding a new page or site. Run this first for any new project or branded template — before pulse_sketch or pulse_intent.
|
|
2192
|
+
|
|
2193
|
+
Returns a structured product brief with copy-ready content, early contrast warnings, and component suggestions matched to the vibe. After intake, call pulse_sketch to explore structural layout directions before writing any code.
|
|
2194
|
+
|
|
2195
|
+
Accepts antiStyle ("what should this NOT look like?") and inspiration (a site or brand reference) to push the design beyond default patterns.`,
|
|
2196
|
+
inputSchema: {
|
|
2197
|
+
name: z.string().describe('App or product name exactly as it should appear in the UI'),
|
|
2198
|
+
pitch: z.string().describe('One-line description — what the product does. Used as the hero subtitle.'),
|
|
2199
|
+
features: z.string().describe('3–6 real selling points, comma-separated. E.g. "Habit streaks, Smart reminders, Weekly insights, Dark mode"'),
|
|
2200
|
+
targetUser: z.string().optional().describe('Who this is for — shapes copy tone. E.g. "busy professionals", "indie developers", "home cooks"'),
|
|
2201
|
+
palette: z.string().optional().describe('Brand colours as hex values, comma-separated. E.g. "#6366f1, #f8fafc, #1e1b4b". Used for early contrast check.'),
|
|
2202
|
+
font: z.string().optional().describe('Brand font name, or omit for system-ui'),
|
|
2203
|
+
theme: z.enum(['light', 'dark']).optional().describe('Colour theme preference'),
|
|
2204
|
+
vibe: z.enum(['warm', 'editorial', 'playful', 'minimal', 'bold', 'brutalist', 'retro', 'corporate', 'neon', 'paper']).optional().describe('Visual personality — see pulse://guide/design-references for full descriptions. warm (rounded, inviting), editorial (serif, sharp), playful (very rounded), minimal (clean), bold (impact headings), brutalist (zero radius, raw), retro (slab serif, nostalgic), corporate (conservative), neon (monospace, futuristic), paper (organic, serif, journal)'),
|
|
2205
|
+
styleNotes: z.string().optional().describe('Free-form style direction — e.g. "like a print poster", "market stall chalkboard feel", "clinical and professional"'),
|
|
2206
|
+
antiStyle: z.string().optional().describe('"What should this NOT look like?" — negative aesthetic constraint. E.g. "not corporate SaaS", "not another Vercel dark card page", "not a startup landing page". Used to steer component and layout choices away from over-used patterns.'),
|
|
2207
|
+
inspiration: z.string().optional().describe('A website, brand, or visual reference you admire — any industry. E.g. "stripe.com", "a Swiss railway poster", "the Economist magazine". Used to extract aesthetic intent and inform structural choices, not to copy.'),
|
|
2208
|
+
},
|
|
2209
|
+
},
|
|
2210
|
+
({ name, pitch, features, targetUser, palette, font, theme = 'dark', vibe: rawVibe, styleNotes, antiStyle, inspiration }) => {
|
|
2211
|
+
const vibe = normaliseVibe(rawVibe)
|
|
2212
|
+
const featureList = features.split(',').map(f => f.trim()).filter(Boolean)
|
|
2213
|
+
const lines = []
|
|
2214
|
+
|
|
2215
|
+
lines.push(`# Product brief — ${name}\n`)
|
|
2216
|
+
lines.push(`**Pitch:** ${pitch}`)
|
|
2217
|
+
if (targetUser) lines.push(`**Target user:** ${targetUser}`)
|
|
2218
|
+
lines.push(`**Theme:** ${theme}`)
|
|
2219
|
+
if (vibe) lines.push(`**Vibe:** ${vibe} → set \`meta.vibe: '${vibe}'\` in the spec`)
|
|
2220
|
+
if (styleNotes) lines.push(`**Style direction:** ${styleNotes}`)
|
|
2221
|
+
if (inspiration) lines.push(`**Inspiration:** ${inspiration} — extract the aesthetic intent (structure, whitespace, type scale, colour restraint) not the literal appearance`)
|
|
2222
|
+
if (antiStyle) lines.push(`**Anti-pattern:** NOT ${antiStyle} — actively steer away from this in layout and component choices`)
|
|
2223
|
+
if (font) lines.push(`**Font:** ${font}`)
|
|
2224
|
+
lines.push('')
|
|
2225
|
+
|
|
2226
|
+
lines.push('## Features')
|
|
2227
|
+
featureList.forEach((f, i) => lines.push(`${i + 1}. ${f}`))
|
|
2228
|
+
lines.push('')
|
|
2229
|
+
|
|
2230
|
+
// Palette
|
|
2231
|
+
const hexValues = palette
|
|
2232
|
+
? palette.split(',').map(s => s.trim()).filter(s => /^#[0-9a-fA-F]{3,8}$/.test(s))
|
|
2233
|
+
: []
|
|
2234
|
+
|
|
2235
|
+
if (hexValues.length > 0) {
|
|
2236
|
+
lines.push('## Palette')
|
|
2237
|
+
hexValues.forEach(h => lines.push(` ${h}`))
|
|
2238
|
+
lines.push('')
|
|
2239
|
+
|
|
2240
|
+
// Quick luminance / tone analysis
|
|
2241
|
+
const tones = hexValues.map(hex => {
|
|
2242
|
+
const r = parseInt(hex.slice(1, 3), 16) / 255
|
|
2243
|
+
const g = parseInt(hex.slice(3, 5), 16) / 255
|
|
2244
|
+
const b = parseInt(hex.slice(5, 7), 16) / 255
|
|
2245
|
+
const lum = 0.2126 * r + 0.7152 * g + 0.0722 * b
|
|
2246
|
+
const tone = lum > 0.7 ? 'light' : lum > 0.2 ? 'mid' : 'dark'
|
|
2247
|
+
return { hex, lum: lum.toFixed(3), tone }
|
|
2248
|
+
})
|
|
2249
|
+
|
|
2250
|
+
lines.push('## Palette tone analysis')
|
|
2251
|
+
lines.push('| Hex | Luminance | Tone | Use for |')
|
|
2252
|
+
lines.push('|---|---|---|---|')
|
|
2253
|
+
tones.forEach(({ hex, lum, tone }) => {
|
|
2254
|
+
const use = tone === 'light' ? 'background, surface' : tone === 'dark' ? 'backgrounds, deep fills' : 'accent colour, buttons'
|
|
2255
|
+
lines.push(`| ${hex} | ${lum} | ${tone} | ${use} |`)
|
|
2256
|
+
})
|
|
2257
|
+
lines.push('')
|
|
2258
|
+
|
|
2259
|
+
// Early contrast warnings — check mid-tones on light/dark backgrounds
|
|
2260
|
+
const mids = tones.filter(t => t.tone === 'mid')
|
|
2261
|
+
const lights = tones.filter(t => t.tone === 'light')
|
|
2262
|
+
const darks = tones.filter(t => t.tone === 'dark')
|
|
2263
|
+
|
|
2264
|
+
const contrastWarnings = []
|
|
2265
|
+
const contrast = (l1, l2) => {
|
|
2266
|
+
const [hi, lo] = l1 > l2 ? [l1, l2] : [l2, l1]
|
|
2267
|
+
return (hi + 0.05) / (lo + 0.05)
|
|
2268
|
+
}
|
|
2269
|
+
|
|
2270
|
+
for (const mid of mids) {
|
|
2271
|
+
if (lights.length > 0) {
|
|
2272
|
+
const bg = lights[0]
|
|
2273
|
+
const ratio = contrast(parseFloat(mid.lum), parseFloat(bg.lum))
|
|
2274
|
+
if (ratio < 4.5) {
|
|
2275
|
+
contrastWarnings.push(`⚠ ${mid.hex} (mid) on ${bg.hex} (light): contrast ${ratio.toFixed(1)}:1 — fails WCAG AA (4.5:1 needed for body text). Darken the accent or use it only on large text (3:1 threshold).`)
|
|
2276
|
+
}
|
|
2277
|
+
}
|
|
2278
|
+
if (darks.length > 0) {
|
|
2279
|
+
const bg = darks[0]
|
|
2280
|
+
const ratio = contrast(parseFloat(mid.lum), parseFloat(bg.lum))
|
|
2281
|
+
if (ratio < 4.5) {
|
|
2282
|
+
contrastWarnings.push(`⚠ ${mid.hex} (mid) on ${bg.hex} (dark): contrast ${ratio.toFixed(1)}:1 — fails WCAG AA for body text. Consider a lighter tint for the dark theme.`)
|
|
2283
|
+
}
|
|
2284
|
+
}
|
|
2285
|
+
}
|
|
2286
|
+
|
|
2287
|
+
if (contrastWarnings.length > 0) {
|
|
2288
|
+
lines.push('## ⚠ Early contrast warnings')
|
|
2289
|
+
lines.push('Resolve these before writing CSS — fixing palette problems after building is expensive:\n')
|
|
2290
|
+
contrastWarnings.forEach(w => lines.push(w))
|
|
2291
|
+
lines.push('')
|
|
2292
|
+
lines.push('**Tip:** Mid-tone palette colours often fail on both light and dark backgrounds. Use them for large background areas (3:1 threshold), and derive a darker variant for small text (needs 4.5:1).')
|
|
2293
|
+
lines.push('')
|
|
2294
|
+
} else if (hexValues.length > 1) {
|
|
2295
|
+
lines.push('✓ No obvious contrast failures detected in palette. Run `pulse_check_contrast` after writing theme CSS for a full check.')
|
|
2296
|
+
lines.push('')
|
|
2297
|
+
}
|
|
2298
|
+
} else if (palette) {
|
|
2299
|
+
lines.push(`Note: palette "${palette}" could not be parsed — provide hex values like #6366f1, #f8fafc\n`)
|
|
2300
|
+
}
|
|
2301
|
+
|
|
2302
|
+
lines.push('## Ready to build')
|
|
2303
|
+
lines.push('')
|
|
2304
|
+
lines.push('Pass this brief to `pulse_intent` or use it when adapting a template. Replace every placeholder in the scaffold with content from this brief.')
|
|
2305
|
+
lines.push('')
|
|
2306
|
+
lines.push('**Spec copy checklist:**')
|
|
2307
|
+
lines.push(`- [ ] App name everywhere it appears: \`${name}\``)
|
|
2308
|
+
lines.push(`- [ ] Hero title/subtitle: use the pitch — "${pitch}"`)
|
|
2309
|
+
lines.push(`- [ ] Features section: ${featureList.slice(0, 3).join(', ')} (+ ${Math.max(0, featureList.length - 3)} more)`)
|
|
2310
|
+
if (targetUser) lines.push(`- [ ] Copy tone: aimed at ${targetUser}`)
|
|
2311
|
+
lines.push(`- [ ] Theme: ${theme} — ${theme === 'light' ? "use `meta.theme: 'light'` and target `[data-theme=\"light\"]` for CSS overrides" : 'default Pulse theme (no meta.theme needed), override tokens in `:root`'}`)
|
|
2312
|
+
if (vibe) {
|
|
2313
|
+
lines.push(`- [ ] Vibe: \`meta.vibe: '${vibe}'\` — sets data-vibe on body, activating geometry/type presets`)
|
|
2314
|
+
const vibeGuide = {
|
|
2315
|
+
warm: 'Pair with rounded imagery (photoCard with tilt), warmer accent tones, section variant: paper or alt',
|
|
2316
|
+
editorial: 'Use layout: overlap on hero for dramatic imagery. Serif headings via --font-display. section variant: diagonal for transitions.',
|
|
2317
|
+
playful: 'gallery with rounded images, photoCard with tilt, marquee for social proof, bright accent',
|
|
2318
|
+
minimal: 'hero align: left (no center), section variant: spotlight, clean whitespace, monochrome palette',
|
|
2319
|
+
bold: 'hero gradient with strong color, large stat components, section variant: dark for contrast sections',
|
|
2320
|
+
brutalist: 'raw section wrappers, oversized heading with zero padding, no shadows, high contrast accent (red/lime)',
|
|
2321
|
+
retro: 'decorate(pattern: "dots") backgrounds, badge eyebrows, thick dividers, earthy amber/cream palette',
|
|
2322
|
+
corporate: 'feature grid with checkmarks, testimonial with company name+logo, trust logo marquee, stat bar',
|
|
2323
|
+
neon: 'section(variant: "spotlight") for glow sections, codeWindow for terminal/API examples, stat with glowing accent',
|
|
2324
|
+
paper: 'pullquote prominently, prose with generous line-height, avatar for author, container(size: "sm")',
|
|
2325
|
+
}
|
|
2326
|
+
if (vibeGuide[vibe]) lines.push(` **${vibe} component suggestions:** ${vibeGuide[vibe]}`)
|
|
2327
|
+
}
|
|
2328
|
+
if (styleNotes) lines.push(`- [ ] Style direction: "${styleNotes}" — translate this into component choices and layout decisions`)
|
|
2329
|
+
if (antiStyle) lines.push(`- [ ] Anti-pattern enforced: "${antiStyle}" — if your layout resembles this, reconsider structural choices before writing the spec`)
|
|
2330
|
+
if (inspiration) lines.push(`- [ ] Inspiration taken from: "${inspiration}" — extract intent (spacing, type scale, restraint), not appearance`)
|
|
2331
|
+
if (font) lines.push(`- [ ] Font: set \`--font: '${font}', system-ui\` in \`:root\` via app.css; if display font differs set \`--font-display: '${font}'\``)
|
|
2332
|
+
|
|
2333
|
+
lines.push('')
|
|
2334
|
+
lines.push('## Next step')
|
|
2335
|
+
lines.push('')
|
|
2336
|
+
lines.push('Call `pulse_sketch` with this brief to get 3 structurally distinct layout directions before writing any code.')
|
|
2337
|
+
lines.push('This prevents defaulting to the standard centred hero + three columns layout.')
|
|
2338
|
+
if (antiStyle) lines.push(`Pass \`antiStyle: "${antiStyle}"\` to pulse_sketch to filter directions that might drift toward the constraint.`)
|
|
2339
|
+
lines.push('')
|
|
2340
|
+
lines.push('**Before writing image tags:** if you plan to use external images (picsum.photos, Unsplash, Cloudinary, etc.), add the host to `csp.img-src` in `pulse.config.js` before your first Lighthouse run — or images will be blocked and Best Practices will fail. See `pulse://guide/styles` → "External images (img-src)".')
|
|
2341
|
+
|
|
2342
|
+
// Save brief to .pulse/brief.json for later design review
|
|
2343
|
+
try {
|
|
2344
|
+
const pulseDir = path.join(ROOT, '.pulse')
|
|
2345
|
+
const briefFile = path.join(pulseDir, 'brief.json')
|
|
2346
|
+
fs.mkdirSync(pulseDir, { recursive: true })
|
|
2347
|
+
fs.writeFileSync(briefFile, JSON.stringify({
|
|
2348
|
+
name, pitch, features: featureList, targetUser: targetUser ?? null,
|
|
2349
|
+
vibe: vibe ?? null, theme, styleNotes: styleNotes ?? null,
|
|
2350
|
+
antiStyle: antiStyle ?? null, palette: palette ?? null,
|
|
2351
|
+
font: font ?? null, inspiration: inspiration ?? null,
|
|
2352
|
+
}, null, 2))
|
|
2353
|
+
} catch (_) { /* non-fatal — brief.json is best-effort */ }
|
|
2354
|
+
|
|
2355
|
+
return text(lines.join('\n'))
|
|
2356
|
+
}
|
|
2357
|
+
)
|
|
2358
|
+
|
|
2359
|
+
// ---------------------------------------------------------------------------
|
|
2360
|
+
// pulse_sketch — Generate 3 structural layout directions before writing code
|
|
2361
|
+
// ---------------------------------------------------------------------------
|
|
2362
|
+
|
|
2363
|
+
const SKETCH_DIRECTIONS = {
|
|
2364
|
+
'full-bleed-photo': {
|
|
2365
|
+
name: 'Full-Bleed Photo',
|
|
2366
|
+
mood: 'Immersive. The image IS the hero. Type floats over.',
|
|
2367
|
+
gesture: 'Edge-to-edge image fills the viewport. Title reversed out. Content below in a clean strip.',
|
|
2368
|
+
wireframe: [
|
|
2369
|
+
'┌─────────────────────────────────────────────────────┐',
|
|
2370
|
+
'│▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓│',
|
|
2371
|
+
'│▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓│',
|
|
2372
|
+
'│▓▓▓▓▓ LARGE TITLE — REVERSED OUT ▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓│',
|
|
2373
|
+
'│▓▓▓▓▓ subtitle · CTA ▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓│',
|
|
2374
|
+
'│▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓│',
|
|
2375
|
+
'├─────────────────────────────────────────────────────┤',
|
|
2376
|
+
'│ Content / features / grid below the fold │',
|
|
2377
|
+
'└─────────────────────────────────────────────────────┘',
|
|
2378
|
+
],
|
|
2379
|
+
decisions: [
|
|
2380
|
+
'Type positioned bottom-left or lower-centre — never centred at eye level',
|
|
2381
|
+
'Nav is transparent on load, transitions to solid on scroll',
|
|
2382
|
+
'Gradient overlay at bottom of hero — text readable without a stroke',
|
|
2383
|
+
'Below-fold uses a clean background for contrast reset',
|
|
2384
|
+
],
|
|
2385
|
+
useComponents: 'nav (transparent variant), hero (gradient overlay), footer, card (below-fold features)',
|
|
2386
|
+
rawHtml: 'hero layout positioning — use hero() component with custom .hero-wrapper CSS for full-bleed background image',
|
|
2387
|
+
},
|
|
2388
|
+
'asymmetric-split': {
|
|
2389
|
+
name: 'Asymmetric Split',
|
|
2390
|
+
mood: 'Confident and considered. Permanent visual tension.',
|
|
2391
|
+
gesture: '60/40 or 65/35 vertical split throughout. Left: identity + text. Right: image, proof, or form.',
|
|
2392
|
+
wireframe: [
|
|
2393
|
+
'┌──────────────────┬──────────────────────────────────┐',
|
|
2394
|
+
'│ │ │',
|
|
2395
|
+
'│ Logo │ ▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓ │',
|
|
2396
|
+
'│ │ ▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓ │',
|
|
2397
|
+
'│ BIG HEADING │ ▓▓▓▓▓ FULL-BLEED IMAGE ▓▓▓▓ │',
|
|
2398
|
+
'│ │ ▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓ │',
|
|
2399
|
+
'│ Subtitle │ │',
|
|
2400
|
+
'│ [CTA Button] │ │',
|
|
2401
|
+
'│ │ │',
|
|
2402
|
+
'└──────────────────┴──────────────────────────────────┘',
|
|
2403
|
+
],
|
|
2404
|
+
decisions: [
|
|
2405
|
+
'Left column can be sticky on large screens — identity persists while right scrolls',
|
|
2406
|
+
'Asymmetry is maintained across ALL sections — never go full-width mid-page',
|
|
2407
|
+
'Right column can hold image, testimonials, or a form',
|
|
2408
|
+
'Mobile: stacks vertically — image first, then text',
|
|
2409
|
+
],
|
|
2410
|
+
useComponents: 'nav (left column), hero (split layout), media, card, footer',
|
|
2411
|
+
rawHtml: 'page-level grid wrapper — wrap all sections in a two-column CSS grid container',
|
|
2412
|
+
},
|
|
2413
|
+
'typography-only': {
|
|
2414
|
+
name: 'Typography-Only',
|
|
2415
|
+
mood: 'Quiet confidence. The words do all the work.',
|
|
2416
|
+
gesture: 'No hero image. Ultra-large heading fills the viewport. Restrained details. Generous whitespace.',
|
|
2417
|
+
wireframe: [
|
|
2418
|
+
'┌─────────────────────────────────────────────────────┐',
|
|
2419
|
+
'│ │',
|
|
2420
|
+
'│ Logo Nav links │',
|
|
2421
|
+
'│ │',
|
|
2422
|
+
'│ THE HEADLINE IS ENORMOUS. │',
|
|
2423
|
+
'│ IT FILLS THE SPACE. │',
|
|
2424
|
+
'│ │',
|
|
2425
|
+
'│ Subtitle copy. One sentence max. │',
|
|
2426
|
+
'│ │',
|
|
2427
|
+
'│ [ CTA Button ] ↓ Scroll │',
|
|
2428
|
+
'│ │',
|
|
2429
|
+
'└─────────────────────────────────────────────────────┘',
|
|
2430
|
+
],
|
|
2431
|
+
decisions: [
|
|
2432
|
+
'font-size: clamp(3rem, 10vw, 9rem) — headline scales with viewport',
|
|
2433
|
+
'No images above the fold — trust the writing',
|
|
2434
|
+
'Single accent colour — everything else is text and background',
|
|
2435
|
+
'Below-fold sections may introduce imagery, but type remains dominant',
|
|
2436
|
+
],
|
|
2437
|
+
useComponents: 'nav, footer, stat (for numbers/proof below fold)',
|
|
2438
|
+
useComponents: 'hero (size:lg, align:center), section, container, footer',
|
|
2439
|
+
rawHtml: 'page-level typography overrides — custom font-size on hero title, tighter letter-spacing',
|
|
2440
|
+
},
|
|
2441
|
+
'editorial-flow': {
|
|
2442
|
+
name: 'Editorial Flow',
|
|
2443
|
+
mood: 'Authoritative. Reads like a magazine, not a landing page.',
|
|
2444
|
+
gesture: 'Long vertical column with intentional zone widths — intro full-width, body at reading width (~65ch), pullouts wider.',
|
|
2445
|
+
wireframe: [
|
|
2446
|
+
'┌─────────────────────────────────────────────────────┐',
|
|
2447
|
+
'│ Full-width header image or colour fill │',
|
|
2448
|
+
'│ ───────────────────────────────────────── │',
|
|
2449
|
+
'│ HEADLINE RUNS FULL WIDTH HERE │',
|
|
2450
|
+
'│ ───────────────────────────────────────── │',
|
|
2451
|
+
'│ │ Opening paragraph at reading width │ │',
|
|
2452
|
+
'│ │ (max 65ch, generous leading) │ │',
|
|
2453
|
+
'│ │ │ │',
|
|
2454
|
+
'│ ╔══════ PULLQUOTE BREAKS OUT WIDER ══════╗ │ │',
|
|
2455
|
+
'│ ║ "The quoted passage." ║ │ │',
|
|
2456
|
+
'│ ╚════════════════════════════════════════╝ │ │',
|
|
2457
|
+
'│ │ Body text resumes… │ │',
|
|
2458
|
+
'└─────────────────────────────────────────────────────┘',
|
|
2459
|
+
],
|
|
2460
|
+
decisions: [
|
|
2461
|
+
'Text is the primary element — no sidebar, no card grid above the fold',
|
|
2462
|
+
'pullquote used prominently to break the reading rhythm',
|
|
2463
|
+
'Section widths vary deliberately — not uniform padding throughout',
|
|
2464
|
+
'CTA lives at the end of the reading flow, not above the fold',
|
|
2465
|
+
],
|
|
2466
|
+
useComponents: 'nav, hero (image layout), prose, pullquote, footer, avatar (author bio)',
|
|
2467
|
+
rawHtml: 'section width variations — use container(size) with different sizes for editorial flow',
|
|
2468
|
+
},
|
|
2469
|
+
'dense-grid': {
|
|
2470
|
+
name: 'Dense Grid',
|
|
2471
|
+
mood: 'Rich and scannable. Every pixel earns its place.',
|
|
2472
|
+
gesture: 'Information-dense from the first scroll. Tight gutters, small cards, horizontal + vertical navigation.',
|
|
2473
|
+
wireframe: [
|
|
2474
|
+
'┌──────────────────────────────────────────────────────┐',
|
|
2475
|
+
'│ Logo · Nav · Search · Category filter · CTA │',
|
|
2476
|
+
'├───────────────┬────────────────────────────┬─────────┤',
|
|
2477
|
+
'│ │ ┌──────┐ ┌──────┐ ┌────┐ │ Filter │',
|
|
2478
|
+
'│ Sidebar │ │ Card │ │ Card │ │ │ │ ────── │',
|
|
2479
|
+
'│ ────────── │ │ │ │ │ │ │ │ Tags │',
|
|
2480
|
+
'│ Category 1 │ └──────┘ └──────┘ └────┘ │ ────── │',
|
|
2481
|
+
'│ Category 2 │ ┌────┐ ┌──────────┐ │ Sort │',
|
|
2482
|
+
'│ Category 3 │ │ │ │ Featured │ │ │',
|
|
2483
|
+
'└───────────────┴────────────────────────────┴─────────┘',
|
|
2484
|
+
],
|
|
2485
|
+
decisions: [
|
|
2486
|
+
'Three-column layout with sidebar — breaks the standard single-column scroll',
|
|
2487
|
+
'Cards use flush:true — image-first, minimal padding',
|
|
2488
|
+
'No hero — jump straight into content',
|
|
2489
|
+
'Filtering visible on load — for sites where browsing/exploration is the UX',
|
|
2490
|
+
],
|
|
2491
|
+
useComponents: 'nav, card (flush:true), badge, grid, footer',
|
|
2492
|
+
rawHtml: 'three-column page wrapper — wrap entire page in CSS grid layout for sidebar + content + filters',
|
|
2493
|
+
},
|
|
2494
|
+
'story-scroll': {
|
|
2495
|
+
name: 'Story Scroll',
|
|
2496
|
+
mood: 'Narrative and immersive. Each section feels like a chapter.',
|
|
2497
|
+
gesture: 'Full-viewport sections that scroll one at a time. Each zone fills 100svh with a single focused message.',
|
|
2498
|
+
wireframe: [
|
|
2499
|
+
'┌──────────────────────────────────────┐ ← 100svh',
|
|
2500
|
+
'│ │',
|
|
2501
|
+
'│ IDENTITY │',
|
|
2502
|
+
'│ The hook, the name, the feeling │',
|
|
2503
|
+
'│ │',
|
|
2504
|
+
'└──────────────────────────────────────┘ ← scroll',
|
|
2505
|
+
'┌──────────────────────────────────────┐ ← 100svh',
|
|
2506
|
+
'│ ▓▓▓▓▓▓▓▓▓▓▓▓ PROOF ZONE │',
|
|
2507
|
+
'│ ▓▓ photo ▓▓▓ Stat or testimonial │',
|
|
2508
|
+
'└──────────────────────────────────────┘ ← scroll',
|
|
2509
|
+
'┌──────────────────────────────────────┐ ← 100svh',
|
|
2510
|
+
'│ THE ASK │',
|
|
2511
|
+
'│ Form or CTA, nothing else │',
|
|
2512
|
+
'└──────────────────────────────────────┘',
|
|
2513
|
+
],
|
|
2514
|
+
decisions: [
|
|
2515
|
+
'min-height: 100svh per section — each screen has one job',
|
|
2516
|
+
'Nav is minimal or floating — does not compete with content',
|
|
2517
|
+
'Sections alternate visual weight (light → dark → image-heavy)',
|
|
2518
|
+
'Best for short sites: 3–5 sections maximum',
|
|
2519
|
+
],
|
|
2520
|
+
useComponents: 'nav (minimal), section (min-height: 100svh), hero, stat, cta, footer',
|
|
2521
|
+
rawHtml: 'full-viewport section wrappers — use section() with custom CSS for 100svh height and scroll snap',
|
|
2522
|
+
},
|
|
2523
|
+
'content-first': {
|
|
2524
|
+
name: 'Content First',
|
|
2525
|
+
mood: 'Humble and honest. The content leads, the design follows.',
|
|
2526
|
+
gesture: 'Narrow reading column, no hero image, minimal nav — content is visible within two scrolls of the fold.',
|
|
2527
|
+
wireframe: [
|
|
2528
|
+
'┌─────────────────────────────────────────────────────┐',
|
|
2529
|
+
'│ Logo Simple nav │',
|
|
2530
|
+
'├─────────────────────────────────────────────────────┤',
|
|
2531
|
+
'│ │',
|
|
2532
|
+
'│ PAGE TITLE │',
|
|
2533
|
+
'│ One line of context │',
|
|
2534
|
+
'│ │',
|
|
2535
|
+
'│ ┌─────────────────────────────────────────────┐ │',
|
|
2536
|
+
'│ │ Content in a readable column (~65ch) │ │',
|
|
2537
|
+
'│ │ Designed for reading, not scanning │ │',
|
|
2538
|
+
'│ └─────────────────────────────────────────────┘ │',
|
|
2539
|
+
'│ │',
|
|
2540
|
+
'└─────────────────────────────────────────────────────┘',
|
|
2541
|
+
],
|
|
2542
|
+
decisions: [
|
|
2543
|
+
'max-width: 65ch on body text — readability over width-filling',
|
|
2544
|
+
'No above-fold hero — title and content appear immediately',
|
|
2545
|
+
'CTA integrated inline with content — not a separate section',
|
|
2546
|
+
'Feels finished at 3 sections, not 8',
|
|
2547
|
+
],
|
|
2548
|
+
useComponents: 'nav, hero (no image, size:lg), prose, pullquote, avatar, footer',
|
|
2549
|
+
rawHtml: 'narrow content wrapper — use container(size:sm) for 65ch reading width',
|
|
2550
|
+
},
|
|
2551
|
+
}
|
|
2552
|
+
|
|
2553
|
+
const SKETCH_VIBE_MAP = {
|
|
2554
|
+
warm: ['asymmetric-split', 'full-bleed-photo', 'story-scroll'],
|
|
2555
|
+
editorial: ['editorial-flow', 'typography-only', 'content-first'],
|
|
2556
|
+
playful: ['full-bleed-photo', 'story-scroll', 'dense-grid'],
|
|
2557
|
+
minimal: ['typography-only', 'content-first', 'asymmetric-split'],
|
|
2558
|
+
bold: ['typography-only', 'full-bleed-photo', 'story-scroll'],
|
|
2559
|
+
brutalist: ['typography-only', 'dense-grid', 'content-first'],
|
|
2560
|
+
retro: ['story-scroll', 'full-bleed-photo', 'editorial-flow'],
|
|
2561
|
+
corporate: ['asymmetric-split', 'content-first', 'dense-grid'],
|
|
2562
|
+
neon: ['full-bleed-photo', 'story-scroll', 'dense-grid'],
|
|
2563
|
+
paper: ['editorial-flow', 'content-first', 'typography-only'],
|
|
2564
|
+
}
|
|
2565
|
+
|
|
2566
|
+
const SKETCH_PAGE_MAP = {
|
|
2567
|
+
landing: ['full-bleed-photo', 'asymmetric-split', 'typography-only'],
|
|
2568
|
+
about: ['editorial-flow', 'story-scroll', 'content-first'],
|
|
2569
|
+
portfolio: ['dense-grid', 'full-bleed-photo', 'asymmetric-split'],
|
|
2570
|
+
blog: ['editorial-flow', 'content-first', 'dense-grid'],
|
|
2571
|
+
product: ['story-scroll', 'asymmetric-split', 'full-bleed-photo'],
|
|
2572
|
+
event: ['full-bleed-photo', 'story-scroll', 'typography-only'],
|
|
2573
|
+
contact: ['content-first', 'typography-only', 'asymmetric-split'],
|
|
2574
|
+
dashboard: ['dense-grid', 'content-first', 'asymmetric-split'],
|
|
2575
|
+
}
|
|
2576
|
+
|
|
2577
|
+
server.registerTool(
|
|
2578
|
+
'pulse_sketch',
|
|
2579
|
+
{
|
|
2580
|
+
description: `Generate 3 structurally distinct layout directions for a page before writing any code.
|
|
2581
|
+
|
|
2582
|
+
Call this after pulse_intake, before fetching guide sections or writing the spec. Returns three named layout directions — each with a different structural gesture (full-bleed, asymmetric split, typography-only, editorial flow, etc.) — so you can choose the one that matches the emotional intent rather than defaulting to "centred hero + three-column features".
|
|
2583
|
+
|
|
2584
|
+
After choosing a direction, fetch \`pulse://guide/explore\` for raw HTML patterns that match the structure, then \`pulse://guide/components\` for Pulse components to mix in.`,
|
|
2585
|
+
inputSchema: {
|
|
2586
|
+
brief: z.string().describe('Product brief — paste the pulse_intake output, or describe the site in free text: what it is, who it\'s for, what feeling it should give.'),
|
|
2587
|
+
vibe: z.enum(['warm', 'editorial', 'playful', 'minimal', 'bold', 'brutalist', 'retro', 'corporate', 'neon', 'paper']).optional().describe('Visual personality from pulse_intake — tailors the structural suggestions.'),
|
|
2588
|
+
antiStyle: z.string().optional().describe('What it should NOT look like — from pulse_intake antiStyle or stated directly.'),
|
|
2589
|
+
pageType: z.enum(['landing', 'about', 'portfolio', 'blog', 'product', 'event', 'contact', 'dashboard']).optional().describe('Type of page — calibrates structural options. Defaults to landing.'),
|
|
2590
|
+
},
|
|
2591
|
+
},
|
|
2592
|
+
({ brief, vibe: rawVibe, antiStyle, pageType = 'landing' }) => {
|
|
2593
|
+
const vibe = normaliseVibe(rawVibe)
|
|
2594
|
+
const lines = []
|
|
2595
|
+
lines.push('# Layout Directions\n')
|
|
2596
|
+
lines.push('Three structurally distinct options for this page. Read all three before choosing — the right one is rarely the first.\n')
|
|
2597
|
+
|
|
2598
|
+
if (antiStyle) {
|
|
2599
|
+
lines.push(`**Anti-constraint:** NOT "${antiStyle}"`)
|
|
2600
|
+
lines.push('Each direction below is chosen to contrast with this constraint. If any still feels too close, say so.\n')
|
|
2601
|
+
}
|
|
2602
|
+
|
|
2603
|
+
const keys = (vibe && SKETCH_VIBE_MAP[vibe]) || SKETCH_PAGE_MAP[pageType] || SKETCH_PAGE_MAP.landing
|
|
2604
|
+
const directions = keys.map(k => SKETCH_DIRECTIONS[k]).filter(Boolean)
|
|
2605
|
+
|
|
2606
|
+
directions.forEach((dir, i) => {
|
|
2607
|
+
lines.push('---\n')
|
|
2608
|
+
lines.push(`## Direction ${i + 1}: ${dir.name}`)
|
|
2609
|
+
lines.push(`*${dir.mood}*\n`)
|
|
2610
|
+
lines.push(`**Structural gesture:** ${dir.gesture}\n`)
|
|
2611
|
+
lines.push('```')
|
|
2612
|
+
dir.wireframe.forEach(l => lines.push(l))
|
|
2613
|
+
lines.push('```\n')
|
|
2614
|
+
lines.push('**Key structural decisions:**')
|
|
2615
|
+
dir.decisions.forEach(d => lines.push(`- ${d}`))
|
|
2616
|
+
lines.push('')
|
|
2617
|
+
lines.push('**Component strategy:**')
|
|
2618
|
+
lines.push(`- **Use Pulse components:** ${dir.useComponents}`)
|
|
2619
|
+
lines.push(`- **Custom CSS/styling may be needed for:** ${dir.rawHtml}`)
|
|
2620
|
+
lines.push(`- **Important:** Always use components first. Custom CSS is for styling overrides only, never for rewriting component HTML from scratch.`)
|
|
2621
|
+
lines.push('')
|
|
2622
|
+
})
|
|
2623
|
+
|
|
2624
|
+
lines.push('---\n')
|
|
2625
|
+
lines.push('## Next step\n')
|
|
2626
|
+
lines.push('Choose a direction (e.g. "Direction 2" or "mix 1 and 3 — split layout but with full-bleed image on the right").')
|
|
2627
|
+
lines.push('')
|
|
2628
|
+
lines.push('Then:')
|
|
2629
|
+
lines.push('1. Fetch `pulse://guide/explore` — raw HTML patterns for your chosen structure')
|
|
2630
|
+
lines.push('2. Fetch `pulse://guide/components` — Pulse components to mix in')
|
|
2631
|
+
lines.push('3. Fetch `pulse://guide/spec` — spec format reference')
|
|
2632
|
+
lines.push('4. Write the spec')
|
|
2633
|
+
|
|
2634
|
+
return text(lines.join('\n'))
|
|
2635
|
+
}
|
|
2636
|
+
)
|
|
2637
|
+
|
|
2638
|
+
|
|
2639
|
+
|
|
2640
|
+
/**
|
|
2641
|
+
* Parse a hex color string (#rgb, #rrggbb, #rrggbbaa) to relative luminance.
|
|
2642
|
+
* Returns null if the string is not a valid hex color.
|
|
2643
|
+
*/
|
|
2644
|
+
function hexToLuminance(hex) {
|
|
2645
|
+
const h = hex.replace('#', '')
|
|
2646
|
+
let r, g, b
|
|
2647
|
+
if (h.length === 3 || h.length === 4) {
|
|
2648
|
+
r = parseInt(h[0] + h[0], 16)
|
|
2649
|
+
g = parseInt(h[1] + h[1], 16)
|
|
2650
|
+
b = parseInt(h[2] + h[2], 16)
|
|
2651
|
+
} else if (h.length >= 6) {
|
|
2652
|
+
r = parseInt(h.slice(0, 2), 16)
|
|
2653
|
+
g = parseInt(h.slice(2, 4), 16)
|
|
2654
|
+
b = parseInt(h.slice(4, 6), 16)
|
|
2655
|
+
} else {
|
|
2656
|
+
return null
|
|
2657
|
+
}
|
|
2658
|
+
if (isNaN(r) || isNaN(g) || isNaN(b)) return null
|
|
2659
|
+
const linearize = c => {
|
|
2660
|
+
const s = c / 255
|
|
2661
|
+
return s <= 0.03928 ? s / 12.92 : Math.pow((s + 0.055) / 1.055, 2.4)
|
|
2662
|
+
}
|
|
2663
|
+
return 0.2126 * linearize(r) + 0.7152 * linearize(g) + 0.0722 * linearize(b)
|
|
2664
|
+
}
|
|
2665
|
+
|
|
2666
|
+
function contrastRatio(lum1, lum2) {
|
|
2667
|
+
const [hi, lo] = lum1 > lum2 ? [lum1, lum2] : [lum2, lum1]
|
|
2668
|
+
return (hi + 0.05) / (lo + 0.05)
|
|
2669
|
+
}
|
|
2670
|
+
|
|
2671
|
+
/**
|
|
2672
|
+
* Given a hex foreground, background luminance, and target contrast ratio,
|
|
2673
|
+
* suggest a corrected hex value that achieves the target ratio.
|
|
2674
|
+
* Strategy: adjust foreground luminance toward darker/lighter as needed.
|
|
2675
|
+
*/
|
|
2676
|
+
function suggestContrastFix(fgHex, bgLum, targetRatio) {
|
|
2677
|
+
const fgLum = hexToLuminance(fgHex)
|
|
2678
|
+
if (fgLum === null) return null
|
|
2679
|
+
|
|
2680
|
+
// Calculate target foreground luminance needed to achieve targetRatio
|
|
2681
|
+
// (hi + 0.05) / (lo + 0.05) = targetRatio
|
|
2682
|
+
// If bg is lighter: (bgLum + 0.05) / (fgLum + 0.05) = targetRatio
|
|
2683
|
+
// → fgLum = (bgLum + 0.05) / targetRatio - 0.05
|
|
2684
|
+
// If bg is darker: (fgLum + 0.05) / (bgLum + 0.05) = targetRatio
|
|
2685
|
+
// → fgLum = targetRatio * (bgLum + 0.05) - 0.05
|
|
2686
|
+
|
|
2687
|
+
let targetLum
|
|
2688
|
+
if (bgLum > fgLum) {
|
|
2689
|
+
// Background is lighter — darken foreground
|
|
2690
|
+
targetLum = (bgLum + 0.05) / targetRatio - 0.05
|
|
2691
|
+
} else {
|
|
2692
|
+
// Background is darker — lighten foreground
|
|
2693
|
+
targetLum = targetRatio * (bgLum + 0.05) - 0.05
|
|
2694
|
+
}
|
|
2695
|
+
|
|
2696
|
+
// Clamp to valid luminance range
|
|
2697
|
+
targetLum = Math.max(0, Math.min(1, targetLum))
|
|
2698
|
+
|
|
2699
|
+
// Convert target luminance to RGB (grayscale approximation for simplicity)
|
|
2700
|
+
// Inverse of linearize: L = 0.2126*R + 0.7152*G + 0.0722*B
|
|
2701
|
+
// For grayscale: L = R = G = B (simplified), so solve for sRGB value
|
|
2702
|
+
const delinearize = l => {
|
|
2703
|
+
return l <= 0.0031308 ? l * 12.92 : 1.055 * Math.pow(l, 1 / 2.4) - 0.055
|
|
2704
|
+
}
|
|
2705
|
+
|
|
2706
|
+
const srgb = delinearize(targetLum)
|
|
2707
|
+
const val = Math.round(srgb * 255)
|
|
2708
|
+
const clamp = Math.max(0, Math.min(255, val))
|
|
2709
|
+
|
|
2710
|
+
return `#${clamp.toString(16).padStart(2, '0').repeat(3)}`
|
|
2711
|
+
}
|
|
2712
|
+
|
|
2713
|
+
server.registerTool(
|
|
2714
|
+
'pulse_tokens',
|
|
2715
|
+
{
|
|
2716
|
+
description: `List all available --ui-* CSS custom property tokens from the installed pulse-ui.css.
|
|
2717
|
+
|
|
2718
|
+
Use this before writing theme overrides or app.css to avoid guessing token names. The correct override pattern is:
|
|
2719
|
+
- Set --accent (no prefix) in theme.css — pulse-ui maps it to --ui-accent automatically
|
|
2720
|
+
- Reference --ui-accent (prefixed) in app.css
|
|
2721
|
+
|
|
2722
|
+
Returns tokens grouped by category.`,
|
|
2723
|
+
inputSchema: {},
|
|
2724
|
+
},
|
|
2725
|
+
async () => {
|
|
2726
|
+
const cssPath = path.join(ROOT, 'public', 'pulse-ui.css')
|
|
2727
|
+
let tokens = []
|
|
2728
|
+
|
|
2729
|
+
if (fs.existsSync(cssPath)) {
|
|
2730
|
+
const css = fs.readFileSync(cssPath, 'utf8')
|
|
2731
|
+
// Extract property: value pairs for --ui-* defined in :root
|
|
2732
|
+
const matches = [...css.matchAll(/--ui-([a-z0-9-]+)\s*:\s*([^;}{]+)/g)]
|
|
2733
|
+
tokens = [...new Set(matches.map(m => `--ui-${m[1]}`))]
|
|
2734
|
+
}
|
|
2735
|
+
|
|
2736
|
+
// Group tokens by category
|
|
2737
|
+
const groups = {
|
|
2738
|
+
'Colour': tokens.filter(t => /^--ui-(bg|surface|border|text|muted|accent|green|red|yellow|blue|shadow)/.test(t)),
|
|
2739
|
+
'Spacing': tokens.filter(t => t.startsWith('--ui-space-')),
|
|
2740
|
+
'Type size': tokens.filter(t => t.startsWith('--ui-text-')),
|
|
2741
|
+
'Typography': tokens.filter(t => /^--ui-(font|mono|letter-spacing)/.test(t)),
|
|
2742
|
+
'Shape': tokens.filter(t => t.startsWith('--ui-radius')),
|
|
2743
|
+
'Other': tokens.filter(t => !/(bg|surface|border|text|muted|accent|green|red|yellow|blue|shadow|space-|text-|font|mono|letter|radius)/.test(t.slice(5))),
|
|
2744
|
+
}
|
|
2745
|
+
|
|
2746
|
+
const lines = [
|
|
2747
|
+
'## Pulse UI Tokens\n',
|
|
2748
|
+
'**Override pattern:**',
|
|
2749
|
+
' • Set `--accent: #yourcolour` in `:root` (theme.css) — maps to `--ui-accent` automatically',
|
|
2750
|
+
' • Reference `var(--ui-accent)` in app.css — never `var(--accent)` in app.css',
|
|
2751
|
+
' • Common mistake: `--ui-color-accent` or `--color-accent` — these do NOT exist\n',
|
|
2752
|
+
]
|
|
2753
|
+
|
|
2754
|
+
for (const [group, toks] of Object.entries(groups)) {
|
|
2755
|
+
if (toks.length === 0) continue
|
|
2756
|
+
lines.push(`### ${group}`)
|
|
2757
|
+
lines.push(toks.join(' • '))
|
|
2758
|
+
lines.push('')
|
|
2759
|
+
}
|
|
2760
|
+
|
|
2761
|
+
lines.push('**Input tokens (set these in theme.css):**')
|
|
2762
|
+
lines.push('`--accent` `--accent-hover` `--accent-dim` `--accent-text` `--bg` `--surface` `--surface-2` `--border` `--text` `--muted` `--muted-bg` `--radius` `--font` `--mono`')
|
|
2763
|
+
|
|
2764
|
+
return text(lines.join('\n'))
|
|
2765
|
+
}
|
|
2766
|
+
)
|
|
2767
|
+
|
|
2768
|
+
server.registerTool(
|
|
2769
|
+
'pulse_check_contrast',
|
|
2770
|
+
{
|
|
2771
|
+
description: `Static WCAG contrast checker. Provide theme CSS content (or a file path) and it extracts all color variable definitions, then checks common foreground/background pairings for WCAG AA compliance (4.5:1 for normal text, 3:1 for large text and UI components).
|
|
2772
|
+
|
|
2773
|
+
Run this immediately after writing a theme file — before production build and Lighthouse. It catches palette mistakes in milliseconds instead of after a 90-second build cycle.`,
|
|
2774
|
+
inputSchema: {
|
|
2775
|
+
css: z.string().optional().describe('CSS content to analyse — paste the contents of your theme file'),
|
|
2776
|
+
file: z.string().optional().describe('Absolute path to a CSS file to analyse (alternative to css)'),
|
|
2777
|
+
theme: z.enum(['light', 'dark']).optional().describe('Which theme context to analyse — determines which CSS block to parse (default: both)'),
|
|
2778
|
+
},
|
|
2779
|
+
},
|
|
2780
|
+
({ css, file, theme }) => {
|
|
2781
|
+
let source = css || ''
|
|
2782
|
+
|
|
2783
|
+
if (!source && file) {
|
|
2784
|
+
if (!fs.existsSync(file)) return text(`File not found: ${file}`)
|
|
2785
|
+
source = fs.readFileSync(file, 'utf8')
|
|
2786
|
+
}
|
|
2787
|
+
|
|
2788
|
+
if (!source) return text('Provide either css content or a file path.')
|
|
2789
|
+
|
|
2790
|
+
// Extract CSS custom property definitions from all relevant blocks
|
|
2791
|
+
// Look in :root, [data-theme="light"], and .ui-theme-light blocks
|
|
2792
|
+
const extractVars = (cssText, blockPattern) => {
|
|
2793
|
+
const vars = {}
|
|
2794
|
+
const blockRegex = new RegExp(blockPattern + '\\s*\\{([^}]+)\\}', 'gi')
|
|
2795
|
+
let block
|
|
2796
|
+
while ((block = blockRegex.exec(cssText)) !== null) {
|
|
2797
|
+
const body = block[1]
|
|
2798
|
+
const propRegex = /--([\w-]+)\s*:\s*(#[0-9a-fA-F]{3,8}|rgba?\([^)]+\)|var\(--[\w-]+\)|[a-z]+)\s*;/gi
|
|
2799
|
+
let prop
|
|
2800
|
+
while ((prop = propRegex.exec(body)) !== null) {
|
|
2801
|
+
vars[`--${prop[1]}`] = prop[2].trim()
|
|
2802
|
+
}
|
|
2803
|
+
}
|
|
2804
|
+
return vars
|
|
2805
|
+
}
|
|
2806
|
+
|
|
2807
|
+
const rootVars = extractVars(source, ':root')
|
|
2808
|
+
const lightVars = extractVars(source, '\\[data-theme=["\']light["\']\\]|[.]ui-theme-light')
|
|
2809
|
+
|
|
2810
|
+
// pulse-ui.css maps --NAME → --ui-NAME (e.g. --accent → --ui-accent).
|
|
2811
|
+
// Theme files written per the guide define bare --* tokens. Synthesize the
|
|
2812
|
+
// --ui-* entries so the checker can resolve them without needing pulse-ui.css.
|
|
2813
|
+
const UI_ALIASES = ['text','bg','muted','accent','accent-text','surface','surface-2','border','heading']
|
|
2814
|
+
const addAliases = (vars) => {
|
|
2815
|
+
for (const name of UI_ALIASES) {
|
|
2816
|
+
if (vars[`--${name}`] && !vars[`--ui-${name}`]) {
|
|
2817
|
+
vars[`--ui-${name}`] = vars[`--${name}`]
|
|
2818
|
+
}
|
|
2819
|
+
}
|
|
2820
|
+
}
|
|
2821
|
+
addAliases(rootVars)
|
|
2822
|
+
addAliases(lightVars)
|
|
2823
|
+
|
|
2824
|
+
const resolveColor = (value, varMap) => {
|
|
2825
|
+
if (!value) return null
|
|
2826
|
+
if (value.startsWith('#')) return hexToLuminance(value)
|
|
2827
|
+
if (value.startsWith('var(')) {
|
|
2828
|
+
const ref = value.match(/var\(--([\w-]+)\)/)?.[1]
|
|
2829
|
+
if (ref && varMap[`--${ref}`]) return resolveColor(varMap[`--${ref}`], varMap)
|
|
2830
|
+
}
|
|
2831
|
+
return null
|
|
2832
|
+
}
|
|
2833
|
+
|
|
2834
|
+
const resolveToHex = (value, varMap) => {
|
|
2835
|
+
if (!value) return null
|
|
2836
|
+
if (value.startsWith('#')) return value
|
|
2837
|
+
if (value.startsWith('var(')) {
|
|
2838
|
+
const ref = value.match(/var\(--([\w-]+)\)/)?.[1]
|
|
2839
|
+
if (ref && varMap[`--${ref}`]) return resolveToHex(varMap[`--${ref}`], varMap)
|
|
2840
|
+
}
|
|
2841
|
+
return null
|
|
2842
|
+
}
|
|
2843
|
+
|
|
2844
|
+
// Standard pairings to check — [foreground token, background token, label, isLargeText]
|
|
2845
|
+
const PAIRINGS = [
|
|
2846
|
+
['--ui-text', '--ui-bg', 'Body text on page background', false],
|
|
2847
|
+
['--ui-muted', '--ui-bg', 'Muted text on page background', false],
|
|
2848
|
+
['--ui-accent', '--ui-bg', 'Accent text on page background', false],
|
|
2849
|
+
['--ui-text', '--ui-surface', 'Body text on card/surface', false],
|
|
2850
|
+
['--ui-muted', '--ui-surface', 'Muted text on card/surface', false],
|
|
2851
|
+
['--ui-accent-text', '--ui-accent', 'Button text on accent background', false],
|
|
2852
|
+
['--ui-accent', '--ui-surface', 'Accent text on surface', false],
|
|
2853
|
+
['--ui-text', '--ui-surface-2','Text on nested surface', false],
|
|
2854
|
+
]
|
|
2855
|
+
|
|
2856
|
+
const checkSet = (vars, context) => {
|
|
2857
|
+
const results = []
|
|
2858
|
+
for (const [fg, bg, label, isLarge] of PAIRINGS) {
|
|
2859
|
+
const fgVal = vars[fg]
|
|
2860
|
+
const bgVal = vars[bg]
|
|
2861
|
+
if (!fgVal || !bgVal) continue
|
|
2862
|
+
|
|
2863
|
+
const fgLum = resolveColor(fgVal, vars)
|
|
2864
|
+
const bgLum = resolveColor(bgVal, vars)
|
|
2865
|
+
if (fgLum === null || bgLum === null) continue
|
|
2866
|
+
|
|
2867
|
+
const fgHex = resolveToHex(fgVal, vars)
|
|
2868
|
+
const bgHex = resolveToHex(bgVal, vars)
|
|
2869
|
+
|
|
2870
|
+
const ratio = contrastRatio(fgLum, bgLum)
|
|
2871
|
+
const threshold = isLarge ? 3.0 : 4.5
|
|
2872
|
+
const pass = ratio >= threshold
|
|
2873
|
+
const level = ratio >= 7 ? 'AAA' : ratio >= 4.5 ? 'AA' : ratio >= 3 ? 'AA large' : 'FAIL'
|
|
2874
|
+
|
|
2875
|
+
results.push({
|
|
2876
|
+
context, label,
|
|
2877
|
+
fg: fg + ' (' + fgVal + ')',
|
|
2878
|
+
bg: bg + ' (' + bgVal + ')',
|
|
2879
|
+
fgHex,
|
|
2880
|
+
bgHex,
|
|
2881
|
+
bgLum,
|
|
2882
|
+
ratio: ratio.toFixed(2),
|
|
2883
|
+
pass,
|
|
2884
|
+
level,
|
|
2885
|
+
threshold,
|
|
2886
|
+
})
|
|
2887
|
+
}
|
|
2888
|
+
return results
|
|
2889
|
+
}
|
|
2890
|
+
|
|
2891
|
+
const allResults = []
|
|
2892
|
+
|
|
2893
|
+
if (!theme || theme === 'dark') {
|
|
2894
|
+
const combined = { ...rootVars }
|
|
2895
|
+
allResults.push(...checkSet(combined, 'Dark theme (:root)'))
|
|
2896
|
+
}
|
|
2897
|
+
if (!theme || theme === 'light') {
|
|
2898
|
+
if (Object.keys(lightVars).length > 0) {
|
|
2899
|
+
const combined = { ...rootVars, ...lightVars }
|
|
2900
|
+
allResults.push(...checkSet(combined, 'Light theme ([data-theme="light"])'))
|
|
2901
|
+
}
|
|
2902
|
+
}
|
|
2903
|
+
|
|
2904
|
+
if (allResults.length === 0) {
|
|
2905
|
+
// Try to cross-reference app.css to extract real-world color pairings
|
|
2906
|
+
// (e.g. --color-text on --color-bg used in body { color: var(--color-text); background: var(--color-bg) })
|
|
2907
|
+
const appCssPath = path.join(ROOT, 'public', 'app.css')
|
|
2908
|
+
let appCssHints = ''
|
|
2909
|
+
if (fs.existsSync(appCssPath)) {
|
|
2910
|
+
try {
|
|
2911
|
+
const appCss = fs.readFileSync(appCssPath, 'utf8')
|
|
2912
|
+
const varRefs = [...appCss.matchAll(/var\(--([\w-]+)\)/g)].map(m => `--${m[1]}`)
|
|
2913
|
+
const uniqueRefs = [...new Set(varRefs)]
|
|
2914
|
+
const unknownToChecker = uniqueRefs.filter(v =>
|
|
2915
|
+
!v.startsWith('--ui-') &&
|
|
2916
|
+
(Object.keys(rootVars).some(k => k === v) || Object.keys(lightVars).some(k => k === v))
|
|
2917
|
+
)
|
|
2918
|
+
if (unknownToChecker.length > 0) {
|
|
2919
|
+
appCssHints = `\n\napp.css uses these custom tokens that the checker couldn't auto-map to --ui-* pairings:\n ${unknownToChecker.join(', ')}\n\nTo check these, add explicit --ui-* aliases in your theme file:\n :root { --ui-text: var(--color-text); --ui-bg: var(--color-bg); }\nOr run this tool with theme.css content that includes --ui-text/--ui-bg definitions.`
|
|
2920
|
+
}
|
|
2921
|
+
} catch { /* ignore */ }
|
|
2922
|
+
}
|
|
2923
|
+
|
|
2924
|
+
return text(`⚠ CHECKED 0 PAIRINGS — this is NOT "all clear". The checker found no colour variable pairs to test.\n\nThis typically means your theme uses custom token names (e.g. --color-text, --color-sunburst) rather than --ui-text / --ui-bg. The checker auto-maps bare tokens (--text, --bg, --accent, --muted, --surface, --border, --heading, --accent-text) but NOT arbitrary custom names.\n\nThe checker looked for --ui-* token pairs (e.g. --ui-text, --ui-bg, --ui-accent). Make sure variables are inside a :root { } or [data-theme="light"] { } block.\n\nYou must check contrast manually (e.g. via WebAIM) or alias your tokens:\n :root { --ui-text: var(--color-text); --ui-bg: var(--color-bg); }\n\nDo NOT skip contrast checking and proceed to Lighthouse — 11 contrast failures have been caught at Lighthouse stage after a 0-pairing result here.${appCssHints}`)
|
|
2925
|
+
}
|
|
2926
|
+
|
|
2927
|
+
const failures = allResults.filter(r => !r.pass)
|
|
2928
|
+
const passes = allResults.filter(r => r.pass)
|
|
2929
|
+
|
|
2930
|
+
const lines = [`# Contrast check — ${failures.length} failure${failures.length !== 1 ? 's' : ''}, ${passes.length} pass${passes.length !== 1 ? 'es' : ''}\n`]
|
|
2931
|
+
|
|
2932
|
+
if (failures.length > 0) {
|
|
2933
|
+
lines.push('## ✗ Failures (must fix before shipping)\n')
|
|
2934
|
+
for (const r of failures) {
|
|
2935
|
+
lines.push(`**${r.label}** — ${r.context}`)
|
|
2936
|
+
lines.push(` ${r.fg} on ${r.bg}`)
|
|
2937
|
+
lines.push(` Ratio: ${r.ratio}:1 · Needed: ${r.threshold}:1 · Level: ${r.level}`)
|
|
2938
|
+
|
|
2939
|
+
// Suggest a corrected hex value
|
|
2940
|
+
if (r.fgHex && r.bgLum !== null) {
|
|
2941
|
+
const suggested = suggestContrastFix(r.fgHex, r.bgLum, r.threshold)
|
|
2942
|
+
if (suggested) {
|
|
2943
|
+
lines.push(` 💡 Suggested fix: use ${suggested} instead of ${r.fgHex}`)
|
|
2944
|
+
}
|
|
2945
|
+
}
|
|
2946
|
+
lines.push('')
|
|
2947
|
+
}
|
|
2948
|
+
}
|
|
2949
|
+
|
|
2950
|
+
if (passes.length > 0) {
|
|
2951
|
+
lines.push('## ✓ Passing\n')
|
|
2952
|
+
for (const r of passes) {
|
|
2953
|
+
lines.push(`✓ ${r.level} ${r.ratio}:1 — ${r.label} (${r.context})`)
|
|
2954
|
+
}
|
|
2955
|
+
lines.push('')
|
|
2956
|
+
}
|
|
2957
|
+
|
|
2958
|
+
if (failures.length > 0) {
|
|
2959
|
+
lines.push('---')
|
|
2960
|
+
lines.push('**Fix guidance:**')
|
|
2961
|
+
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).')
|
|
2962
|
+
lines.push('• For `--ui-muted` failures: muted text on `--ui-bg` needs at minimum a 4.5:1 ratio. Adjust the muted tone.')
|
|
2963
|
+
lines.push('• Light-theme `--ui-accent` must be distinctly darker than the page background — many default accent hues are too light.')
|
|
2964
|
+
lines.push('• After fixing, run this tool again before `pulse_build`.')
|
|
2965
|
+
}
|
|
2966
|
+
|
|
2967
|
+
return text(lines.join('\n'))
|
|
2968
|
+
}
|
|
2969
|
+
)
|
|
2970
|
+
|
|
2971
|
+
// ---------------------------------------------------------------------------
|
|
2972
|
+
// pulse_update
|
|
2973
|
+
// ---------------------------------------------------------------------------
|
|
2974
|
+
|
|
2975
|
+
server.registerTool(
|
|
2976
|
+
'pulse_update',
|
|
2977
|
+
{
|
|
2978
|
+
description: 'Install the latest @invisibleloop/pulse package (skipped if npm-linked), then re-copy pulse-ui.css, pulse-ui.js, and the agent checklist into public/. One command does the full upgrade.',
|
|
2979
|
+
inputSchema: {},
|
|
2980
|
+
},
|
|
2981
|
+
async () => {
|
|
2982
|
+
// 1. npm install latest — but only if the package is NOT npm-linked.
|
|
2983
|
+
// An npm link is a symlink in node_modules. Installing @latest over a
|
|
2984
|
+
// link downgrades the project to the published version even when the
|
|
2985
|
+
// developer has a newer local build linked. Detect the symlink and skip.
|
|
2986
|
+
const { execSync } = await import('child_process')
|
|
2987
|
+
const pkgDir = path.join(ROOT, 'node_modules', '@invisibleloop', 'pulse')
|
|
2988
|
+
const isLinked = fs.existsSync(pkgDir) && fs.lstatSync(pkgDir).isSymbolicLink()
|
|
2989
|
+
|
|
2990
|
+
if (!isLinked) {
|
|
2991
|
+
try {
|
|
2992
|
+
execSync('npm install @invisibleloop/pulse@latest', { cwd: ROOT, stdio: 'pipe' })
|
|
2993
|
+
} catch (e) {
|
|
2994
|
+
return text(`npm install failed:\n${e.stderr?.toString() || e.message}`)
|
|
2995
|
+
}
|
|
2996
|
+
}
|
|
2997
|
+
|
|
2998
|
+
// 2. Copy assets from the package in node_modules (installed or linked).
|
|
2999
|
+
const pkgPublic = path.join(ROOT, 'node_modules', '@invisibleloop', 'pulse', 'public')
|
|
3000
|
+
const publicDir = path.join(ROOT, 'public')
|
|
3001
|
+
const assets = ['pulse-ui.css', 'pulse-ui.js', '.pulse-ui-version']
|
|
3002
|
+
const updated = []
|
|
3003
|
+
|
|
3004
|
+
fs.mkdirSync(publicDir, { recursive: true })
|
|
3005
|
+
for (const asset of assets) {
|
|
3006
|
+
const src = path.join(pkgPublic, asset)
|
|
3007
|
+
const dst = path.join(publicDir, asset)
|
|
3008
|
+
if (fs.existsSync(src)) { fs.copyFileSync(src, dst); updated.push(`public/${asset}`) }
|
|
3009
|
+
}
|
|
3010
|
+
|
|
3011
|
+
const checklistSrc = path.join(ROOT, 'node_modules', '@invisibleloop', 'pulse', 'src', 'agent', 'checklist.md')
|
|
3012
|
+
const checklistDst = path.join(ROOT, '.claude', 'pulse-checklist.md')
|
|
3013
|
+
if (fs.existsSync(checklistSrc)) {
|
|
3014
|
+
fs.mkdirSync(path.dirname(checklistDst), { recursive: true })
|
|
3015
|
+
fs.copyFileSync(checklistSrc, checklistDst)
|
|
3016
|
+
updated.push('.claude/pulse-checklist.md')
|
|
3017
|
+
}
|
|
3018
|
+
|
|
3019
|
+
const versionFile = path.join(publicDir, '.pulse-ui-version')
|
|
3020
|
+
const version = fs.existsSync(versionFile) ? fs.readFileSync(versionFile, 'utf8').trim() : '?'
|
|
3021
|
+
return text(`Pulse updated to v${version}\n\n${updated.map(f => `✓ ${f}`).join('\n')}`)
|
|
3022
|
+
}
|
|
3023
|
+
)
|
|
3024
|
+
|
|
3025
|
+
// ---------------------------------------------------------------------------
|
|
3026
|
+
// Helpers
|
|
3027
|
+
// ---------------------------------------------------------------------------
|
|
3028
|
+
|
|
3029
|
+
/** Poll until the HTTP server on the given port accepts a connection, or timeout. */
|
|
3030
|
+
async function waitForServer(port, maxMs = 10_000) {
|
|
3031
|
+
const deadline = Date.now() + maxMs
|
|
3032
|
+
while (Date.now() < deadline) {
|
|
3033
|
+
const alive = await new Promise(resolve => {
|
|
3034
|
+
const req = http.get(`http://localhost:${port}/`, { timeout: 500 }, res => {
|
|
3035
|
+
res.resume()
|
|
3036
|
+
resolve(true)
|
|
3037
|
+
})
|
|
3038
|
+
req.on('error', () => resolve(false))
|
|
3039
|
+
req.on('timeout', () => { req.destroy(); resolve(false) })
|
|
3040
|
+
})
|
|
3041
|
+
if (alive) return true
|
|
3042
|
+
await new Promise(r => setTimeout(r, 300))
|
|
3043
|
+
}
|
|
3044
|
+
return false
|
|
3045
|
+
}
|
|
3046
|
+
|
|
3047
|
+
// Common prop aliases that agents and developers pass by mistake.
|
|
3048
|
+
// Maps wrong name → { correct, component } for each well-known component.
|
|
3049
|
+
const PROP_ALIASES = [
|
|
3050
|
+
{ component: 'nav()', wrong: 'brand', correct: 'logo', note: 'The nav logo is set with the `logo` prop, not `brand`.' },
|
|
3051
|
+
{ component: 'nav()', wrong: 'actions', correct: 'action', note: 'nav() takes a single `action` string (HTML), not an array.' },
|
|
3052
|
+
{ component: 'footer()', wrong: 'items', correct: 'links', note: 'footer() top-level links use `links: [{ label, href }]`, not `items`.' },
|
|
3053
|
+
{ component: 'footer()', wrong: 'nav', correct: 'links', note: 'footer() links prop is `links`, not `nav`.' },
|
|
3054
|
+
{ component: 'input()', wrong: 'autocomplete', correct: 'attrs: { autocomplete }', note: 'HTML attributes not in the component API (autocomplete, min, max, step, pattern, inputmode, etc.) go inside the `attrs` object: input({ attrs: { autocomplete: "email" } }).' },
|
|
3055
|
+
{ component: 'input()', wrong: 'maxlength', correct: 'attrs: { maxlength }', note: 'HTML attributes go inside `attrs`: input({ attrs: { maxlength: "100" } }).' },
|
|
3056
|
+
{ component: 'input()', wrong: 'minlength', correct: 'attrs: { minlength }', note: 'HTML attributes go inside `attrs`: input({ attrs: { minlength: "2" } }).' },
|
|
3057
|
+
{ component: 'input()', wrong: 'pattern', correct: 'attrs: { pattern }', note: 'HTML attributes go inside `attrs`: input({ attrs: { pattern: "[0-9]+" } }).' },
|
|
3058
|
+
]
|
|
3059
|
+
|
|
3060
|
+
async function validateContent(content) {
|
|
3061
|
+
// Source-level checks — run before the worker so errors are caught without importing
|
|
3062
|
+
const sourceWarnings = []
|
|
3063
|
+
|
|
3064
|
+
// Component prop alias checks — flag known wrong prop names
|
|
3065
|
+
for (const { component, wrong, correct, note } of PROP_ALIASES) {
|
|
3066
|
+
// Match: nav({ ...wrong: or footer({ ...wrong: (within the call args)
|
|
3067
|
+
const fnName = component.replace('()', '')
|
|
3068
|
+
const re = new RegExp(`${fnName}\\s*\\(\\s*\\{[^}]*\\b${wrong}\\s*:`, 'g')
|
|
3069
|
+
if (re.test(content)) {
|
|
3070
|
+
sourceWarnings.push(`"${wrong}" is not a recognised prop for ${component} — did you mean "${correct}"? ${note}`)
|
|
3071
|
+
}
|
|
3072
|
+
}
|
|
3073
|
+
|
|
3074
|
+
// External image URL check — warn if spec contains external image domains not
|
|
3075
|
+
// commonly whitelisted in CSP. Caught here prevents a Lighthouse Best Practices failure.
|
|
3076
|
+
const externalImgHosts = [
|
|
3077
|
+
{
|
|
3078
|
+
pattern: /https?:\/\/images\.unsplash\.com/,
|
|
3079
|
+
entry: 'https://images.unsplash.com',
|
|
3080
|
+
cookieWarning: true,
|
|
3081
|
+
name: 'Unsplash',
|
|
3082
|
+
},
|
|
3083
|
+
{
|
|
3084
|
+
pattern: /https?:\/\/(?:fastly\.)?picsum\.photos/,
|
|
3085
|
+
entry: 'https://picsum.photos https://fastly.picsum.photos',
|
|
3086
|
+
cookieWarning: false,
|
|
3087
|
+
name: 'picsum',
|
|
3088
|
+
},
|
|
3089
|
+
{
|
|
3090
|
+
pattern: /https?:\/\/res\.cloudinary\.com/,
|
|
3091
|
+
entry: 'https://res.cloudinary.com',
|
|
3092
|
+
cookieWarning: true,
|
|
3093
|
+
name: 'Cloudinary',
|
|
3094
|
+
},
|
|
3095
|
+
{
|
|
3096
|
+
pattern: /https?:\/\/cdn\.shopify\.com/,
|
|
3097
|
+
entry: 'https://cdn.shopify.com',
|
|
3098
|
+
cookieWarning: false,
|
|
3099
|
+
name: 'Shopify CDN',
|
|
3100
|
+
},
|
|
3101
|
+
]
|
|
3102
|
+
const configPath = path.join(ROOT, 'pulse.config.js')
|
|
3103
|
+
let configImgSrc = ''
|
|
3104
|
+
if (fs.existsSync(configPath)) {
|
|
3105
|
+
try { configImgSrc = fs.readFileSync(configPath, 'utf8') } catch { /* ignore */ }
|
|
3106
|
+
}
|
|
3107
|
+
for (const { pattern, entry, cookieWarning, name } of externalImgHosts) {
|
|
3108
|
+
if (pattern.test(content) && !pattern.test(configImgSrc)) {
|
|
3109
|
+
const cookieNote = cookieWarning
|
|
3110
|
+
? `\n ⚠ ${name} sets tracking cookies that fail Lighthouse Best Practices regardless of CSP. For production, download images to public/images/ instead of linking to ${name} directly.`
|
|
3111
|
+
: ''
|
|
3112
|
+
sourceWarnings.push(
|
|
3113
|
+
`External image host detected (${name}). Add it to csp.img-src in pulse.config.js before running Lighthouse:\n` +
|
|
3114
|
+
` csp: { 'img-src': ['${entry}'] }\n` +
|
|
3115
|
+
` Without this, images will be blocked and Lighthouse Best Practices will fail.${cookieNote}`
|
|
3116
|
+
)
|
|
3117
|
+
}
|
|
3118
|
+
}
|
|
3119
|
+
|
|
3120
|
+
// Write into PAGES_DIR so relative imports (e.g. '../components/nav.js') resolve correctly
|
|
3121
|
+
fs.mkdirSync(PAGES_DIR, { recursive: true })
|
|
3122
|
+
const tmpFile = path.join(PAGES_DIR, `.pulse-validate-${Date.now()}.mjs`)
|
|
3123
|
+
try {
|
|
3124
|
+
fs.writeFileSync(tmpFile, content, 'utf8')
|
|
3125
|
+
|
|
3126
|
+
// Run validation in a child process with a hard timeout so a hanging import
|
|
3127
|
+
// (slow module, circular dep, network call) cannot block the MCP server.
|
|
3128
|
+
const validatorScript = new URL('./validate-worker.js', import.meta.url).pathname
|
|
3129
|
+
let output
|
|
3130
|
+
try {
|
|
3131
|
+
output = execFileSync(process.execPath, [validatorScript, tmpFile], {
|
|
3132
|
+
timeout: 10_000,
|
|
3133
|
+
encoding: 'utf8',
|
|
3134
|
+
})
|
|
3135
|
+
} catch (err) {
|
|
3136
|
+
const msg = err.killed || err.signal === 'SIGTERM'
|
|
3137
|
+
? 'Invalid: validation timed out — spec may have a hanging import or infinite loop'
|
|
3138
|
+
: `Invalid: could not parse — ${err.stdout || err.message}`
|
|
3139
|
+
return text(msg)
|
|
3140
|
+
}
|
|
3141
|
+
|
|
3142
|
+
let finalOutput = output.trim()
|
|
3143
|
+
// Merge source-level prop warnings into the worker output
|
|
3144
|
+
if (sourceWarnings.length > 0) {
|
|
3145
|
+
const propNotes = sourceWarnings.map(w => ` ⚠ ${w}`).join('\n')
|
|
3146
|
+
if (finalOutput.startsWith('Valid ✓')) {
|
|
3147
|
+
finalOutput = finalOutput.replace('Valid ✓', 'Valid ✓ — but fix these issues:').replace(' — but fix these issues: — but fix these issues:', ' — but fix these issues:')
|
|
3148
|
+
finalOutput = finalOutput + '\n' + propNotes
|
|
3149
|
+
} else {
|
|
3150
|
+
finalOutput = finalOutput + '\n' + propNotes
|
|
3151
|
+
}
|
|
3152
|
+
}
|
|
3153
|
+
|
|
3154
|
+
// Append browser check reminder on clean pass so agents don't skip Lighthouse
|
|
3155
|
+
if (finalOutput.startsWith('Valid ✓') && !finalOutput.includes('Invalid')) {
|
|
3156
|
+
finalOutput += '\n\n---\n**Next: browser check sequence** (do not skip)\n1. `pulse_fetch_page` → screenshot\n2. `pulse_design_review` (if intake ran)\n3. `pulse_layout_review <url>`\n4. `/verify` — Lighthouse desktop + mobile + pulse_review\nDo not report done until `/verify` passes.'
|
|
858
3157
|
}
|
|
859
3158
|
|
|
860
|
-
return text(
|
|
3159
|
+
return text(finalOutput)
|
|
861
3160
|
} finally {
|
|
862
3161
|
try { fs.unlinkSync(tmpFile) } catch { /* ignore */ }
|
|
863
3162
|
}
|
|
@@ -895,7 +3194,10 @@ const GUIDE_STYLES = fs.readFileSync(new URL('../agent/guide-styles.md',
|
|
|
895
3194
|
const GUIDE_ROUTING = fs.readFileSync(new URL('../agent/guide-routing.md', import.meta.url), 'utf8')
|
|
896
3195
|
const GUIDE_COMPONENTS = fs.readFileSync(new URL('../agent/guide-components.md', import.meta.url), 'utf8')
|
|
897
3196
|
const GUIDE_EXAMPLES = fs.readFileSync(new URL('../agent/guide-examples.md', import.meta.url), 'utf8')
|
|
898
|
-
const GUIDE_TEMPLATES
|
|
3197
|
+
const GUIDE_TEMPLATES = fs.readFileSync(new URL('../agent/guide-templates.md', import.meta.url), 'utf8')
|
|
3198
|
+
const GUIDE_DESIGN_REF = fs.readFileSync(new URL('../agent/guide-design-references.md', import.meta.url), 'utf8')
|
|
3199
|
+
const GUIDE_DESIGN_GALL = fs.readFileSync(new URL('../agent/guide-design-gallery.md', import.meta.url), 'utf8')
|
|
3200
|
+
const GUIDE_EXPLORE = fs.readFileSync(new URL('../agent/guide-explore.md', import.meta.url), 'utf8')
|
|
899
3201
|
|
|
900
3202
|
// ---------------------------------------------------------------------------
|
|
901
3203
|
// The Persona — CLI-specific identity header + shared identity content
|
|
@@ -936,10 +3238,22 @@ const PULSE_GUIDE_INDEX = `# Pulse Framework Guide
|
|
|
936
3238
|
| \`pulse://guide/components\` | All UI components, icons, charts, composition patterns |
|
|
937
3239
|
| \`pulse://guide/examples\` | Complete working page examples |
|
|
938
3240
|
| \`pulse://guide/templates\` | **Fetch when asked to build a landing page or branded template.** Pre-build questions, template inventory, adaptation rules, theme CSS patterns. |
|
|
3241
|
+
| \`pulse://guide/design-references\` | **Fetch when choosing a design aesthetic.** 12 design directions with vibes, component combos, palette patterns, and signature moves. Use at intake time to pick the right direction, not SaaS-by-default. |
|
|
3242
|
+
| \`pulse://guide/design-gallery\` | **Fetch when adapting a template or combining components.** All 6 templates with visual descriptions + key components. Critical prop-name reference (content vs children, name vs author, etc.). Component recipes: image card, article card, stat strip, booking form. |
|
|
3243
|
+
| \`pulse://guide/explore\` | **Fetch when you want a distinctive or unusual layout.** Zone-based layout thinking, 7 structural gestures (full-bleed, asymmetric, typography-only, editorial, dense grid, story scroll, content-first), raw HTML patterns with zero components, CSS token reference, anti-pattern checklist. |
|
|
939
3244
|
|
|
940
3245
|
## Tools available
|
|
941
3246
|
|
|
942
3247
|
**Pulse MCP tools** (always available):
|
|
3248
|
+
- \`pulse_extract_inspiration(source, focus?)\` — **Extract a structured design brief from a URL or image.** Call this when the user shares a website URL, a site name they admire, or pastes an inspiration image. Returns a structured extraction template — you fill it in using your browsing or vision tools, then feed the results into pulse_intake. Maps directly to palette, vibe, styleNotes, and font fields.
|
|
3249
|
+
- \`pulse_intake(name, pitch, features, targetUser?, palette?, font?, theme?, vibe?, styleNotes?, antiStyle?, inspiration?)\` — **Capture product details before scaffolding.** Run this first for any new project or branded template — before pulse_sketch or pulse_intent. **Gather answers by asking the user one free-form question at a time — never use multi-choice lists for open-ended intake questions.** After intake, call pulse_sketch to explore structural directions before writing code.
|
|
3250
|
+
- \`pulse_sketch(brief, vibe?, antiStyle?, pageType?)\` — **Generate 3 structurally distinct layout directions before writing any code.** Call after pulse_intake. Returns three named directions (full-bleed, asymmetric split, typography-only, editorial flow, dense grid, story scroll, content-first) with wireframes, key decisions, and component strategies. Prevents defaulting to "centred hero + three columns" on every project. After choosing a direction, fetch \`pulse://guide/explore\` for raw HTML patterns.
|
|
3251
|
+
- \`pulse_intent(description)\` — Describe what you want to build in plain language and get back a matched archetype, component recommendations, a ready-to-adapt spec scaffold, and which guides to read. Use after pulse_intake and pulse_sketch, before fetching guides.
|
|
3252
|
+
- \`pulse_suggest(content)\` — **Draft-mode feedback.** Paste a partial or complete spec and get constructive, non-blocking suggestions: missing pieces, likely omissions, component upgrades, empty-state reminders. A collaborator, not a gate. Use mid-build before running the hard validator.
|
|
3253
|
+
- \`pulse_list_icons(filter?)\` — **List all available icon names grouped by category.** Always call this before importing icons — never guess a name or grep source files. Optional filter keyword narrows results (e.g. filter: "arrow").
|
|
3254
|
+
- \`pulse_check_contrast(css?, file?, theme?)\` — **Static WCAG contrast check.** Provide theme CSS content or a file path; it checks all token color pairings against WCAG AA thresholds (4.5:1 normal text, 3:1 large text/UI). Run immediately after writing a theme file — before pulse_build and Lighthouse. Catches palette mistakes in milliseconds.
|
|
3255
|
+
- \`pulse_tokens\` — **List all --ui-* CSS tokens** from the installed pulse-ui.css, grouped by category (colour, spacing, type-size, typography, shape). Call before writing theme overrides to avoid guessing names. Reminder: set \`--accent\` (no prefix) in theme.css — pulse-ui maps it to \`--ui-accent\` automatically.
|
|
3256
|
+
- \`pulse_status\` — **Project health snapshot.** Returns page count, routes, dev server status, last build age, and pulse-ui version check. Call at the start of a session to orient quickly without reading files.
|
|
943
3257
|
- \`pulse_list_structure\` — list pages, components, and pulse-ui version. Call at the start of every session.
|
|
944
3258
|
- \`pulse_validate\` — validate spec content. Call after every write. Fix all errors AND warnings.
|
|
945
3259
|
- \`pulse_review\` — switch into reviewer mode and critically examine a spec you just built. Returns the source, rendered HTML, validator output, and a full review checklist. **Call this only after validate, Lighthouse (desktop + mobile), and tests all pass — it is the final phase before declaring done.**
|