@invisibleloop/pulse 0.14.1 → 0.14.2
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/.claude/commands/build-page.md +5 -1
- package/.claude/commands/verify.md +23 -13
- package/docs/public/.pulse-ui-version +1 -1
- package/examples/.claude/commands/pulse-build.md +7 -0
- package/examples/.claude/commands/pulse-dev.md +7 -0
- package/examples/.claude/commands/pulse-start.md +9 -0
- package/examples/.claude/commands/pulse-stop.md +5 -0
- package/examples/.claude/commands/verify.md +84 -0
- package/examples/.claude/coverage-check.js +127 -0
- package/examples/.claude/pulse-checklist.md +177 -0
- package/examples/public/.pulse-ui-version +1 -0
- package/examples/public/pulse-ui.css +4031 -0
- package/examples/public/pulse-ui.js +257 -0
- package/package.json +1 -1
- package/public/.pulse-ui-version +1 -1
- package/public/pulse-ui.css +44 -0
- package/src/agent/checklist.md +5 -3
- package/src/agent/guide-components.md +2 -2
- package/src/agent/guide-styles.md +15 -0
- package/src/agent/guide-templates.md +3 -0
- package/src/agent/identity.md +2 -1
- package/src/agent/skills/verify/SKILL.md +64 -28
- package/src/agent/workflow.md +53 -17
- package/src/cli/dev.js +6 -13
- package/src/cli/index.js +4 -10
- package/src/cli/logger.js +145 -0
- package/src/cli/scaffold.js +55 -1
- package/src/mcp/server.js +174 -26
- package/src/mcp/validate-worker.js +21 -6
- package/src/server/index.js +20 -16
- package/src/spec/schema.js +19 -3
- package/src/spec/schema.test.js +28 -0
- package/src/ui/button.js +1 -1
package/src/mcp/server.js
CHANGED
|
@@ -187,6 +187,134 @@ for (const { name, uri, title, description, content } of GUIDE_RESOURCES) {
|
|
|
187
187
|
)
|
|
188
188
|
}
|
|
189
189
|
|
|
190
|
+
// pulse://quickstart — combined essentials for simple/targeted tasks.
|
|
191
|
+
// Reduces cold-start fetch round-trips from 4 (workflow + spec + components + styles)
|
|
192
|
+
// to 1 for edits, bug fixes, and one-shot page builds that don't need the full guides.
|
|
193
|
+
server.registerResource(
|
|
194
|
+
'quickstart',
|
|
195
|
+
'pulse://quickstart',
|
|
196
|
+
{
|
|
197
|
+
title: 'Pulse Quickstart — Essentials in One Fetch',
|
|
198
|
+
description: 'Workflow phases, spec skeleton, key component rules, and theming essentials combined. Use for targeted edits, bug fixes, or simple page builds instead of fetching pulse://workflow + pulse://guide/spec + pulse://guide/components separately.',
|
|
199
|
+
mimeType: 'text/plain',
|
|
200
|
+
},
|
|
201
|
+
async () => ({
|
|
202
|
+
contents: [{
|
|
203
|
+
uri: 'pulse://quickstart',
|
|
204
|
+
mimeType: 'text/plain',
|
|
205
|
+
text: `# Pulse Quickstart
|
|
206
|
+
|
|
207
|
+
> **When to use this resource**
|
|
208
|
+
> - Editing an existing page, fixing a bug, or adding a section to a known page
|
|
209
|
+
> - One-shot "build me X" requests where you already know what to build
|
|
210
|
+
> - Any task where you don't need intake, sketch, or design direction
|
|
211
|
+
>
|
|
212
|
+
> **When to use the full guides instead**
|
|
213
|
+
> - New project or new branded page → \`pulse://workflow\` then \`pulse://guide/templates\` + \`pulse://guide/design-references\`
|
|
214
|
+
> - Unfamiliar component props → \`pulse://guide/components\`
|
|
215
|
+
> - Server data, store, auth → \`pulse://guide/server\`
|
|
216
|
+
> - CSS / theming deep-dive → \`pulse://guide/styles\`
|
|
217
|
+
|
|
218
|
+
---
|
|
219
|
+
|
|
220
|
+
## Workflow (abbreviated)
|
|
221
|
+
|
|
222
|
+
\`\`\`
|
|
223
|
+
New page: pulse_intake → pulse_sketch → pulse_intent → build → validate → screenshot → design approval → /verify
|
|
224
|
+
Edit/fix: read file → change → validate → /verify
|
|
225
|
+
\`\`\`
|
|
226
|
+
|
|
227
|
+
Tier 1 (static/read-only): phases 3 → 4 → 5a (design approval) → 5b (/verify)
|
|
228
|
+
Tier 2 (interactive): phases 3 → 4 → 5a → 5b → 6 (tests) → 7 (review)
|
|
229
|
+
|
|
230
|
+
Pass bar — ALL must pass before done:
|
|
231
|
+
- \`pulse_validate\` clean (no errors, no warnings)
|
|
232
|
+
- Lighthouse desktop + mobile: Accessibility, Best Practices, SEO all 100
|
|
233
|
+
- CLS: 0.00
|
|
234
|
+
- No console errors
|
|
235
|
+
|
|
236
|
+
---
|
|
237
|
+
|
|
238
|
+
## Spec skeleton
|
|
239
|
+
|
|
240
|
+
\`\`\`js
|
|
241
|
+
import { nav, hero, section, container, grid, button, footer } from '@invisibleloop/pulse/ui'
|
|
242
|
+
|
|
243
|
+
export default {
|
|
244
|
+
route: '/',
|
|
245
|
+
meta: {
|
|
246
|
+
title: 'Page Title',
|
|
247
|
+
description: 'Meta description.',
|
|
248
|
+
theme: 'light', // omit for dark (default)
|
|
249
|
+
vibe: 'editorial', // warm|editorial|playful|minimal|bold|brutalist|retro|corporate|neon|paper
|
|
250
|
+
styles: ['/pulse-ui.css', '/theme.css', '/app.css'],
|
|
251
|
+
},
|
|
252
|
+
// state + mutations only if interactive:
|
|
253
|
+
state: { status: 'idle' },
|
|
254
|
+
mutations: { setStatus: (state, e) => ({ status: e.target.value }) },
|
|
255
|
+
// server fetchers (SSR, optional):
|
|
256
|
+
server: { items: async () => fetchItems() },
|
|
257
|
+
view: (state, server) => \`
|
|
258
|
+
<a href="#main-content" class="skip-link" style="position:absolute;left:-9999px;...">Skip to main content</a>
|
|
259
|
+
\${nav({ logo: 'My Site', links: [...], action: button({ label: 'CTA', href: '/signup' }) })}
|
|
260
|
+
<main id="main-content">
|
|
261
|
+
<!-- sections here -->
|
|
262
|
+
</main>
|
|
263
|
+
\${footer({ logo: 'My Site', links: [...], legal: '© 2026 My Site' })}
|
|
264
|
+
\`,
|
|
265
|
+
}
|
|
266
|
+
\`\`\`
|
|
267
|
+
|
|
268
|
+
**Critical rules:**
|
|
269
|
+
- \`meta\` is always a plain object — never \`meta: async (ctx) => ({...})\`
|
|
270
|
+
- Never set \`hydrate\` — the framework sets it automatically
|
|
271
|
+
- \`onSuccess\` AND \`onError\` are both required in every action
|
|
272
|
+
- Never use \`data-event\` on text inputs — use FormData in \`onStart\`/\`run\`
|
|
273
|
+
- Every interactive element must be \`<button>\`, \`<a>\`, or have \`tabindex="0"\`
|
|
274
|
+
- \`<main id="main-content">\` is required on every page
|
|
275
|
+
|
|
276
|
+
---
|
|
277
|
+
|
|
278
|
+
## Key components (quick reference)
|
|
279
|
+
|
|
280
|
+
Import from \`@invisibleloop/pulse/ui\`. Always include \`/pulse-ui.css\` in \`meta.styles\`.
|
|
281
|
+
|
|
282
|
+
| Component | Key props |
|
|
283
|
+
|---|---|
|
|
284
|
+
| \`nav\` | \`logo\`, \`logoHref\`, \`links\` ([{label,href}]), \`action\` (HTML slot), \`sticky\` |
|
|
285
|
+
| \`hero\` | \`title\`, \`subtitle\`, \`eyebrow\`, \`actions\` (HTML slot), \`image\` (HTML slot), \`background\` |
|
|
286
|
+
| \`button\` | \`label\`, \`variant\` (primary/secondary/ghost/**ghost-light**/danger), \`size\` (sm/md/lg), \`href\` — use **ghost-light** on dark backgrounds |
|
|
287
|
+
| \`section\` | \`content\`, \`variant\` (default/alt/**dark**/diagonal/paper/spotlight), \`padding\`, \`id\` — **dark** in light theme = navy bg |
|
|
288
|
+
| \`container\` | \`content\`, \`size\` (sm/md/lg/xl) |
|
|
289
|
+
| \`grid\` | \`content\`, \`cols\` (1–4), \`gap\` (sm/md/lg) |
|
|
290
|
+
| \`card\` | \`title\`, \`content\`, \`footer\`, \`variant\` (default/elevated/bordered/flat/glass/tinted) |
|
|
291
|
+
| \`feature\` | \`icon\`, \`title\`, \`description\`, \`level\`, \`center\` |
|
|
292
|
+
| \`stat\` | \`label\`, \`value\`, \`change\`, \`trend\`, \`center\` |
|
|
293
|
+
| \`testimonial\` | \`quote\`, \`name\`, \`role\`, \`rating\` |
|
|
294
|
+
| \`cta\` | \`title\`, \`subtitle\`, \`actions\` (HTML slot), \`eyebrow\` |
|
|
295
|
+
| \`footer\` | \`logo\`, \`logoHref\`, \`links\`, \`legal\`, \`columns\` |
|
|
296
|
+
| \`input\` | \`name\`, \`label\`, \`type\`, \`required\`, \`error\` |
|
|
297
|
+
| \`modal\` | \`id\`, \`title\`, \`content\`, \`footer\` — always in DOM, never conditional |
|
|
298
|
+
| \`displayHeading\` | \`text\`, \`level\`, \`tracking\` — editorial large type |
|
|
299
|
+
| \`sectionLabel\` | \`eyebrow\`, \`heading\`, \`level\`, \`rule\` — editorial section divider |
|
|
300
|
+
| \`pullquote\` | \`quote\`, \`cite\`, \`size\` |
|
|
301
|
+
| \`entryList\` | \`items\` ([{meta, title, description}]), \`metaLabel\` — tabular editorial list |
|
|
302
|
+
|
|
303
|
+
---
|
|
304
|
+
|
|
305
|
+
## Theming essentials
|
|
306
|
+
|
|
307
|
+
- Hex values → \`public/theme.css\` only. \`app.css\` uses \`var()\` references only.
|
|
308
|
+
- Load order: \`/pulse-ui.css\` → \`/theme.css\` → \`/app.css\`
|
|
309
|
+
- Light theme overrides target \`[data-theme="light"]\`, NOT \`\:root\`
|
|
310
|
+
- \`--ui-accent\` must pass WCAG AA 4.5:1 on \`--ui-bg\` for body text
|
|
311
|
+
- \`--ui-muted\` on warm/light palettes often fails contrast — always override \`--muted\` when changing \`--bg\` and run \`pulse_check_contrast\`
|
|
312
|
+
- Ghost buttons on dark backgrounds: use \`variant: 'ghost-light'\` not \`'ghost'\`
|
|
313
|
+
`,
|
|
314
|
+
}]
|
|
315
|
+
})
|
|
316
|
+
)
|
|
317
|
+
|
|
190
318
|
server.registerResource(
|
|
191
319
|
'workflow',
|
|
192
320
|
'pulse://workflow',
|
|
@@ -675,7 +803,7 @@ server.registerTool(
|
|
|
675
803
|
res.on('data', d => chunks.push(d))
|
|
676
804
|
res.on('end', () => {
|
|
677
805
|
const body = Buffer.concat(chunks).toString('utf-8')
|
|
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.
|
|
806
|
+
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. If this was a NEW build (pulse_intake ran): show the screenshot to the user and ASK for design approval before continuing\n3. Once the user approves (or this is an edit/fix): pulse_design_review → pulse_layout_review → /verify\nDo NOT run Lighthouse before the user has approved the design.` : ''
|
|
679
807
|
resolve(text(`HTTP ${res.statusCode}\n\n${body.slice(0, 8000)}${reminder}`))
|
|
680
808
|
})
|
|
681
809
|
})
|
|
@@ -896,10 +1024,17 @@ ${(() => {
|
|
|
896
1024
|
const reactPatterns = /(className|htmlFor|onClick)=/.test(renderedHtml)
|
|
897
1025
|
checks.push(reactPatterns ? '✗ **React patterns found** — use class, for, data-event instead' : '✓ No React patterns (className/htmlFor/onClick)')
|
|
898
1026
|
|
|
899
|
-
// Emoji in HTML
|
|
900
|
-
|
|
901
|
-
const
|
|
902
|
-
|
|
1027
|
+
// Emoji in HTML — strip SVG, data URIs, and aria-hidden elements before checking.
|
|
1028
|
+
// Mathematical/decorative Unicode in aria-hidden elements is intentional and accessible.
|
|
1029
|
+
const htmlNoSvg = renderedHtml
|
|
1030
|
+
.replace(/<svg[\s\S]*?<\/svg>/gi, '')
|
|
1031
|
+
.replace(/data:[^"']+/g, '')
|
|
1032
|
+
.replace(/<[^>]+aria-hidden=["']true["'][^>]*>[\s\S]*?<\/[^>]+>/gi, '')
|
|
1033
|
+
// Restrict to the emoji Unicode blocks only — excludes math symbols (∑, π, ∞, etc.),
|
|
1034
|
+
// arrows, and other non-emoji Unicode that are commonly used as decorative text.
|
|
1035
|
+
const emojiRegex = /[\u{1F600}-\u{1F64F}\u{1F300}-\u{1F5FF}\u{1F680}-\u{1F6FF}\u{1F900}-\u{1F9FF}\u{1FA00}-\u{1FA6F}\u{1FA70}-\u{1FAFF}]/u
|
|
1036
|
+
const hasEmoji = emojiRegex.test(htmlNoSvg)
|
|
1037
|
+
checks.push(hasEmoji ? '⚠ **Emoji in HTML** — use icon components or aria-label instead (verify: may be intentional icon)' : '✓ No emoji in view HTML')
|
|
903
1038
|
|
|
904
1039
|
// Main landmark
|
|
905
1040
|
const hasMain = /<main[^>]*id=["']?main-content/.test(renderedHtml)
|
|
@@ -907,14 +1042,22 @@ ${(() => {
|
|
|
907
1042
|
}
|
|
908
1043
|
|
|
909
1044
|
// Check spec source
|
|
910
|
-
// Hex colours in view
|
|
911
|
-
|
|
1045
|
+
// Hex colours in view — exclude anchor hrefs (#id), CSS selector strings, and comments
|
|
1046
|
+
// Only flag actual colour values: # followed by exactly 3 or 6 hex chars with no trailing word chars
|
|
1047
|
+
const viewBlock = source.slice(source.indexOf('view:'))
|
|
1048
|
+
const hexInView = /(?<!href=["']?)#[0-9a-fA-F]{6}\b|(?<!href=["']?)#[0-9a-fA-F]{3}\b(?![0-9a-fA-F])/.test(viewBlock)
|
|
912
1049
|
if (hexInView) {
|
|
913
|
-
checks.push('⚠ **Possible hex colour in view** — use var(--ui-*) tokens only')
|
|
1050
|
+
checks.push('⚠ **Possible hex colour in view** — use var(--ui-*) tokens only (verify: may be an anchor #id href)')
|
|
914
1051
|
} else {
|
|
915
1052
|
checks.push('✓ No obvious hex colours in view')
|
|
916
1053
|
}
|
|
917
1054
|
|
|
1055
|
+
// Creative override detection — check if spec declares component-free mode
|
|
1056
|
+
const creativeOverride = /component.free|creative\s+override|raw\s+HTML\s+throughout/i.test(source)
|
|
1057
|
+
checks.push(creativeOverride
|
|
1058
|
+
? '⚡ **Creative override declared** — component pattern checks are advisory only. Lighthouse 100/100/100/100 is the pass bar.'
|
|
1059
|
+
: '○ No creative override declared — component checks apply')
|
|
1060
|
+
|
|
918
1061
|
return checks.join('\n')
|
|
919
1062
|
})()}
|
|
920
1063
|
|
|
@@ -941,19 +1084,23 @@ Work through every item. Fix anything that fails. Refer to the spec source at ${
|
|
|
941
1084
|
|
|
942
1085
|
### Components & HTML
|
|
943
1086
|
- [ ] Components from the UI library are used — no hand-written \`<button>\`, \`<input>\`, \`<table>\` etc where a component exists
|
|
944
|
-
- [ ] **
|
|
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
|
-
|
|
1087
|
+
- [ ] **Component patterns check** — grep the rendered HTML for these class names:
|
|
1088
|
+
- \`.hero\`, \`.-hero\`, \`__hero\` → should use \`hero()\` component
|
|
1089
|
+
- \`.card\`, \`.product-card\`, \`.service-card\` → should use \`card()\` component
|
|
1090
|
+
- Any two-column image + text layout → should use \`media()\` component
|
|
1091
|
+
- \`.banner\`, \`.strip\`, \`.bar\` → should use \`banner()\` component
|
|
1092
|
+
- \`.feature\`, \`.feature-card\` → should use \`feature()\` component
|
|
1093
|
+
- \`.testimonial\` → should use \`testimonial()\` component
|
|
1094
|
+
|
|
1095
|
+
**Before flagging a pattern match**, ask: *can the \`hero()\` (or relevant) component actually reproduce this layout?* If the design uses full-viewport height, custom gradient glows, clamp-scaled display type, asymmetric layout, or other features the component doesn't support — that is a **creative override**, not a violation. Check the auto-checked items above: if "creative override declared" is shown, these checks are advisory only and Lighthouse is the pass bar.
|
|
1096
|
+
|
|
1097
|
+
If no creative override is declared and a component *could* reproduce the design, refactor to use it. Custom utility classes on top of components are fine (\`hero({ ... })\` + override CSS) — but do not write the entire structure from scratch when the component supports the layout.
|
|
1098
|
+
|
|
1099
|
+
- [ ] **Creative override** (fill in if applicable): *State the override reason here — e.g. "full-viewport gradient hero not achievable with hero() component"*. Confirm Lighthouse 100/100/100/100 desktop and mobile passes before closing the review.
|
|
953
1100
|
- [ ] No \`data-event\` on text inputs — this destroys focus on every keystroke
|
|
954
1101
|
- [ ] No \`className\`, \`htmlFor\`, \`onClick=\`, or other React patterns
|
|
955
|
-
- [ ] No hardcoded hex colours — only \`var(--ui-*)\` tokens
|
|
956
|
-
- [ ] No emoji in
|
|
1102
|
+
- [ ] No hardcoded hex colours in view — only \`var(--ui-*)\` tokens (anchor \`href="#id"\` values are fine — those are not colours)
|
|
1103
|
+
- [ ] No emoji in view HTML (decorative emoji without accessible text is a fail; emoji with \`aria-label\` or inside \`<span aria-hidden="true">\` is acceptable)
|
|
957
1104
|
- [ ] **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.
|
|
958
1105
|
|
|
959
1106
|
### Accessibility
|
|
@@ -1606,10 +1753,10 @@ server.registerTool(
|
|
|
1606
1753
|
{
|
|
1607
1754
|
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
1755
|
|
|
1609
|
-
Use this AFTER the
|
|
1756
|
+
Use this AFTER design approval (user has seen the screenshot and signed off on the layout) 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
1757
|
|
|
1611
|
-
Call this as part of the Phase
|
|
1612
|
-
|
|
1758
|
+
Call this as part of the Phase 5b verification flow (after design approval):
|
|
1759
|
+
design approval → pulse_design_review → pulse_layout_review → Lighthouse`,
|
|
1613
1760
|
inputSchema: {
|
|
1614
1761
|
url: z.string().describe('The full URL to check, e.g. http://localhost:3001/about'),
|
|
1615
1762
|
},
|
|
@@ -3153,7 +3300,7 @@ async function validateContent(content) {
|
|
|
3153
3300
|
|
|
3154
3301
|
// Append browser check reminder on clean pass so agents don't skip Lighthouse
|
|
3155
3302
|
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)
|
|
3303
|
+
finalOutput += '\n\n---\n**Next: browser check sequence** (do not skip)\n1. `pulse_fetch_page` → screenshot\n2. **New build?** Show screenshot to user and ask for design approval before continuing\n3. Once approved (or edit/fix): `pulse_design_review` (if intake ran) → `pulse_layout_review <url>` → `/verify`\nDo not run Lighthouse before the user approves the design. Do not report done until `/verify` passes.'
|
|
3157
3304
|
}
|
|
3158
3305
|
|
|
3159
3306
|
return text(finalOutput)
|
|
@@ -3230,7 +3377,8 @@ const PULSE_GUIDE_INDEX = `# Pulse Framework Guide
|
|
|
3230
3377
|
|
|
3231
3378
|
| Resource | When to fetch |
|
|
3232
3379
|
|---|---|
|
|
3233
|
-
| \`pulse://
|
|
3380
|
+
| \`pulse://quickstart\` | **Editing, bug-fixing, or a simple one-shot build** — workflow + spec skeleton + components + theming in one fetch. Skip the 4-resource cold-start for targeted tasks. |
|
|
3381
|
+
| \`pulse://workflow\` | **New project or new page** — full phase/gate sequence before writing any code. |
|
|
3234
3382
|
| \`pulse://guide/spec\` | Building a spec — state, mutations, actions, streaming SSR, key rules, form layout |
|
|
3235
3383
|
| \`pulse://guide/server\` | Server data, global store, persist, cookies, redirects, POST handling |
|
|
3236
3384
|
| \`pulse://guide/styles\` | CSS tokens, theming, custom fonts, utility classes |
|
|
@@ -3245,8 +3393,8 @@ const PULSE_GUIDE_INDEX = `# Pulse Framework Guide
|
|
|
3245
3393
|
## Tools available
|
|
3246
3394
|
|
|
3247
3395
|
**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.
|
|
3396
|
+
- \`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/attaches 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. **Always check \`public/intake/\` for images at the start of a new build — if any exist, call this before pulse_intake.**
|
|
3397
|
+
- \`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.** The final intake question must always be: "Do you have any design inspiration — a site you love, a screenshot, or a mood board? Drop images into \`public/intake/\` or share a URL." If the user provides references, call \`pulse_extract_inspiration\` before proceeding. After intake, call pulse_sketch to explore structural directions before writing code.
|
|
3250
3398
|
- \`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
3399
|
- \`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
3400
|
- \`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.
|
|
@@ -53,12 +53,6 @@ if (!valid) {
|
|
|
53
53
|
// Render the view and run additional HTML checks
|
|
54
54
|
const warnings = [...schemaWarnings]
|
|
55
55
|
|
|
56
|
-
// meta.theme missing — a page without theme set will use the user's system preference,
|
|
57
|
-
// which can produce unexpected dark/light rendering for spec authors who built for one mode.
|
|
58
|
-
if (!spec.meta?.theme) {
|
|
59
|
-
warnings.push('meta.theme is not set — the page will follow the user\'s system colour-scheme preference. Add meta: { theme: \'light\' } or meta: { theme: \'dark\' } to lock the appearance. Most Pulse pages should set this explicitly.')
|
|
60
|
-
}
|
|
61
|
-
|
|
62
56
|
// Raw-response specs (contentType + render) produce non-HTML output —
|
|
63
57
|
// skip all view rendering and HTML-specific checks for them.
|
|
64
58
|
const isRawResponse = typeof spec.render === 'function' ||
|
|
@@ -93,6 +87,18 @@ try {
|
|
|
93
87
|
warnings.push(`Heading order: page starts with h${levels[0]}, expected h1`)
|
|
94
88
|
}
|
|
95
89
|
|
|
90
|
+
// Duplicate h2s inside a single section — inner item headings should be h3
|
|
91
|
+
// A section with an h2 title and child items that also use h2 creates a flat
|
|
92
|
+
// outline that is semantically incorrect. Flag when a <section> contains 2+ h2s.
|
|
93
|
+
const sectionBlocks = [...html.matchAll(/<section[\s>][^]*?<\/section>/gi)]
|
|
94
|
+
for (const [sectionHtml] of sectionBlocks) {
|
|
95
|
+
const h2s = [...sectionHtml.matchAll(/<h2[\s>]/gi)]
|
|
96
|
+
if (h2s.length > 1) {
|
|
97
|
+
warnings.push(`Multiple h2 headings inside a single <section> — the first h2 should be the section title; inner item headings should use h3 (or deeper). A flat outline of h2s makes navigation harder for screen reader users.`)
|
|
98
|
+
break // one warning is enough
|
|
99
|
+
}
|
|
100
|
+
}
|
|
101
|
+
|
|
96
102
|
// Missing main landmark
|
|
97
103
|
if (!/id=["']main-content["']/.test(html)) {
|
|
98
104
|
warnings.push('Missing <main id="main-content"> — required for accessibility and Lighthouse landmark audit')
|
|
@@ -143,6 +149,15 @@ try {
|
|
|
143
149
|
if (/<style[\s>]/i.test(html)) {
|
|
144
150
|
warnings.push('Inline <style> block detected in view — Pulse\'s CSP (style-src with nonces) will silently block these styles at runtime. Move styles to public/app.css instead.')
|
|
145
151
|
}
|
|
152
|
+
|
|
153
|
+
// Hardcoded hex colours in view HTML — must use var(--ui-*) tokens instead.
|
|
154
|
+
// Matches: style="...color:#hex..." or style="...background:#hex..."
|
|
155
|
+
// Allows: href="#section-id" (anchor links are not colours)
|
|
156
|
+
const hexInStyle = [...html.matchAll(/style="[^"]*#[0-9a-fA-F]{3,6}[^"]*"/g)]
|
|
157
|
+
if (hexInStyle.length) {
|
|
158
|
+
const samples = [...new Set(hexInStyle.flatMap(m => [...m[0].matchAll(/#[0-9a-fA-F]{3,6}/g)].map(h => h[0])))].slice(0, 3)
|
|
159
|
+
warnings.push(`Hardcoded hex colour${samples.length > 1 ? 's' : ''} in view HTML (${samples.join(', ')}) — use var(--ui-*) CSS tokens instead. Move the hex value to public/theme.css as a named token, then reference it via var() in public/app.css or as a CSS class on the element.`)
|
|
160
|
+
}
|
|
146
161
|
} catch { /* view may depend on server data — skip HTML checks */ }
|
|
147
162
|
|
|
148
163
|
if (warnings.length > 0) {
|
package/src/server/index.js
CHANGED
|
@@ -499,7 +499,9 @@ export async function createServer(entries, options = {}) {
|
|
|
499
499
|
healthCheck = '/healthz', // path for health check endpoint, or false to disable
|
|
500
500
|
csp = {}, // extra CSP sources: { 'style-src': ['https://fonts.googleapis.com'] }
|
|
501
501
|
onError = (err, req, res) => defaultErrorHandler(err, req, res, dev),
|
|
502
|
-
onRequest
|
|
502
|
+
onRequest,
|
|
503
|
+
agentMode = false, // show agent-active indicator in banner
|
|
504
|
+
quiet = false, // suppress request logging
|
|
503
505
|
} = options
|
|
504
506
|
|
|
505
507
|
// Resolve URL entries → spec objects, auto-setting hydrate where needed
|
|
@@ -553,6 +555,15 @@ export async function createServer(entries, options = {}) {
|
|
|
553
555
|
let router = buildRouter(specs)
|
|
554
556
|
|
|
555
557
|
const server = http.createServer(async (req, res) => {
|
|
558
|
+
const reqStart = Date.now()
|
|
559
|
+
res.on('finish', () => {
|
|
560
|
+
if (!quiet) {
|
|
561
|
+
import('../cli/logger.js').then(({ request: logRequest }) => {
|
|
562
|
+
const url = new URL(req.url, `http://localhost:${port}`)
|
|
563
|
+
logRequest({ method: req.method, pathname: url.pathname, status: res.statusCode, ms: Date.now() - reqStart })
|
|
564
|
+
}).catch(() => {})
|
|
565
|
+
}
|
|
566
|
+
})
|
|
556
567
|
try {
|
|
557
568
|
// Parse URL — needed before health check and routing
|
|
558
569
|
const url = new URL(req.url, `http://localhost:${port}`)
|
|
@@ -754,7 +765,7 @@ export async function createServer(entries, options = {}) {
|
|
|
754
765
|
if (draining) return
|
|
755
766
|
draining = true
|
|
756
767
|
|
|
757
|
-
|
|
768
|
+
import('../cli/logger.js').then(({ shutdown: logShutdown }) => logShutdown()).catch(() => {})
|
|
758
769
|
|
|
759
770
|
// Stop background cache eviction timers
|
|
760
771
|
serverDataCache.stopEviction()
|
|
@@ -771,7 +782,7 @@ export async function createServer(entries, options = {}) {
|
|
|
771
782
|
|
|
772
783
|
// Force-exit after shutdownTimeout so a stuck request can't block a deploy
|
|
773
784
|
setTimeout(() => {
|
|
774
|
-
|
|
785
|
+
import('../cli/logger.js').then(({ shutdown: logShutdown }) => logShutdown(true)).catch(() => {})
|
|
775
786
|
process.exit(1)
|
|
776
787
|
}, shutdownTimeout).unref()
|
|
777
788
|
}
|
|
@@ -780,20 +791,13 @@ export async function createServer(entries, options = {}) {
|
|
|
780
791
|
process.on('SIGINT', shutdown)
|
|
781
792
|
|
|
782
793
|
server.listen(port, () => {
|
|
783
|
-
import('../cli/
|
|
784
|
-
|
|
785
|
-
|
|
786
|
-
|
|
787
|
-
|
|
788
|
-
|
|
789
|
-
return [c.cyan(route), c.dim(method), hydrated]
|
|
794
|
+
import('../cli/logger.js').then(({ banner }) => {
|
|
795
|
+
banner({
|
|
796
|
+
url: `http://localhost:${port}`,
|
|
797
|
+
version,
|
|
798
|
+
specs,
|
|
799
|
+
agentMode,
|
|
790
800
|
})
|
|
791
|
-
console.log(`\n ${icon.bolt()} ${c.bold('Pulse')} ${c.dim(`→ ${url}`)}\n`)
|
|
792
|
-
if (rows.length > 0) {
|
|
793
|
-
const tbl = table(['Route', 'Method', 'Hydrated'], rows)
|
|
794
|
-
console.log(tbl.split('\n').map(l => ' ' + l).join('\n'))
|
|
795
|
-
console.log()
|
|
796
|
-
}
|
|
797
801
|
}).catch(() => {
|
|
798
802
|
console.log(`⚡ Pulse running at http://localhost:${port}`)
|
|
799
803
|
})
|
package/src/spec/schema.js
CHANGED
|
@@ -376,6 +376,22 @@ export function validateSpec(spec) {
|
|
|
376
376
|
warnings.push('spec.hydrate is set manually — it is auto-derived by the framework from the URL entry passed to createServer. Remove it from the spec.')
|
|
377
377
|
}
|
|
378
378
|
|
|
379
|
+
// meta — warn on unrecognised fields (e.g. meta.vibe is silently ignored; the correct
|
|
380
|
+
// field is top-level spec.meta.vibe which IS supported, but an agent might write
|
|
381
|
+
// meta: { vibe: 'bold' } expecting it to work when the real effect comes from
|
|
382
|
+
// the framework reading spec.meta.vibe directly — this check catches unknown keys)
|
|
383
|
+
if (spec.meta && typeof spec.meta === 'object' && !Array.isArray(spec.meta)) {
|
|
384
|
+
const KNOWN_META_KEYS = new Set([
|
|
385
|
+
'title', 'description', 'styles', 'scripts', 'theme', 'vibe',
|
|
386
|
+
'ogTitle', 'ogDescription', 'ogImage', 'canonical', 'schema',
|
|
387
|
+
'robots', 'viewport', 'charset', 'lang',
|
|
388
|
+
])
|
|
389
|
+
const unknown = Object.keys(spec.meta).filter(k => typeof spec.meta[k] !== 'function' && !KNOWN_META_KEYS.has(k))
|
|
390
|
+
if (unknown.length) {
|
|
391
|
+
warnings.push(`spec.meta has unrecognised field${unknown.length > 1 ? 's' : ''}: ${unknown.map(k => `"${k}"`).join(', ')} — these are silently ignored by the framework. Known fields: ${[...KNOWN_META_KEYS].join(', ')}.`)
|
|
392
|
+
}
|
|
393
|
+
}
|
|
394
|
+
|
|
379
395
|
// meta quality
|
|
380
396
|
if (!spec.meta) {
|
|
381
397
|
warnings.push('spec.meta is missing — add at minimum title and description for SEO')
|
|
@@ -389,9 +405,9 @@ export function validateSpec(spec) {
|
|
|
389
405
|
} else if (typeof desc === 'string' && desc.trim() === 'Built with Pulse') {
|
|
390
406
|
warnings.push('spec.meta.description is still the default "Built with Pulse" — replace it with a real description')
|
|
391
407
|
}
|
|
392
|
-
// If meta.theme is not set, Pulse defaults to dark — warn so agents
|
|
393
|
-
// ship a dark-themed page when they intended light
|
|
394
|
-
if (spec.meta.theme
|
|
408
|
+
// If meta.theme is not set (and not a function), Pulse defaults to dark — warn so agents
|
|
409
|
+
// don't accidentally ship a dark-themed page when they intended light.
|
|
410
|
+
if (typeof spec.meta.theme !== 'string' && typeof spec.meta.theme !== 'function') {
|
|
395
411
|
warnings.push('spec.meta.theme is not set — Pulse defaults to dark. Add meta.theme: \'dark\' (or \'light\') to make the intent explicit and suppress this warning.')
|
|
396
412
|
}
|
|
397
413
|
}
|
package/src/spec/schema.test.js
CHANGED
|
@@ -384,6 +384,34 @@ test('validateSpec still warns when hydrate is set manually in source', () => {
|
|
|
384
384
|
assert(warnings.some(w => w.includes('hydrate')), 'Expected hydrate warning from validateSpec')
|
|
385
385
|
})
|
|
386
386
|
|
|
387
|
+
// meta.theme warnings
|
|
388
|
+
test('warns when meta.theme is not set', () => {
|
|
389
|
+
const { warnings } = validateSpec({
|
|
390
|
+
route: '/entry',
|
|
391
|
+
view: () => '<main id="main-content"></main>',
|
|
392
|
+
meta: { title: 'Entry', description: 'A page' },
|
|
393
|
+
})
|
|
394
|
+
assert(warnings.some(w => w.includes('meta.theme')), 'Expected meta.theme warning')
|
|
395
|
+
})
|
|
396
|
+
|
|
397
|
+
test('does not warn when meta.theme is a string', () => {
|
|
398
|
+
const { warnings } = validateSpec({
|
|
399
|
+
route: '/entry',
|
|
400
|
+
view: () => '<main id="main-content"></main>',
|
|
401
|
+
meta: { title: 'Entry', description: 'A page', theme: 'dark' },
|
|
402
|
+
})
|
|
403
|
+
assert(!warnings.some(w => w.includes('meta.theme')), 'Unexpected meta.theme warning when theme is set')
|
|
404
|
+
})
|
|
405
|
+
|
|
406
|
+
test('does not warn when meta.theme is a function', () => {
|
|
407
|
+
const { warnings } = validateSpec({
|
|
408
|
+
route: '/entry',
|
|
409
|
+
view: () => '<main id="main-content"></main>',
|
|
410
|
+
meta: { title: 'Entry', description: 'A page', theme: (ctx) => ctx.brand?.theme ?? 'dark' },
|
|
411
|
+
})
|
|
412
|
+
assert(!warnings.some(w => w.includes('meta.theme')), 'Unexpected meta.theme warning when theme is a function')
|
|
413
|
+
})
|
|
414
|
+
|
|
387
415
|
// ---------------------------------------------------------------------------
|
|
388
416
|
|
|
389
417
|
console.log(`\n${passed + failed} tests: ${passed} passed, ${failed} failed\n`)
|
package/src/ui/button.js
CHANGED
|
@@ -20,7 +20,7 @@
|
|
|
20
20
|
|
|
21
21
|
import { escHtml as e } from '../html.js'
|
|
22
22
|
|
|
23
|
-
const VARIANTS = new Set(['primary', 'secondary', 'ghost', 'danger'])
|
|
23
|
+
const VARIANTS = new Set(['primary', 'secondary', 'ghost', 'ghost-light', 'danger'])
|
|
24
24
|
const SIZES = new Set(['sm', 'md', 'lg'])
|
|
25
25
|
|
|
26
26
|
export function button({
|