@invisibleloop/pulse 0.20.1 → 0.20.3

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.
@@ -12,9 +12,9 @@
12
12
  export function extractUsedClasses(htmlContents, jsContents, safelist = []) {
13
13
  const used = new Set()
14
14
 
15
- // Extract from class="..." in both rendered HTML and JS template literals
16
- for (const content of [...htmlContents, ...jsContents]) {
17
- const re = /class(?:Name)?=["']([^"'\n]+)["']/g
15
+ // Extract from class="..." in rendered HTML (no template expressions to worry about)
16
+ for (const content of htmlContents) {
17
+ const re = /class=["']([^"'\n]+)["']/g
18
18
  let m
19
19
  while ((m = re.exec(content)) !== null) {
20
20
  for (const cls of m[1].trim().split(/\s+/)) {
@@ -23,6 +23,41 @@ export function extractUsedClasses(htmlContents, jsContents, safelist = []) {
23
23
  }
24
24
  }
25
25
 
26
+ // Extract from class= attributes in JS source. Template literal class attributes
27
+ // like class="foo${expr ? ' bar' : ''}" have their expressions interrupted by
28
+ // single quotes inside the ternary, so [^"'\n]+ stops mid-expression and misses
29
+ // the base class. Instead: scan for class= followed by a double-quoted string
30
+ // OR a backtick-delimited segment, extract everything up to the first ${ or the
31
+ // closing delimiter, and tokenise only the static prefix.
32
+ for (const js of jsContents) {
33
+ // Match class="...anything up to closing quote or ${..."
34
+ // Using a two-pass approach: find class= then manually scan to get the static part
35
+ let i = 0
36
+ while (i < js.length) {
37
+ const idx = js.indexOf('class=', i)
38
+ if (idx === -1) break
39
+ i = idx + 6 // past 'class='
40
+
41
+ // Skip optional whitespace (shouldn't be any but be safe)
42
+ while (i < js.length && (js[i] === ' ' || js[i] === '\t')) i++
43
+
44
+ const quote = js[i]
45
+ if (quote !== '"' && quote !== "'" && quote !== '`') continue
46
+ i++ // past opening quote
47
+
48
+ // Collect characters until we hit the closing quote, a ${, or end of string
49
+ let staticPart = ''
50
+ while (i < js.length && js[i] !== quote && !(js[i] === '$' && js[i + 1] === '{')) {
51
+ staticPart += js[i++]
52
+ }
53
+
54
+ // Tokenise the static prefix
55
+ for (const cls of staticPart.trim().split(/\s+/)) {
56
+ if (cls && /^[a-zA-Z][a-zA-Z0-9_-]*$/.test(cls)) used.add(cls)
57
+ }
58
+ }
59
+ }
60
+
26
61
  // Scan JS source for quoted strings that look like CSS class names (contain hyphens)
27
62
  // Catches conditionally-applied classes like 'ui-btn--disabled' in ternaries
28
63
  for (const js of jsContents) {
@@ -50,6 +85,33 @@ export function extractUsedClasses(htmlContents, jsContents, safelist = []) {
50
85
  }
51
86
  }
52
87
 
88
+ // Pulse re-renders via innerHTML — classes added after mutations appear as
89
+ // template literal expressions inside class= attributes, not classList calls.
90
+ // Pattern: class="${state.x ? 'foo' : 'bar'}" or class="base ${state.x ? 'active' : ''}"
91
+ // The hyphen-only scan above misses single-word classes like 'active', 'open',
92
+ // 'visible', 'selected', 'hidden' that are common state-driven class names.
93
+ // Extract every quoted string that appears inside a class= template expression.
94
+ for (const js of jsContents) {
95
+ // Match: class=`...` or class="${...}" template attribute values
96
+ const attrRe = /class(?:Name)?=`([^`]*)`/g
97
+ let m
98
+ while ((m = attrRe.exec(js)) !== null) {
99
+ const attrContent = m[1]
100
+ // Collect static class tokens (outside ${...} expressions)
101
+ for (const cls of attrContent.replace(/\$\{[^}]*\}/g, ' ').trim().split(/\s+/)) {
102
+ if (cls && /^[a-zA-Z][a-zA-Z0-9_-]*$/.test(cls)) used.add(cls)
103
+ }
104
+ // Collect quoted strings inside ${...} expressions — these are the
105
+ // conditional class names: ${state.open ? 'open' : ''} → 'open'
106
+ for (const [, inner] of attrContent.matchAll(/\$\{([^}]*)\}/g)) {
107
+ for (const [, cls] of inner.matchAll(/['"]([a-zA-Z][a-zA-Z0-9_-]*)['"]|`([a-zA-Z][a-zA-Z0-9_-]*)`/g)) {
108
+ const name = cls
109
+ if (name && /^[a-zA-Z][a-zA-Z0-9_-]*$/.test(name)) used.add(name)
110
+ }
111
+ }
112
+ }
113
+ }
114
+
53
115
  // Apply safelist from pulse.config.js — css.safelist accepts strings or RegExp.
54
116
  // String entries are added directly; RegExp entries are matched against all
55
117
  // class names defined in the CSS (resolved later in purgeCss via selectorUsed
@@ -119,6 +119,22 @@ test('multiple classList arguments in one call are all collected', () => {
119
119
  for (const cls of ['one', 'two-x', 'three']) assert.ok(used.has(cls), cls)
120
120
  })
121
121
 
122
+ // Regression: class name immediately followed by ${ (no space before the expression)
123
+ // was not extracted — "saved-palette${...}" yielded "saved-palette${expanded" as a
124
+ // single token, so the rule was silently purged in prod while dev (no purging) looked fine.
125
+ test('class name immediately before ${ is extracted correctly', () => {
126
+ const js = [
127
+ // No space before ${ — the broken pattern
128
+ `const v = \`<div class="saved-palette\${expanded ? ' saved-palette--expanded' : ''}"></div>\``,
129
+ // Multiple static classes, last one butts into ${
130
+ `const v = \`<button class="btn btn--primary\${loading ? ' btn--loading' : ''}"></button>\``,
131
+ ]
132
+ const used = extractUsedClasses([], js)
133
+ for (const cls of ['saved-palette', 'btn', 'btn--primary']) {
134
+ assert.ok(used.has(cls), `Expected "${cls}" to be extracted but it was missing`)
135
+ }
136
+ })
137
+
122
138
  test('runtime-toggled compound rule survives the purge', () => {
123
139
  const css = `.docs-sidebar { transform: translateX(-100%); }\n.docs-sidebar.open { transform: translateX(0); }\n.unused { color: red; }`
124
140
  const used = extractUsedClasses(
@@ -8,7 +8,7 @@
8
8
  // ---------------------------------------------------------------------------
9
9
 
10
10
  const SERVER_ONLY_KEYS = ['server', 'meta', 'guard', 'serverTimeout', 'contentType', 'render', 'sitemap']
11
- const SERVER_ONLY_IMPORTS = ['@invisibleloop/pulse/md']
11
+ const SERVER_ONLY_IMPORTS = ['@invisibleloop/pulse/md', '@invisibleloop/pulse']
12
12
 
13
13
  function stripServerOnlyKeys(source) {
14
14
  for (const key of SERVER_ONLY_KEYS) source = removeObjectKey(source, key)
@@ -415,6 +415,16 @@ test('store definition: server fetchers stripped, state and mutations survive',
415
415
  `export default {\n state: { basket: [] },\n mutations: {\n add: (store, item) => ({ basket: [...store.basket, item] }),\n },\n}`
416
416
  )
417
417
 
418
+ test('package-root import (pushStore) is stripped — the root export is the server itself',
419
+ `import { pushStore } from '@invisibleloop/pulse'\nexport default {\n route: '/staff',\n view: () => '',\n}`,
420
+ `export default {\n route: '/staff',\n view: () => '',\n}`
421
+ )
422
+
423
+ test('subpath imports (/ui) are NOT stripped by the root entry',
424
+ `import { button } from '@invisibleloop/pulse/ui'\nexport default {\n route: '/x',\n view: () => button({ label: 'x' }),\n}`,
425
+ `import { button } from '@invisibleloop/pulse/ui'\nexport default {\n route: '/x',\n view: () => button({ label: 'x' }),\n}`
426
+ )
427
+
418
428
  // ---------------------------------------------------------------------------
419
429
 
420
430
  console.log(`\n${pass + fail} tests: ${pass} passed, ${fail} failed\n`)
@@ -1,9 +1,11 @@
1
- Start the Pulse production server for this project.
1
+ Start the Pulse production server for this project — **locally, on the production port (dev port + 1, normally 3001)** so it can run alongside the dev server on 3000.
2
2
 
3
- First ensure a build exists (`public/dist/`). Then run:
3
+ First ensure a build exists (`public/dist/` — run `pulse build` if not). Then:
4
4
 
5
5
  ```bash
6
- pulse start
6
+ pulse start --port 3001
7
7
  ```
8
8
 
9
- The production server uses the port from `pulse.config.js` (defaults to 3000).
9
+ If `pulse.config.js` sets a custom `port`, use that value + 1 instead.
10
+
11
+ > **Why the flag:** plain `pulse start` binds the configured port (3000) because in real deployment it is the only server and platforms inject `PORT`. Locally that collides with the dev server and breaks the convention used by `pulse_build`, Lighthouse, and the performance trace — production is always dev port + 1. Never use `--port` in actual deployment.
@@ -1,4 +1,4 @@
1
- Stop the Pulse dev server for this project.
1
+ Stop the Pulse servers for this project — both the dev server (port 3000) and the local production server (port 3001, started by `pulse_build` or `/pulse-start`).
2
2
 
3
3
  ```bash
4
4
  pulse stop
@@ -1,12 +1,18 @@
1
1
  # Verify
2
2
 
3
- Run the full verification loop on the current page or the page specified in $ARGUMENTS.
3
+ Run the verification loop on the current page or the page specified in $ARGUMENTS.
4
+
5
+ **Modes:**
6
+ - `/verify` — full loop including Lighthouse (desktop + mobile), perf trace, and mobile layout check. Use when the user approves the design or signals they're ready to ship.
7
+ - `/verify --quick` — skips Lighthouse and perf trace; just validate → screenshot → console → review → stamp. Use during iterative building: design rounds, debugging visual issues, mid-build checkpoints. Fast enough to run between every change.
8
+
9
+ If `--quick` appears anywhere in $ARGUMENTS, run in quick mode (steps 6, 7, 8, and 9 are skipped). The route/file path is whatever remains in $ARGUMENTS after removing `--quick`.
4
10
 
5
11
  ## Steps
6
12
 
7
13
  ### 1. Identify the target
8
14
 
9
- If $ARGUMENTS is provided, use it as the file path or route. Otherwise, identify the most recently edited spec file from context.
15
+ If $ARGUMENTS is provided, use it (minus `--quick` if present) as the file path or route. Otherwise, identify the most recently edited spec file from context.
10
16
 
11
17
  ### 2. Design approval (new builds only)
12
18
 
@@ -30,7 +36,7 @@ Use `pulse_fetch_page` to confirm the server is responding for the route. If it
30
36
 
31
37
  Use `mcp__chrome-devtools__navigate_page` to load the page route, then `mcp__chrome-devtools__take_screenshot` to capture the result. Describe what you see — layout, content, any obvious rendering issues.
32
38
 
33
- ### 6. Lighthouse — desktop
39
+ ### 6. Lighthouse — desktop *(skip in quick mode)*
34
40
 
35
41
  **Pre-flight (required before every Lighthouse run):**
36
42
  1. Call `pulse_build` to produce a production build and start the production server on port 3001.
@@ -40,7 +46,7 @@ Then run `mcp__chrome-devtools__lighthouse_audit` with `{ "device": "desktop" }`
40
46
 
41
47
  **Pass bar: Accessibility, Best Practices, and SEO must all be 100.** Performance is measured and reported but is not a hard requirement (it varies with machine load). Report the actual scores. If Accessibility, Best Practices, or SEO is below 100, identify the failing audit(s), fix the issue, and restart from step 3.
42
48
 
43
- ### 7. Lighthouse — mobile
49
+ ### 7. Lighthouse — mobile *(skip in quick mode)*
44
50
 
45
51
  The browser should still be on `http://localhost:3001/` from step 6. Run `mcp__chrome-devtools__lighthouse_audit` with `{ "device": "mobile" }`.
46
52
 
@@ -48,7 +54,7 @@ The browser should still be on `http://localhost:3001/` from step 6. Run `mcp__c
48
54
 
49
55
  **Stay on the production server (port 3001)** — steps 8 and 9 use it too. Do not call `pulse_restart_server` yet.
50
56
 
51
- ### 8. Mobile layout check
57
+ ### 8. Mobile layout check *(skip in quick mode)*
52
58
 
53
59
  Emulate a mobile viewport and take a screenshot to catch wrapping, overflow, and layout issues before they become Lighthouse failures.
54
60
 
@@ -70,7 +76,7 @@ Fix any issues, then reset to desktop before continuing:
70
76
  mcp__chrome-devtools__emulate viewport: "1440x900x1"
71
77
  ```
72
78
 
73
- ### 9. Performance
79
+ ### 9. Performance *(skip in quick mode)*
74
80
 
75
81
  **Run the trace against the production server, not dev.** Navigate to `http://localhost:3001/<route>` first — if the production server is no longer running, run `pulse_build` again. Tracing the dev server (port 3000) reports misleading LCP/CLS. Then run `mcp__chrome-devtools__performance_start_trace` with `reload: true` and `autoStop: true`. Report LCP and CLS. Flag any LCP insight that suggests a fixable problem (render-blocking resources, large image load delay, etc.).
76
82
 
@@ -102,11 +108,11 @@ Call `pulse_stamp`. This writes `.pulse-verified` via the MCP server, which is m
102
108
 
103
109
  Summarise:
104
110
  - Validation: pass or fail (with errors if any)
105
- - Visual: desktop screenshot and mobile layout check — any issues found and fixed
106
- - Lighthouse desktop: scores for Accessibility, Best Practices, SEO
107
- - Lighthouse mobile: scores for Accessibility, Best Practices, SEO
108
- - Performance: LCP and CLS values, any flagged insights
111
+ - Visual: desktop screenshot — any issues found and fixed
109
112
  - Console: any errors
110
113
  - Review: pass or issues found and fixed
114
+ - **Full mode only:** Lighthouse desktop/mobile scores, mobile layout check, LCP and CLS values
115
+
116
+ In quick mode, end with: "Quick check passed — run `/verify` when you're ready for the full Lighthouse audit."
111
117
 
112
- Only confirm the page is good when validation passes, both Lighthouse runs are 100/100/100, CLS is 0.00, and there are no console errors. Otherwise, fix and run `/verify` again.
118
+ In full mode, only confirm the page is good when validation passes, both Lighthouse runs are 100/100/100, CLS is 0.00, and there are no console errors. Otherwise, fix and run `/verify` again.
@@ -4,92 +4,59 @@
4
4
  *
5
5
  * Called by the Stop hook in .claude/settings.json to enforce test coverage.
6
6
  * Runs `npm run test:coverage` and blocks the agent if any src/pages/ file
7
- * has uncovered lines — except async action run() and server fetcher functions
8
- * which cannot be tested without mocking real APIs.
7
+ * has uncovered lines — except code that cannot be unit-tested without mocking
8
+ * real APIs:
9
+ *
10
+ * - server fetchers (`server:` blocks) and store fetchers
11
+ * - action `run:` functions
12
+ * - `guard:`, `submit:`, and `sitemap:` handlers
13
+ * - `meta:` blocks (async meta functions hit real data sources)
14
+ *
15
+ * Also honours c8-style ignore comments, which Node's built-in coverage
16
+ * (--experimental-test-coverage) does NOT support natively. In a block or line
17
+ * comment, write:
18
+ *
19
+ * "c8 ignore next" — exempts the following line
20
+ * "c8 ignore next 3" — exempts the following 3 lines
21
+ * "c8 ignore start" ... "c8 ignore stop" — exempts the range
22
+ * ("coverage-ignore" works as a synonym for "c8 ignore")
9
23
  *
10
24
  * Outputs a Claude Code hook JSON decision to stdout.
11
25
  */
12
26
 
13
27
  import { execSync } from 'child_process'
14
28
  import fs from 'fs'
15
-
16
- // Agent is ending its turn to wait for the user's answer (design-approval gate) —
17
- // let the turn end. The marker is consumed on the user's next prompt, so the
18
- // gates return in full force afterwards.
19
- if (fs.existsSync('.pulse-awaiting-approval')) process.exit(0)
20
-
21
- // No test files → nothing to check
22
- const hasTests = fs.existsSync('src/pages') && (function scan(d) {
23
- for (const f of fs.readdirSync(d)) {
24
- const p = `${d}/${f}`
25
- if (fs.statSync(p).isDirectory()) { if (scan(p)) return true }
26
- else if (f.endsWith('.test.js')) return true
27
- }
28
- return false
29
- })('src/pages')
30
-
31
- if (!hasTests) process.exit(0)
32
-
33
- let out = ''
34
- try {
35
- out = execSync('npm run test:coverage 2>&1', { encoding: 'utf8', timeout: 60000 })
36
- } catch (e) {
37
- out = e.stdout || e.message
38
- }
39
-
40
- // Parse the coverage report — look for lines with uncovered line numbers
41
- const lines = out.split('\n')
42
- let inReport = false
43
- const rawGaps = []
44
-
45
- for (const line of lines) {
46
- if (line.includes('start of coverage report')) { inReport = true; continue }
47
- if (!inReport) continue
48
-
49
- // Match: # filename.js | line% | branch% | func% | 30-36 50
50
- const m = line.match(/^[#ℹ]\s+([\w./[\]-]+\.js)\s*\|\s*[\d.]+\s*\|\s*[\d.]+\s*\|\s*[\d.]+\s*\|\s*([^|\n]+)$/)
51
- if (m) {
52
- const uncov = m[2].trim()
53
- if (uncov && uncov !== 'uncovered lines') {
54
- rawGaps.push({ file: m[1].trim(), uncovered: uncov })
55
- }
56
- }
57
- }
29
+ import { pathToFileURL } from 'url'
30
+
31
+ // Property blocks that are exempt — all server-side-only code paths that call
32
+ // real external APIs and cannot run in unit tests.
33
+ const EXEMPT_BLOCK_STARTS = [
34
+ /^server\s*:/,
35
+ /^run\s*:/, /^run\s*\(/,
36
+ /^guard\s*:/,
37
+ /^submit\s*:/,
38
+ /^sitemap\s*:/,
39
+ /^meta\s*:/,
40
+ ]
58
41
 
59
42
  /**
60
- * Returns true if all uncovered line numbers in the given file fall inside
61
- * a `server:` property block or an action `run:` function — both of which
62
- * call real external APIs and are explicitly exempt from coverage requirements.
43
+ * Build the set of exempt (1-based) line numbers for a source string:
44
+ * lines inside exempt property blocks, plus c8-ignore-annotated lines.
45
+ *
46
+ * @param {string} src
47
+ * @returns {Set<number>}
63
48
  */
64
- function allLinesExempt(filePath, uncoveredSpec) {
65
- let src
66
- try { src = fs.readFileSync(filePath, 'utf8') } catch { return false }
49
+ export function computeExemptLines(src) {
67
50
  const srcLines = src.split('\n')
68
-
69
- // Expand "30-36 50" → [30, 31, 32, 33, 34, 35, 36, 50]
70
- const lineNums = []
71
- for (const part of uncoveredSpec.split(/\s+/)) {
72
- const range = part.match(/^(\d+)-(\d+)$/)
73
- if (range) {
74
- for (let i = parseInt(range[1]); i <= parseInt(range[2]); i++) lineNums.push(i)
75
- } else if (/^\d+$/.test(part)) {
76
- lineNums.push(parseInt(part))
77
- }
78
- }
79
-
80
- // Build a set of exempt line numbers: lines inside `server:` blocks or action `run:` functions.
81
- // Strategy: scan the source tracking brace depth relative to the start of each exempt block.
82
51
  const exemptLines = new Set()
52
+
53
+ // 1. Exempt property blocks — scan tracking brace/paren depth from block start
83
54
  let i = 0
84
55
  while (i < srcLines.length) {
85
56
  const trimmed = srcLines[i].trimStart()
86
- // Detect start of `server:` property or action `run:` function
87
- const isExemptStart = /^server\s*:/.test(trimmed) || /^run\s*:/.test(trimmed) || /^run\s*\(/.test(trimmed)
88
- if (isExemptStart) {
89
- // Mark lines from here until the block closes (depth reaches 0 after opening)
57
+ if (EXEMPT_BLOCK_STARTS.some(re => re.test(trimmed))) {
90
58
  let depth = 0
91
59
  let started = false
92
- const startLine = i
93
60
  while (i < srcLines.length) {
94
61
  exemptLines.add(i + 1) // 1-based
95
62
  for (const ch of srcLines[i]) {
@@ -103,30 +70,127 @@ function allLinesExempt(filePath, uncoveredSpec) {
103
70
  i++
104
71
  }
105
72
 
106
- return lineNums.every(n => exemptLines.has(n))
73
+ // 2. c8-style ignore comments (Node's built-in coverage ignores these, so we
74
+ // honour them here — the marker line itself is always exempt too)
75
+ const IGNORE = /(?:c8 ignore|coverage-ignore)(?:\s+(next|start|stop))?(?:\s+(\d+))?/
76
+ let rangeOpen = false
77
+ for (let n = 0; n < srcLines.length; n++) {
78
+ if (rangeOpen) exemptLines.add(n + 1)
79
+ const m = srcLines[n].match(IGNORE)
80
+ if (!m) continue
81
+ exemptLines.add(n + 1)
82
+ const keyword = m[1]
83
+ if (keyword === 'start') { rangeOpen = true; continue }
84
+ if (keyword === 'stop') { rangeOpen = false; continue }
85
+ // 'next' (or bare marker) — exempt the following N lines (default 1)
86
+ const count = m[2] ? parseInt(m[2], 10) : 1
87
+ for (let k = 1; k <= count; k++) exemptLines.add(n + 1 + k)
88
+ }
89
+
90
+ return exemptLines
107
91
  }
108
92
 
109
- const gaps = []
110
- for (const { file, uncovered } of rawGaps) {
111
- // Try to resolve relative to cwd (coverage reports use relative paths)
112
- const candidates = [file, `src/pages/${file}`, file.replace(/^.*src\/pages\//, 'src/pages/')]
113
- const resolved = candidates.find(p => fs.existsSync(p))
93
+ /**
94
+ * Returns true if all uncovered line numbers fall on exempt lines.
95
+ *
96
+ * @param {string} filePath
97
+ * @param {string} uncoveredSpec - e.g. "30-36 50"
98
+ * @returns {boolean}
99
+ */
100
+ export function allLinesExempt(filePath, uncoveredSpec) {
101
+ let src
102
+ try { src = fs.readFileSync(filePath, 'utf8') } catch { return false }
103
+
104
+ // Expand "30-36 50" → [30, 31, 32, 33, 34, 35, 36, 50]
105
+ const lineNums = []
106
+ for (const part of uncoveredSpec.split(/\s+/)) {
107
+ const range = part.match(/^(\d+)-(\d+)$/)
108
+ if (range) {
109
+ for (let i = parseInt(range[1]); i <= parseInt(range[2]); i++) lineNums.push(i)
110
+ } else if (/^\d+$/.test(part)) {
111
+ lineNums.push(parseInt(part))
112
+ }
113
+ }
114
+
115
+ const exemptLines = computeExemptLines(src)
116
+ return lineNums.every(n => exemptLines.has(n))
117
+ }
114
118
 
115
- if (resolved && allLinesExempt(resolved, uncovered)) continue // auto-exempt
119
+ // ---------------------------------------------------------------------------
120
+ // Main — only when executed directly (the exports above are unit-tested)
121
+ // ---------------------------------------------------------------------------
116
122
 
117
- gaps.push(` ${file} — uncovered lines: ${uncovered}`)
123
+ if (process.argv[1] && import.meta.url === pathToFileURL(process.argv[1]).href) {
124
+ main()
118
125
  }
119
126
 
120
- if (gaps.length) {
121
- process.stdout.write(JSON.stringify({
122
- decision: 'block',
123
- reason: [
124
- 'COVERAGE GAPS detected. Add tests for these uncovered lines before finishing:',
125
- ...gaps,
126
- '',
127
- 'Run `npm run test:coverage` to see the full report.',
128
- 'Exempt from this rule: async action run() functions and server fetchers that call real APIs.',
129
- 'Everything else — view branches, mutations, onViewError, pure helper functions — must be covered.',
130
- ].join('\n'),
131
- }))
127
+ function main() {
128
+ // Agent is ending its turn to wait for the user's answer (approval gate or a
129
+ // mid-flow pause) — let the turn end. The marker is consumed on the user's
130
+ // next prompt, so the gates return in full force afterwards.
131
+ if (fs.existsSync('.pulse-awaiting-approval')) process.exit(0)
132
+
133
+ // No test files → nothing to check
134
+ const hasTests = fs.existsSync('src/pages') && (function scan(d) {
135
+ for (const f of fs.readdirSync(d)) {
136
+ const p = `${d}/${f}`
137
+ if (fs.statSync(p).isDirectory()) { if (scan(p)) return true }
138
+ else if (f.endsWith('.test.js')) return true
139
+ }
140
+ return false
141
+ })('src/pages')
142
+
143
+ if (!hasTests) process.exit(0)
144
+
145
+ let out = ''
146
+ try {
147
+ out = execSync('npm run test:coverage 2>&1', { encoding: 'utf8', timeout: 60000 })
148
+ } catch (e) {
149
+ out = e.stdout || e.message
150
+ }
151
+
152
+ // Parse the coverage report — look for lines with uncovered line numbers
153
+ const lines = out.split('\n')
154
+ let inReport = false
155
+ const rawGaps = []
156
+
157
+ for (const line of lines) {
158
+ if (line.includes('start of coverage report')) { inReport = true; continue }
159
+ if (!inReport) continue
160
+
161
+ // Match: # filename.js | line% | branch% | func% | 30-36 50
162
+ const m = line.match(/^[#ℹ]\s+([\w./[\]-]+\.js)\s*\|\s*[\d.]+\s*\|\s*[\d.]+\s*\|\s*[\d.]+\s*\|\s*([^|\n]+)$/)
163
+ if (m) {
164
+ const uncov = m[2].trim()
165
+ if (uncov && uncov !== 'uncovered lines') {
166
+ rawGaps.push({ file: m[1].trim(), uncovered: uncov })
167
+ }
168
+ }
169
+ }
170
+
171
+ const gaps = []
172
+ for (const { file, uncovered } of rawGaps) {
173
+ // Try to resolve relative to cwd (coverage reports use relative paths)
174
+ const candidates = [file, `src/pages/${file}`, file.replace(/^.*src\/pages\//, 'src/pages/')]
175
+ const resolved = candidates.find(p => fs.existsSync(p))
176
+
177
+ if (resolved && allLinesExempt(resolved, uncovered)) continue // auto-exempt
178
+
179
+ gaps.push(` ${file} — uncovered lines: ${uncovered}`)
180
+ }
181
+
182
+ if (gaps.length) {
183
+ process.stdout.write(JSON.stringify({
184
+ decision: 'block',
185
+ reason: [
186
+ 'COVERAGE GAPS detected. Add tests for these uncovered lines before finishing:',
187
+ ...gaps,
188
+ '',
189
+ 'Run `npm run test:coverage` to see the full report.',
190
+ 'Auto-exempt: server fetchers, action run(), guard, submit, sitemap, and meta blocks — these call real APIs and need no tests.',
191
+ 'For anything else that genuinely cannot be tested, annotate it: /* c8 ignore next */ (or next N, or start/stop for a range) — this hook honours those comments even though Node\'s built-in coverage does not.',
192
+ 'Everything else — view branches, mutations, onViewError, pure helper functions — must be covered.',
193
+ ].join('\n'),
194
+ }))
195
+ }
132
196
  }
@@ -194,7 +194,7 @@ To add a missing icon: copy a Lucide path (MIT) into `src/ui/icons.js` using `s(
194
194
  | `list` | `items` (array of HTML strings), `ordered`, `gap` (xs/sm/md) |
195
195
  | `prose` | `content` (raw HTML — not escaped), `size` (sm/base/lg) — for CMS/markdown content you don't control |
196
196
 
197
- **Never write raw `<h1>`–`<h6>` or `<ul>`/`<ol>` without styling.** Use `heading()` and `list()` instead.
197
+ **Never write raw `<h1>`–`<h6>` or `<ul>`/`<ol>` without styling.** Use `heading()` and `list()` instead. *(Mode A only — in a creative override, raw headings styled directly with utility classes or custom CSS are correct. The `heading()` component adds wrapper markup that can fight custom layouts.)*
198
198
 
199
199
  **Heading order across components.** `card`, `feature`, `section`, `pricing`, `cta`, and `modal` all accept a `level` prop. Use it to keep the document outline correct without changing visual style. For example, if a page has an h2 section title and each card inside it has a title, pass `level: 3` on the cards (the default) — but if the cards are the first heading on the page, pass `level: 2`. The CSS class stays the same (`ui-card-title` etc.) so appearance is unaffected.
200
200
 
@@ -284,7 +284,7 @@ This is by design: vibes are a paint job, not a structural transformation. Compo
284
284
 
285
285
  | Component | Key props |
286
286
  |-----------|-----------|
287
- | `nav` | `logo` (HTML slot), `logoHref`, `links` ([{label,href}]), `action` (HTML slot), `sticky`, `background` (any CSS value — overrides default surface colour), `color` (foreground colour — set when custom background doesn't contrast with default muted text) |
287
+ | `nav` | `logo` (HTML slot), `logoHref`, `links` ([{label,href}]), `action` (HTML slot), `sticky`, `background` (any CSS value — overrides default surface colour), `color` (foreground colour — set when custom background doesn't contrast with default muted text) — ⚠ **renders a `<nav>` landmark internally. Never wrap it in another `<nav>` or `<header><nav>` — that creates a duplicate landmark Lighthouse flags as an accessibility failure. Wrapping in `<header>` alone is fine.** |
288
288
  | `hero` | `eyebrow`, `title`, `subtitle`, `actions` (HTML slot), `image` (HTML slot — activates image layout), `imageAlign` (right/left), `align` (center/left), `size` (md/sm), `layout` (split/asymmetric/overlap — when image provided: 'split'=50/50, 'asymmetric'=60/40 text-heavy, 'overlap'=image fills section with text overlay), `gradient` (true/false or preset: 'purple'\|'blue'\|'green'\|'rose'\|'orange'), `background` (any CSS value), `eyebrowColor` |
289
289
  | `phoneFrame` | `content` (HTML slot — rendered inside the phone screen), `animate` (boolean, default `false` — enables a gentle 3-D tilt on hover). Pure CSS phone mockup with dynamic island, side buttons, home indicator. Use as the `image` slot in `hero()` for app landing pages. Add `animate: true` whenever the phone is a prominent hero element. |
290
290
  | `feature` | `image` (HTML slot — rendered above icon), `icon` (HTML slot), `title`, `level` (1–6, default 3), `description` (or `body` as alias), `center` |
@@ -44,8 +44,8 @@ The component rules marked *(Mode A)* below are suspended under a declared creat
44
44
  - Hardcode hex colours in CSS. Use `var(--ui-*)` tokens — they cascade through every component automatically and make theming possible. The one place hex values belong is `public/theme.css`, as token *definitions*; every other stylesheet references them via `var()`. A lint hook enforces this.
45
45
  - Use inline `style=""` attributes. Use utility classes or `var(--ui-*)` tokens in a stylesheet. *Exception:* structural image styles with no utility-class equivalent (`aspect-ratio`, `object-fit`) are permitted inline — these are layout facts, not theming, and the design gallery uses them this way.
46
46
  - Write raw `<button>`, `<input>`, `<select>`, or `<textarea>` HTML when the component library already provides accessible, styled versions. *(Mode A — under a creative override, functional atoms should still come from components unless there is a specific design reason.)*
47
- - Write raw `<h1>`–`<h6>` without styling. Use `heading({ level, text })` instead. *(Mode A)*
48
- - Write raw `<ul>` or `<ol>` without styling. Use `list({ items })` instead. *(Mode A)*
47
+ - Write raw `<h1>`–`<h6>` without styling. Use `heading({ level, text })` instead. *(Mode A — under a creative override, raw headings styled with utility classes or custom CSS are correct; `heading()` adds wrapper markup that fights custom layouts.)*
48
+ - Write raw `<ul>` or `<ol>` without styling. Use `list({ items })` instead. *(Mode A — under a creative override, raw lists are permitted when the component markup would interfere with the layout.)*
49
49
  - Output CMS or database HTML without a `prose()` wrapper. Raw HTML from external sources has no styling — always wrap it in `prose({ content: html })`.
50
50
  - Use `data-event` on text inputs. Re-rendering on every keystroke destroys focus. Use uncontrolled inputs and read values from `FormData` in `action.onStart` or `action.run`.
51
51
  - Skip `onError` in an action. It is required. Always handle failure.
@@ -9,9 +9,10 @@
9
9
  | New site, new branded page, or "build me X from scratch" | `pulse_intake` → `pulse_sketch` → `pulse_intent` → build |
10
10
  | Editing an existing page, adding a section, fixing a bug | Go straight to Step 1 — Understand |
11
11
  | "Add X to the existing Y" (one-liner) | Read the relevant file, make the change, run `/verify` |
12
+ | Iterating on design before user approves | `/verify --quick` between rounds; full `/verify` once approved |
12
13
  | Stuck on which component to use | `pulse_intent("describe what I'm building")` |
13
14
  | Colours failing contrast / unsure about palette | `pulse_check_contrast` |
14
- | Something looks wrong in the browser | `pulse_restart_server`, then `/verify` |
15
+ | Something looks wrong in the browser | `pulse_restart_server`, then `/verify --quick` |
15
16
  | Lighthouse < 100 | Fix the flagged audit, run `/verify` again — do not skip to commit |
16
17
 
17
18
  ---
@@ -165,6 +166,20 @@ If you're about to type `class="hero"` or `class="product-card"` in Mode A, stop
165
166
  **Mode B — Creative override (raw HTML throughout)**
166
167
  When the design intent calls for a more expressive, unconventional, or typographically-driven layout that components would constrain, you may build with raw HTML throughout. This is a deliberate design decision, not a shortcut.
167
168
 
169
+ **When to choose Mode B — use the component, unless:**
170
+ - The layout requires **full-viewport height or edge-to-edge bleed** that the component doesn't support (e.g. `hero()` doesn't fill the viewport)
171
+ - The design uses **custom gradient glows, parallax, or large-scale image fills** that the component would wrap in unwanted structure
172
+ - The vibe is **brutalist, retro, neon, paper, or typographic-editorial** and the structural feel would be actively fought by the component's default padding/spacing/radius
173
+ - The typography is **clamp-scaled display type** (e.g. `clamp(4rem, 10vw, 12rem)`) that breaks inside a component's internal box model
174
+ - The layout is **asymmetric or zone-based** (e.g. 70/30 split, overlapping elements, staggered grid) that no single component supports
175
+ - A **Figma/reference design was provided** that clearly departs from the component's output
176
+
177
+ **When to use the component (not a valid reason for override):**
178
+ - "The component doesn't have a perfect prop for this" — add a utility class or wrapper div instead
179
+ - "I want a slightly different colour or size" — override with CSS tokens
180
+ - "I'm not sure what the component looks like" — check the guide first
181
+ - "It's easier to write raw HTML" — this is never a valid reason
182
+
168
183
  Rules for Mode B:
169
184
  - **Declare it in a comment at the top of the spec file:** `// component-free — creative override: <reason>`. The review tool detects creative override by reading this comment from the source — without it, `pulse_review` will flag every component pattern as a violation even though you chose Mode B deliberately. Announcing the mode in chat is not enough; the comment must be in the file.
170
185
  - Load `/pulse-ui.css` in `meta.styles` — the token system is still required
@@ -206,9 +221,11 @@ After the Write tool completes, call `pulse_create_page(name)` to register the p
206
221
  ✓ src/pages/my-page.js written and registered.
207
222
  ```
208
223
 
209
- ### 3d — Suggest (optional but recommended)
224
+ ### 3d — Suggest (recommended mid-build checkpoint)
225
+
226
+ After writing the first draft, call `pulse_suggest(content)` before running the hard validator. It catches obvious omissions collaboratively — a second opinion, not a gate. Then call `pulse_review(file, { quick: true })` for a lightweight structural pass (main landmark, data-event on inputs, missing onError, hex in view). Neither is a substitute for the full review, but catching issues here is faster than discovering them at Lighthouse.
210
227
 
211
- After writing the first draft, call `pulse_suggest(content)` before running the hard validator. It catches obvious omissions collaboratively — a second opinion, not a gate.
228
+ If the user is iterating on the design (multiple rounds of changes before giving a final go-ahead), use `/verify --quick` between rounds — it validates, screenshots, checks console, and runs a quick code review without the 90-second Lighthouse cost. Save the full `/verify` for after the user explicitly approves the design.
212
229
 
213
230
  **Rule: never output `✓` before the tool call that confirms the result.**
214
231
 
@@ -264,7 +281,7 @@ Do this before every screenshot, console check, or visual inspection. Never debu
264
281
 
265
282
  ### Phase 5b — Full verification (pass gate)
266
283
 
267
- **Run `/verify` now.** Do not execute these steps manually.
284
+ **Run `/verify` now** (full mode — not `--quick`). Do not execute these steps manually.
268
285
 
269
286
  `/verify` validates, screenshots, runs Lighthouse (desktop + mobile), runs the performance trace (LCP + CLS), checks console errors, runs `pulse_review`, and writes the `.pulse-verified` stamp. Running steps manually does not write the stamp.
270
287