@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/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. 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.` : ''
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
- 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')
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 (rough check — may have false positives from comments)
911
- const hexInView = /#[0-9a-fA-F]{3,6}/.test(source) && source.includes('view:')
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
- - [ ] **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.
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 the view HTML
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 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.
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 5 browser check:
1612
- screenshot → pulse_design_review → pulse_layout_review → Lighthouse`,
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)\n3. `pulse_layout_review <url>`\n4. `/verify` — Lighthouse desktop + mobile + pulse_review\nDo not report done until `/verify` passes.'
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://workflow\` | **First. Always. Before any guide sections or code.** |
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) {
@@ -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
- console.log('⚡ Pulse shutting down gracefully…')
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
- console.error(`⚡ Pulse force-exiting after ${shutdownTimeout}ms shutdown timeout`)
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/fmt.js').then(({ c, table, icon }) => {
784
- const url = `http://localhost:${port}`
785
- const rows = specs.map(s => {
786
- const hydrated = s.hydrate ? c.green('✓') : c.dim('—')
787
- const method = s.method?.toUpperCase() || 'GET'
788
- const route = s.route || '?'
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
  })
@@ -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 don't accidentally
393
- // ship a dark-themed page when they intended light, or forget to declare intent.
394
- if (spec.meta.theme === undefined && typeof spec.meta.theme !== 'function') {
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
  }
@@ -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({