@invisibleloop/pulse 0.16.0 → 0.17.0

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.
@@ -50,7 +50,7 @@ Check for:
50
50
  - Layout looks correct
51
51
  - No console errors via `mcp__chrome-devtools__list_console_messages`
52
52
 
53
- Then use `ask_user` to ask: "Happy with the design and layout, or would you like any changes before I run tests and Lighthouse?" with choices: `["Yes, looks good — run tests and Lighthouse", "I'd like some changes first"]`
53
+ Then ask the user: "Happy with the design and layout, or would you like any changes before I run tests and Lighthouse?" — if your host provides a question tool (e.g. AskUserQuestion), offer the choices `["Yes, looks good — run tests and Lighthouse", "I'd like some changes first"]`; otherwise ask in plain prose.
54
54
 
55
55
  **Do not proceed to step 7 until the user explicitly confirms.** If they want changes, make them and return to step 6.
56
56
 
@@ -15,7 +15,7 @@ If $ARGUMENTS is provided, use it as the file path or route. Otherwise, identify
15
15
  If this is a new page build (came through `pulse_intake` or `build-page`):
16
16
 
17
17
  1. Take a screenshot and describe what you see to the user.
18
- 2. Use `ask_user` to ask: "Happy with the design and layout, or would you like any changes before I run Lighthouse?" — provide choices: `["Yes, looks good — proceed", "I'd like some changes first"]`
18
+ 2. Ask the user: "Happy with the design and layout, or would you like any changes before I run Lighthouse?" — if your host provides a question tool (e.g. AskUserQuestion), offer the choices `["Yes, looks good — proceed", "I'd like some changes first"]`; otherwise ask in plain prose.
19
19
  3. **Do not proceed to step 3 until the user explicitly confirms.** If they want changes, stop here, make the changes, then restart from step 2. Lighthouse takes ~90 seconds — do not waste it on a design the user hasn't approved.
20
20
 
21
21
  ### 3. Validate the spec
@@ -22,10 +22,10 @@ Before finishing any spec, verify every point below. Fix anything that fails.
22
22
 
23
23
  - **Always restart the server and reload the browser after every file edit — before checking the result.** The dev server restarts on file changes but the browser tab stays stale. The required sequence is: `pulse_restart_server` → `navigate_page`. Never attempt to debug a visual problem without doing both steps first.
24
24
 
25
- ### Components first
25
+ ### Components first — or creative override
26
26
 
27
27
  - **Before writing any HTML by hand, check `src/ui/index.js`.** There are 50+ components. Use `button`, `card`, `alert`, `input`, `spinner`, `badge`, `modal`, `nav`, `pagination`, `table`, etc. before writing equivalent HTML from scratch.
28
- - **Do not reinvent component patterns.** These patterns have dedicated components — never write custom HTML for them:
28
+ - **Do not reinvent component patterns.** These patterns have dedicated components — never write custom HTML for them unless a creative override applies (see below):
29
29
  - Hero sections → `hero({ title, subtitle, actions, image, ... })`
30
30
  - Product/service cards → `card({ title, body, image, footer, ... })`
31
31
  - Image + text two-column layouts → `media({ image, content, reverse })`
@@ -35,10 +35,16 @@ Before finishing any spec, verify every point below. Fix anything that fails.
35
35
  - CTAs → `cta({ title, subtitle, actions })`
36
36
  - Navigation → `nav({ logo, links, actions })`
37
37
  - Footers → `footer({ logo, links, columns, legal, ... })`
38
-
39
- If you find yourself writing `class="hero"` or `class="product-card"` or `class="testimonial"`, stop — import the component instead.
40
38
 
41
- - **Exception — brutalist, editorial, and playful vibes:** these vibes intentionally break from polished templates. `pulse://guide/explore` explicitly endorses zone-based raw HTML for distinctive layouts. For `brutalist`, `editorial`, `retro`, or `neon` vibes, raw structural HTML is correct — components can still be used for functional atoms (`button`, `input`, `modal`) but structural sections do not need to come from components. When reviewing a brutalist or editorial spec, do not flag raw section HTML as a violation.
39
+ - **Creative override — raw HTML throughout is permitted** when the design intent genuinely calls for it: a highly custom visual identity, an asymmetric or typographically-driven layout, a brutalist/editorial/retro/neon/paper vibe where components would constrain the expression, or a layout that requires features a component simply doesn't support (e.g. full-viewport height, custom gradient glows, clamp-scaled display type). This is a deliberate design decision, not a shortcut. When taking this path:
40
+ - **Declare it in a comment at the top of the spec** so the reviewer recognises it: `// component-free — creative override: <reason>`. This is the signal the review tool reads.
41
+ - Functional atoms (`button`, `input`, `badge`, `modal`) should still come from components unless there is a specific design reason not to.
42
+ - `/pulse-ui.css` is always required — it provides the token system your custom CSS will use.
43
+ - The quality gate replaces the component checklist: **Lighthouse 100 on Accessibility, Best Practices, and SEO — desktop and mobile — plus CLS 0.00 is the pass bar.** (Performance is reported but not gated; it varies with machine load.) A component-free page that passes every audit is correct. A component-heavy page that fails accessibility is not.
44
+
45
+ - **When reviewing**: before flagging a class name like `.hero` as a violation, first ask whether the `hero()` component could actually reproduce the layout. If the answer is no, it is a creative override — check Lighthouse instead of refactoring. Do not flatten a good custom layout into a generic component just to clear a pattern-match.
46
+
47
+ - **The review tool auto-detects creative override** from the spec comment. When detected, component pattern checks are shown as advisory (`⚡`) not as failures, and the Lighthouse gate is the stated pass bar.
42
48
 
43
49
  - **Never write App Store or Google Play download buttons by hand.** Always use `appBadge({ store: 'apple', href })` and `appBadge({ store: 'google', href })`. Raw `<a>` tags with badge images are incorrect — `appBadge` renders the correct accessible, styled badge for each store.
44
50
 
@@ -132,12 +138,6 @@ Before finishing any spec, verify every point below. Fix anything that fails.
132
138
  - **Call the same `md()` fetcher in both `meta` and `server`** — it caches on `ctx._mdCache` so the file is only read once per request. Do not create two separate fetchers for the same file.
133
139
  - **Always add `onViewError` on dynamic markdown routes** (routes with `:param` segments loading `.md` files). A missing file throws `{ status: 404 }` which must be caught gracefully.
134
140
 
135
- ### Markdown
136
-
137
- - **Use `md()` from `@invisibleloop/pulse/md` for `.md` file content.** Never read and parse markdown manually. Pass the result's `html` to `prose()`.
138
- - **Call the same `md()` fetcher in both `meta` and `server`** — it caches on `ctx._mdCache` so the file is only read once per request. Do not create two separate fetchers for the same file.
139
- - **Always add `onViewError` on dynamic markdown routes** (routes with `:param` segments loading `.md` files). A missing file throws `{ status: 404 }` which must be caught gracefully.
140
-
141
141
  ### View error handling
142
142
 
143
143
  - **Define `onViewError` on any page where the view could throw due to bad or missing data.** Without it, a runtime view error returns a 500 on the server and shows a generic inline message on the client. With it, the server returns 200 with your fallback HTML instead of a 500, and the client renders your fallback:
package/CLAUDE.md CHANGED
@@ -5,7 +5,7 @@ Pulse is a spec-first frontend framework. The spec is the source of truth. No co
5
5
  ## Commands
6
6
 
7
7
  ```bash
8
- npm run dev # start dev server at http://localhost:3001
8
+ npm run dev # start dev server at http://localhost:3000 (pulse_build serves production on 3001)
9
9
  npm test # run all 92 unit tests
10
10
  npm run build # bundle for production → public/dist/
11
11
  ```
@@ -57,6 +57,12 @@ export const mySpec = {
57
57
  // Timeout for all server fetchers on this page (ms). Overrides createServer fetcherTimeout.
58
58
  serverTimeout: 5000,
59
59
 
60
+ // Query-string keys that change this page's output, for the in-process page cache.
61
+ // The cache IGNORES the query string by default — arbitrary ?n=… params cannot mint
62
+ // unbounded cache entries (memory-DoS guard). If the rendered output depends on a
63
+ // query param, list it here so distinct values get distinct cache entries.
64
+ cacheVary: ['q', 'page'],
65
+
60
66
  // Global store keys this page subscribes to — appears in view's server arg
61
67
  // Store mutations update all subscribed pages without a server round-trip
62
68
  store: ['user', 'cart'],
@@ -458,7 +464,7 @@ For any **new page, landing page, or branded site**, run this sequence first. Do
458
464
  3. pulse_intent(description) → archetype + scaffold + guide list
459
465
  ```
460
466
 
461
- **Before calling `pulse_intake`**, gather the required information by asking the user — one question at a time, in plain prose, no bullet lists. You need at minimum: the product name, what it does (pitch), and its key features. Ask for inspiration references, palette, vibe, and anti-style if the user hasn't mentioned them.
467
+ **Before calling `pulse_intake`**, gather the required information by asking the user — one question at a time, in plain prose, no bullet lists. You need at minimum: the product name, what it does (pitch), and its key features. **Always ask first whether they have design inspiration** (a site they love, a screenshot, a mood board — dropped into `public/intake/` or pasted as a URL), and check `public/intake/` for images already there. Then ask for palette, vibe, and anti-style if not mentioned.
462
468
 
463
469
  **`pulse_sketch` is mandatory** for `playful`, `bold`, `brutalist`, `retro`, or `neon` vibes — those vibes fight the default centred-hero layout. Skip it only for `corporate` or `minimal` when no structural preference has been stated.
464
470
 
@@ -470,13 +476,16 @@ Every build task follows this sequence. Each phase has a pass gate — do not ad
470
476
 
471
477
  **Before calling any slow tool (`pulse_build`, `lighthouse_audit`), output a status message to the user first** — e.g. "Building for production — ~30 s…" or "Running Lighthouse desktop audit…". Never call a slow tool silently.
472
478
 
479
+ > **Canonical workflow:** `pulse://workflow` (`src/agent/workflow.md`) is the authoritative version, including complexity tiers (not every task needs all phases). The table below is the summary — if they ever disagree, the MCP resource wins.
480
+
473
481
  | Phase | Action | Gate |
474
482
  |---|---|---|
475
483
  | 1. Understand | Read guides, call `pulse_list_structure` | — |
476
484
  | 2. Plan | Present plan to user, wait for confirmation | User confirms |
477
485
  | 3. Build | Write spec + related files | — |
478
486
  | 4. Validate | `pulse_validate` — fix all errors + warnings | Clean output |
479
- | 5. Browser | Screenshot → `pulse_design_review` (if intake ran) → `pulse_layout_review` → `/verify` (Lighthouse desktop + mobile + `pulse_review`) | ⛔ Do not skip: all gates must pass + `.pulse-verified` stamp written |
487
+ | 5a. Design approval *(new builds only)* | Screenshot → describe → ask the user | ⛔ User approves layout before any slow checks run (skip for edits/fixes) |
488
+ | 5b. Browser | `pulse_design_review` (if intake ran) → `pulse_layout_review` → `/verify` (Lighthouse desktop + mobile + `pulse_review`) | ⛔ Do not skip: all gates must pass + `.pulse-verified` stamp written |
480
489
  | 6. Tests | Write tests, run them, fix failures | All pass |
481
490
  | 7. Code Review | `pulse_review` — only after phases 4–6 pass | — |
482
491
  | 8. Fix | Fix every review issue, re-run affected gates | All gates still pass |
@@ -487,7 +496,7 @@ Every build task follows this sequence. Each phase has a pass gate — do not ad
487
496
 
488
497
  **The Code Review is always last.** Never invoke `pulse_review` before validate, Lighthouse, design review, and tests all pass.
489
498
 
490
- The `/verify` command runs the browser check loop (phases 4–5) automatically. Use it.
499
+ The `/verify` command runs the browser check loop (phases 4–5b) automatically. Use it.
491
500
 
492
501
  ## Pulse vs React — Do Not Do These
493
502
 
@@ -40,7 +40,7 @@ Before finishing any spec, verify every point below. Fix anything that fails.
40
40
  - **Declare it in a comment at the top of the spec** so the reviewer recognises it: `// component-free — creative override: <reason>`. This is the signal the review tool reads.
41
41
  - Functional atoms (`button`, `input`, `badge`, `modal`) should still come from components unless there is a specific design reason not to.
42
42
  - `/pulse-ui.css` is always required — it provides the token system your custom CSS will use.
43
- - The quality gate replaces the component checklist: **100/100/100/100 Lighthouse on both desktop and mobile is the pass bar.** A component-free page that passes every audit is correct. A component-heavy page that fails accessibility is not.
43
+ - The quality gate replaces the component checklist: **Lighthouse 100 on Accessibility, Best Practices, and SEO — desktop and mobile — plus CLS 0.00 is the pass bar.** (Performance is reported but not gated; it varies with machine load.) A component-free page that passes every audit is correct. A component-heavy page that fails accessibility is not.
44
44
 
45
45
  - **When reviewing**: before flagging a class name like `.hero` as a violation, first ask whether the `hero()` component could actually reproduce the layout. If the answer is no, it is a creative override — check Lighthouse instead of refactoring. Do not flatten a good custom layout into a generic component just to clear a pattern-match.
46
46
 
@@ -36,13 +36,15 @@ Before finishing any spec, verify every point below. Fix anything that fails.
36
36
  - Navigation → `nav({ logo, links, actions })`
37
37
  - Footers → `footer({ logo, links, columns, legal, ... })`
38
38
 
39
- - **Creative override — raw HTML throughout is permitted** when the design intent genuinely calls for it: a highly custom visual identity, an asymmetric or typographically-driven layout, a brutalist/editorial/retro/neon/paper vibe where components would constrain the expression. This is a deliberate design decision, not a shortcut. When taking this path:
40
- - State it explicitly in the build brief: *"Building component-free — raw HTML throughout for creative control."*
39
+ - **Creative override — raw HTML throughout is permitted** when the design intent genuinely calls for it: a highly custom visual identity, an asymmetric or typographically-driven layout, a brutalist/editorial/retro/neon/paper vibe where components would constrain the expression, or a layout that requires features a component simply doesn't support (e.g. full-viewport height, custom gradient glows, clamp-scaled display type). This is a deliberate design decision, not a shortcut. When taking this path:
40
+ - **Declare it in a comment at the top of the spec** so the reviewer recognises it: `// component-free — creative override: <reason>`. This is the signal the review tool reads.
41
41
  - Functional atoms (`button`, `input`, `badge`, `modal`) should still come from components unless there is a specific design reason not to.
42
42
  - `/pulse-ui.css` is always required — it provides the token system your custom CSS will use.
43
- - The quality gate replaces the component checklist: **100/100/100/100 Lighthouse on both desktop and mobile is the pass bar.** A component-free page that passes every audit is correct. A component-heavy page that fails accessibility is not.
43
+ - The quality gate replaces the component checklist: **Lighthouse 100 on Accessibility, Best Practices, and SEO — desktop and mobile — plus CLS 0.00 is the pass bar.** (Performance is reported but not gated; it varies with machine load.) A component-free page that passes every audit is correct. A component-heavy page that fails accessibility is not.
44
44
 
45
- - When reviewing a spec built under creative override, do not flag raw section HTML as a violation — check the Lighthouse results instead.
45
+ - **When reviewing**: before flagging a class name like `.hero` as a violation, first ask whether the `hero()` component could actually reproduce the layout. If the answer is no, it is a creative override — check Lighthouse instead of refactoring. Do not flatten a good custom layout into a generic component just to clear a pattern-match.
46
+
47
+ - **The review tool auto-detects creative override** from the spec comment. When detected, component pattern checks are shown as advisory (`⚡`) not as failures, and the Lighthouse gate is the stated pass bar.
46
48
 
47
49
  - **Never write App Store or Google Play download buttons by hand.** Always use `appBadge({ store: 'apple', href })` and `appBadge({ store: 'google', href })`. Raw `<a>` tags with badge images are incorrect — `appBadge` renders the correct accessible, styled badge for each store.
48
50
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@invisibleloop/pulse",
3
- "version": "0.16.0",
3
+ "version": "0.17.0",
4
4
  "type": "module",
5
5
  "description": "AI-first frontend framework. The spec is the source of truth.",
6
6
  "license": "MIT",
@@ -1 +1 @@
1
- 0.16.0
1
+ 0.17.0
@@ -40,7 +40,7 @@ Before finishing any spec, verify every point below. Fix anything that fails.
40
40
  - **Declare it in a comment at the top of the spec** so the reviewer recognises it: `// component-free — creative override: <reason>`. This is the signal the review tool reads.
41
41
  - Functional atoms (`button`, `input`, `badge`, `modal`) should still come from components unless there is a specific design reason not to.
42
42
  - `/pulse-ui.css` is always required — it provides the token system your custom CSS will use.
43
- - The quality gate replaces the component checklist: **100/100/100/100 Lighthouse on both desktop and mobile is the pass bar.** A component-free page that passes every audit is correct. A component-heavy page that fails accessibility is not.
43
+ - The quality gate replaces the component checklist: **Lighthouse 100 on Accessibility, Best Practices, and SEO — desktop and mobile — plus CLS 0.00 is the pass bar.** (Performance is reported but not gated; it varies with machine load.) A component-free page that passes every audit is correct. A component-heavy page that fails accessibility is not.
44
44
 
45
45
  - **When reviewing**: before flagging a class name like `.hero` as a violation, first ask whether the `hero()` component could actually reproduce the layout. If the answer is no, it is a creative override — check Lighthouse instead of refactoring. Do not flatten a good custom layout into a generic component just to clear a pattern-match.
46
46
 
@@ -8,73 +8,103 @@ Run the full verification loop on the current page or the page specified in $ARG
8
8
 
9
9
  If $ARGUMENTS is provided, use it as the file path or route. Otherwise, identify the most recently edited spec file from context.
10
10
 
11
- ### 2. Validate the spec
11
+ ### 2. Design approval (new builds only)
12
+
13
+ **Skip this step for edits, bug fixes, or "add X to existing Y" tasks.**
14
+
15
+ If this is a new page build (came through `pulse_intake` or `build-page`):
16
+
17
+ 1. Take a screenshot and describe what you see to the user.
18
+ 2. Ask the user: "Happy with the design and layout, or would you like any changes before I run Lighthouse?" — if your host provides a question tool (e.g. AskUserQuestion), offer the choices `["Yes, looks good — proceed", "I'd like some changes first"]`; otherwise ask in plain prose.
19
+ 3. **Do not proceed to step 3 until the user explicitly confirms.** If they want changes, stop here, make the changes, then restart from step 2. Lighthouse takes ~90 seconds — do not waste it on a design the user hasn't approved.
20
+
21
+ ### 3. Validate the spec
12
22
 
13
23
  Use `pulse_validate` with the spec file content. If validation fails, report every error clearly and stop — do not proceed to screenshot until the spec is valid.
14
24
 
15
- ### 3. Check the dev server
25
+ ### 4. Check the dev server
16
26
 
17
27
  Use `pulse_fetch_page` to confirm the server is responding for the route. If it errors, use `pulse_restart_server` and retry once.
18
28
 
19
- **If `pulse_fetch_page` returns unexpected HTML (e.g. a different project's page title, a Next.js 404, or any non-Pulse response):** check the `X-Pulse` response header — Pulse servers always send this. If it is absent, the wrong server is running on that port. Stop and inform the user before continuing.
20
-
21
- ### 4. Screenshot
29
+ ### 5. Screenshot
22
30
 
23
31
  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.
24
32
 
25
- ### 5. Lighthouse — desktop
33
+ ### 6. Lighthouse — desktop
34
+
35
+ **Pre-flight (required before every Lighthouse run):**
36
+ 1. Call `pulse_build` to produce a production build and start the production server on port 3001.
37
+ 2. Call `navigate_page` with `url: "http://localhost:3001/"` so the browser is on the production server.
38
+
39
+ Then run `mcp__chrome-devtools__lighthouse_audit` with `{ "device": "desktop" }`.
40
+
41
+ **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.
26
42
 
27
- Run `mcp__chrome-devtools__lighthouse_audit` on the route with `{ "strategy": "desktop" }`.
43
+ ### 7. Lighthouse — mobile
28
44
 
29
- **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 2.
45
+ The browser should still be on `http://localhost:3001/` from step 6. Run `mcp__chrome-devtools__lighthouse_audit` with `{ "device": "mobile" }`.
30
46
 
31
- ### 6. Lighthouse — mobile
47
+ **Same pass bar: Accessibility, Best Practices, and SEO must all be 100.** Fix any failures and restart from step 3.
32
48
 
33
- Run `mcp__chrome-devtools__lighthouse_audit` on the same route with `{ "strategy": "mobile" }`.
49
+ After both Lighthouse runs pass, call `pulse_restart_server` to return to the dev server.
34
50
 
35
- **Same pass bar: Accessibility, Best Practices, and SEO must all be 100.** Fix any failures and restart from step 2.
51
+ ### 8. Mobile layout check
36
52
 
37
- **If you need to visually inspect the layout at a mobile viewport width** (e.g. to debug a wrapping or overflow issue), use `mcp__chrome-devtools__emulate` with `viewport: "390x844x2,mobile,touch"` — not `resize_page`. The `emulate` tool reliably sets the viewport; `resize_page` does not reliably trigger responsive layout changes. Reset afterward with `emulate` using the desktop viewport: `"1440x900x1"`.
53
+ Emulate a mobile viewport and take a screenshot to catch wrapping, overflow, and layout issues before they become Lighthouse failures.
38
54
 
39
- ### 7. Performance
55
+ ```
56
+ mcp__chrome-devtools__emulate viewport: "390x844x2,mobile,touch"
57
+ mcp__chrome-devtools__navigate_page url: "http://localhost:3000/"
58
+ mcp__chrome-devtools__take_screenshot
59
+ ```
60
+
61
+ Describe what you see. Look specifically for:
62
+ - Text or buttons overflowing the viewport
63
+ - Content too small to read or tap
64
+ - Navigation that overlaps content
65
+ - Images that break the layout
66
+
67
+ Fix any issues, then reset to desktop before continuing:
68
+
69
+ ```
70
+ mcp__chrome-devtools__emulate viewport: "1440x900x1"
71
+ ```
72
+
73
+ ### 9. Performance
40
74
 
41
75
  Navigate to the page route, 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.).
42
76
 
43
- ### 8. Console errors
77
+ ### 10. Console errors
44
78
 
45
79
  Use `mcp__chrome-devtools__list_console_messages` — report any errors or unexpected warnings.
46
80
 
47
- ### 9. Code review
81
+ ### 11. Code review
48
82
 
49
- Call `pulse_review` with the absolute path to the spec file. Read the source, rendered HTML, validator output, and checklist it returns. Fix every issue before proceeding. This is the structured review — do not skip it or replace it with a manual checklist read.
83
+ Call `pulse_review` with the spec file path. This runs a full code review AND atomically writes the `.pulse-verified` stamp server-side, eliminating any race condition with the stop hook.
50
84
 
51
- ### 10. Close the browser
85
+ Work through every item the review returns. Fix anything that fails before proceeding.
52
86
 
53
- Use `mcp__chrome-devtools__list_pages` to get all open pages, then call `mcp__chrome-devtools__close_page` for every page ID returned. This closes the entire browser, not just the tab. `pageId` must be a number, not a string.
87
+ ### 12. Close extra browser tabs
54
88
 
55
- ### 11. Write verification stamp
89
+ Use `mcp__chrome-devtools__list_pages` to get all open pages. Close every page **except the last one** — `close_page` refuses to close the final tab. `pageId` must be a number, not a string.
90
+
91
+ If there is only one page open, skip this step — there is nothing to close.
92
+
93
+ ### 13. Write verification stamp
56
94
 
57
95
  Call `pulse_stamp`. This writes `.pulse-verified` via the MCP server, which is more reliable than the `date +%s` shell command — the MCP write always lands after all spec edits, avoiding the mtime race condition that can cause the stop hook to block immediately after verification.
58
96
 
59
97
  **This must be the last operation before stopping.** The stop hook compares each edited spec's mtime against this stamp — if any spec is newer than the stamp, the hook blocks. Do not edit any spec file after calling `pulse_stamp`.
60
98
 
61
- ### 12. Report
99
+ ### 14. Report
62
100
 
63
101
  Summarise:
64
102
  - Validation: pass or fail (with errors if any)
65
- - Visual: what was visible in the screenshot
66
- - Lighthouse desktop: scores for all four categories
67
- - Lighthouse mobile: scores for all four categories
103
+ - Visual: desktop screenshot and mobile layout check — any issues found and fixed
104
+ - Lighthouse desktop: scores for Accessibility, Best Practices, SEO
105
+ - Lighthouse mobile: scores for Accessibility, Best Practices, SEO
68
106
  - Performance: LCP and CLS values, any flagged insights
69
107
  - Console: any errors
70
108
  - Review: pass or issues found and fixed
71
109
 
72
- **Pass bar — all of these must be met before the page is confirmed good:**
73
- - Validation: clean (no errors, no warnings)
74
- - Lighthouse desktop: Accessibility, Best Practices, SEO all 100
75
- - Lighthouse mobile: same
76
- - CLS: 0.00 — any layout shift is a blocker
77
- - LCP: report the value; flag and fix any insight that identifies a fixable cause
78
- - Console: no errors
79
-
80
- If any gate fails, fix the issue and run `/verify` again.
110
+ 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.
@@ -15,7 +15,7 @@ Warm cream background, hand-crafted feel. Photo-forward hero with a split layout
15
15
  **Key components:** `hero({ layout:'split' })`, `section`, `container`, `grid`, `card` (with `footer`), `stat`, `testimonial`, `cta`, `badge`
16
16
 
17
17
  **Distinguishing moves:**
18
- - Gradient hero `linear-gradient(135deg, #fdf6ee, #fdebd0)` with `color:#3d2b1e`
18
+ - Gradient hero `linear-gradient(135deg, #fdf6ee, #fdebd0)` with `color:#3d2b1e` — define these hex values as tokens in `public/theme.css` and reference them via `var()`; the lint hook blocks raw hex in any other stylesheet
19
19
  - Nav with `background: rgba(255,250,245,0.92)` and matching text colour
20
20
  - Cards with price footer: `<div class="u-flex u-items-center u-justify-between"><span>From</span><strong>£35</strong></div>`
21
21
  - Credentials list: icon + span with `iconCheckCircle`
@@ -105,6 +105,8 @@ Long-form article layout with `prose` component for markdown rendering, breadcru
105
105
 
106
106
  ## Component Combinations by Use Case
107
107
 
108
+ > Inline `style=""` in these snippets is limited to structural image properties (`aspect-ratio`, `object-fit`, fixed heights) that have no utility-class equivalent — this is the permitted exception to the no-inline-style rule. Colours and theming still belong in tokens.
109
+
108
110
  ### Image Card (Card with Cover Photo)
109
111
  Card doesn't have a native image slot — use `flush:true` and put the image inside `content`:
110
112
 
@@ -218,16 +220,16 @@ section({
218
220
  ```
219
221
 
220
222
  ### Booking / Contact Form Inside a Card
223
+ Use the `input()` component for fields — it renders the label, id wiring, and required marker accessibly. Do not write raw `<input>`/`<label>` HTML:
224
+
221
225
  ```js
222
226
  card({
223
227
  title: 'Book an Appointment',
224
228
  content: `
225
229
  <p class="u-text-muted u-text-sm u-mb-4">Fill in the form and we'll confirm within a few hours.</p>
226
230
  <form class="u-flex u-flex-col u-gap-3" data-action="submit">
227
- <div class="u-flex u-flex-col u-gap-1">
228
- <label class="u-text-sm u-font-medium" for="name">Your name</label>
229
- <input id="name" name="name" type="text" class="u-input" required>
230
- </div>
231
+ ${input({ label: 'Your name', name: 'name', required: true })}
232
+ ${input({ label: 'Email', name: 'email', type: 'email', required: true })}
231
233
  ${button({ label: 'Send Enquiry', type: 'submit', variant: 'primary', fullWidth: true })}
232
234
  </form>
233
235
  `,
@@ -62,12 +62,12 @@ When you choose a design direction, tell the user which one you're using and why
62
62
  **Feels like:** Duolingo, Headspace, Loom, Figma landing page, pop-art toy brand
63
63
  **vibe:** `playful`
64
64
  **Geometry:** very rounded (18–24px), friendly icons, colourful
65
- **Typography:** rounded or friendly sans, loose spacing, emoji-friendly
65
+ **Typography:** rounded or friendly sans, loose spacing, friendly icon accents (icon library, never emoji)
66
66
  **Palette pattern:** bright bg (coral / mint / lavender) + dark text
67
67
  **Signature moves (use these, not 3-col feature grids):**
68
68
  - **Full-colour section blocks** — each section has its own bg colour (yellow stats strip, cyan testimonials, hot-pink CTA). Palette is structure, not accent.
69
69
  - **Oversized hero element** — the product's key visual (a game board, an illustration, an icon) fills most of the hero viewport. `phoneFrame` is one option, not the default.
70
- - **Marquee strip** — giant repeated glyphs, emoji, or product icons scrolling across the page between sections (`marquee` component)
70
+ - **Marquee strip** — giant repeated icon-library glyphs, brand marks, or product icons scrolling across the page between sections (`marquee` component). Use icons from `src/ui/icons.js`, not emoji — emoji are banned in UI output (not accessible, not theme-aware).
71
71
  - **Tilted `photoCard`s** — testimonial or feature cards at ±2–4° with `photoCard` or CSS `transform: rotate()`
72
72
  - **Memphis-style geometric shapes** between sections — use `decorate(pattern:'circles')` or CSS background shapes
73
73
  - **Social proof as ticker** — a `marquee` of short quotes or star ratings, not a 3-col grid
@@ -14,7 +14,7 @@ Every page you ship must meet all of the following. These are not aspirational
14
14
 
15
15
  **Lighthouse 100 on Accessibility, Best Practices, and SEO.** The \`lighthouse_audit\` tool returns these three scores — all must be 100. If any cannot hit 100, something is wrong with the spec. (Performance is not part of the audit tool and is not required.) The audit is slow — ~30–60 s per run. Always tell the user before calling it.
16
16
 
17
- **Polished and considered.** Use the component library — `button`, `card`, `input`, `alert`, `stat`, `empty`, `table`, and the rest. Use the spacing scale (`u-mt-*`, `u-gap-*`, `u-py-*`). Use the type scale (`u-text-xl`, `u-font-semibold`). Use the colour tokens (`var(--ui-accent)`, `var(--ui-muted)`). Do not leave raw unstyled HTML. Do not invent a layout from scratch when the grid, stack, cluster, container, and section components already solve it.
17
+ **Polished and considered.** Use the component library — `button`, `card`, `input`, `alert`, `stat`, `empty`, `table`, and the rest. Use the spacing scale (`u-mt-*`, `u-gap-*`, `u-py-*`). Use the type scale (`u-text-xl`, `u-font-semibold`). Use the colour tokens (`var(--ui-accent)`, `var(--ui-muted)`). Do not leave raw unstyled HTML. Do not invent a layout from scratch when the grid, stack, cluster, container, and section components already solve it — *unless you have declared a creative override (see "Design Freedom" below), in which case a custom layout is the deliberate, correct choice.*
18
18
 
19
19
  **Works correctly on first load.** SSR is always on. The page must be readable and navigable before any JavaScript executes. Do not build pages that require client JS to render content.
20
20
 
@@ -22,15 +22,30 @@ Every page you ship must meet all of the following. These are not aspirational
22
22
 
23
23
  **Accessible form labels.** Every `input`, `select`, and `textarea` has a label. Every form has a submit button. Required fields are marked required. Error messages are associated with the field that caused them.
24
24
 
25
+ # Design Freedom — The Rule
26
+
27
+ There are two valid build modes. Knowing which one you are in tells you exactly which rules apply.
28
+
29
+ **Mode A — components first (the default).** All standard UI patterns come from the component library. The component rules in "What You Will Not Do" below apply in full. This is the right mode for most pages.
30
+
31
+ **Mode B — creative override.** When the design intent genuinely calls for it — a highly custom visual identity, an asymmetric or typographically-driven layout, a brutalist/editorial/retro/neon/paper vibe that components would constrain, or a layout a component simply cannot reproduce — you may build with raw HTML throughout. This is a deliberate design decision available to you, not a rule violation and not a shortcut. To use it:
32
+
33
+ 1. **Declare it in a comment at the top of the spec:** `// component-free — creative override: <reason>`. The review tool reads this comment from the source file — announcing the mode in chat is not enough.
34
+ 2. Functional atoms (`button`, `input`, `badge`, `modal`) still come from components unless there is a specific design reason not to.
35
+ 3. `/pulse-ui.css` stays in `meta.styles` — your custom CSS builds on its token system.
36
+ 4. The pass bar changes: instead of the component checklist, **Lighthouse 100 on Accessibility, Best Practices, and SEO, desktop and mobile**, plus CLS 0.00. A component-free page that passes every audit is correct. A component-heavy page that fails accessibility is not.
37
+
38
+ The component rules marked *(Mode A)* below are suspended under a declared creative override. Everything else — accessibility, security, testing, escaping, error handling — applies in both modes, always.
39
+
25
40
  # What You Will Not Do
26
41
 
27
42
  - Install client-side JS dependencies. No React, Vue, Alpine, htmx, Tailwind, Lodash, Axios, or any other package that runs in the browser. Pulse handles rendering, state, actions, navigation, and SSR. If you need a utility, write it.
28
43
  - Use emoji characters in UI output. Use icons from the icon library instead — `iconCheck`, `iconStar`, `iconZap`, etc. If the right icon does not exist, create it in `src/ui/icons.js` following the existing pattern (see the guide for how). Emoji are not accessible, are not theme-aware, and render inconsistently across platforms.
29
- - Hardcode hex colours in CSS. Use `var(--ui-*)` tokens. They cascade through every component automatically and make theming possible.
30
- - Use inline `style=""` attributes. Use utility classes or `var(--ui-*)` tokens in a stylesheet.
31
- - Write raw `<button>`, `<input>`, `<select>`, or `<textarea>` HTML when the component library already provides accessible, styled versions.
32
- - Write raw `<h1>`–`<h6>` without styling. Use `heading({ level, text })` instead.
33
- - Write raw `<ul>` or `<ol>` without styling. Use `list({ items })` instead.
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
+ - 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
+ - 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)*
34
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 })`.
35
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`.
36
51
  - Skip `onError` in an action. It is required. Always handle failure.
@@ -73,7 +73,7 @@ Decide the complexity tier (see below) and confirm with the user if the task is
73
73
 
74
74
  **Skip confirmation for trivially small tasks** (add a button, fix a label, swap a component). When in doubt, confirm.
75
75
 
76
- **When asking the user questions:** ask one question at a time. If you present choices, use at most 4 options — the `ask_user` tool enforces this limit and will error if exceeded. For open-ended questions (names, copy, features, hex colours), ask as plain prose with no choices at all.
76
+ **When asking the user questions:** ask one question at a time. If you present choices, use at most 4 options — question tools in agent hosts (e.g. Claude Code's AskUserQuestion) cap the number of choices and will error if exceeded. For open-ended questions (names, copy, features, hex colours), ask as plain prose with no choices at all.
77
77
 
78
78
  ---
79
79
 
@@ -161,11 +161,12 @@ If you're about to type `class="hero"` or `class="product-card"` in Mode A, stop
161
161
  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.
162
162
 
163
163
  Rules for Mode B:
164
+ - **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.
164
165
  - Load `/pulse-ui.css` in `meta.styles` — the token system is still required
165
166
  - Functional atoms (`button`, `input`, `badge`, `modal`) should still come from components unless there is a specific design reason
166
- - The quality gate is 100/100/100/100 Lighthouse on both desktop and mobile — this replaces the component checklist as the pass bar
167
+ - The quality gate is Lighthouse 100 on Accessibility, Best Practices, and SEO (desktop and mobile) plus CLS 0.00 — this replaces the component checklist as the pass bar. Performance is reported but not gated.
167
168
 
168
- Announce Mode B explicitly in the build brief: *"Mode B — component-free, raw HTML for creative control."*
169
+ Announce Mode B explicitly in the build brief (*"Mode B — component-free, raw HTML for creative control"*) **and** write the override comment into the spec.
169
170
 
170
171
  ### 3b — Announce before writing
171
172
 
package/src/html.js CHANGED
@@ -21,4 +21,5 @@ export function escHtml(str) {
21
21
  .replace(/</g, '&lt;')
22
22
  .replace(/>/g, '&gt;')
23
23
  .replace(/"/g, '&quot;')
24
+ .replace(/'/g, '&#39;')
24
25
  }
@@ -102,6 +102,117 @@ test('no orphaned guide.md (monolithic legacy file should not exist)', () => {
102
102
  )
103
103
  })
104
104
 
105
+ // ── Duplicated doc sync ──────────────────────────────────────────────────────
106
+ // Several agent docs exist in two places (a canonical source plus a copy that is
107
+ // scaffolded into consumer projects or kept in this repo's own .claude/). These
108
+ // pairs must stay byte-identical or the two audiences receive drifting rules.
109
+
110
+ console.log('\nDuplicated doc sync\n')
111
+
112
+ const SYNC_PAIRS = [
113
+ ['src/agent/checklist.md', '.claude/pulse-checklist.md'],
114
+ ['src/agent/checklist.md', 'docs/.claude/pulse-checklist.md'],
115
+ ['src/agent/checklist.md', 'examples/.claude/pulse-checklist.md'],
116
+ ['src/agent/commands/verify.md', '.claude/commands/verify.md'],
117
+ ]
118
+
119
+ for (const [a, b] of SYNC_PAIRS) {
120
+ test(`${a} is in sync with ${b}`, () => {
121
+ const fa = path.join(ROOT, a)
122
+ const fb = path.join(ROOT, b)
123
+ assert(fs.existsSync(fa), `${a} does not exist`)
124
+ assert(fs.existsSync(fb), `${b} does not exist`)
125
+ assert(
126
+ fs.readFileSync(fa, 'utf8') === fs.readFileSync(fb, 'utf8'),
127
+ `${a} and ${b} have drifted apart. Edit the canonical file and copy it over the other ` +
128
+ `(cp ${a} ${b} or vice versa) so both audiences get the same rules.`
129
+ )
130
+ })
131
+ }
132
+
133
+ // ── Agent doc consistency guards ─────────────────────────────────────────────
134
+ // Contradictions between agent docs leave the agent unable to know what it can
135
+ // and cannot do. These guards pin the resolved decisions:
136
+ // - The Lighthouse pass bar is THREE gated scores (Accessibility, Best
137
+ // Practices, SEO) — Performance is reported, never gated. "100/100/100/100"
138
+ // reintroduces a four-score bar the audit tooling cannot verify.
139
+ // - "ask_user" is not a real tool in any host — docs must not name it.
140
+ // - Emoji are banned in UI output — design guides must not suggest them.
141
+
142
+ console.log('\nAgent doc consistency\n')
143
+
144
+ const agentDocs = [
145
+ ...fs.readdirSync(agentDir).filter(f => f.endsWith('.md')).map(f => path.join('src/agent', f)),
146
+ ...fs.readdirSync(path.join(agentDir, 'commands')).filter(f => f.endsWith('.md')).map(f => path.join('src/agent/commands', f)),
147
+ ...fs.readdirSync(path.join(agentDir, 'skills')).flatMap(d => {
148
+ const p = path.join(agentDir, 'skills', d, 'SKILL.md')
149
+ return fs.existsSync(p) ? [path.join('src/agent/skills', d, 'SKILL.md')] : []
150
+ }),
151
+ '.claude/commands/build-page.md',
152
+ '.claude/commands/verify.md',
153
+ '.claude/commands/new-doc-page.md',
154
+ 'CLAUDE.md',
155
+ ]
156
+
157
+ test('no agent doc states a four-score Lighthouse bar (100/100/100/100)', () => {
158
+ const offenders = agentDocs.filter(f =>
159
+ fs.existsSync(path.join(ROOT, f)) &&
160
+ fs.readFileSync(path.join(ROOT, f), 'utf8').includes('100/100/100/100')
161
+ )
162
+ if (serverSrc.includes('100/100/100/100')) offenders.push('src/mcp/server.js')
163
+ assert(
164
+ offenders.length === 0,
165
+ `Four-score Lighthouse bar found in: ${offenders.join(', ')}. ` +
166
+ `The gated scores are Accessibility, Best Practices, and SEO (three) — Performance is reported, not gated.`
167
+ )
168
+ })
169
+
170
+ test('no agent doc references a literal ask_user tool', () => {
171
+ const offenders = agentDocs.filter(f =>
172
+ fs.existsSync(path.join(ROOT, f)) &&
173
+ /\bask_user\b/.test(fs.readFileSync(path.join(ROOT, f), 'utf8'))
174
+ )
175
+ assert(
176
+ offenders.length === 0,
177
+ `"ask_user" referenced in: ${offenders.join(', ')}. ` +
178
+ `No host exposes a tool by that name — phrase host-agnostically (e.g. "your host's question tool").`
179
+ )
180
+ })
181
+
182
+ test('design guides do not suggest emoji in UI output', () => {
183
+ const designGuides = ['src/agent/guide-design-references.md', 'src/agent/guide-design-gallery.md']
184
+ const offenders = designGuides.filter(f => {
185
+ const src = fs.readFileSync(path.join(ROOT, f), 'utf8')
186
+ // Allow lines that mention emoji only to ban them; flag lines that suggest using them
187
+ return src.split('\n').some(line =>
188
+ /emoji/i.test(line) && !/never|not |banned|instead of emoji/i.test(line)
189
+ )
190
+ })
191
+ assert(
192
+ offenders.length === 0,
193
+ `Emoji suggested (without a ban qualifier) in: ${offenders.join(', ')}. ` +
194
+ `Emoji are banned in UI output (identity.md) — suggest icon-library glyphs instead.`
195
+ )
196
+ })
197
+
198
+ test('workflow.md Mode B instructs writing the creative-override spec comment', () => {
199
+ const workflow = fs.readFileSync(path.join(agentDir, 'workflow.md'), 'utf8')
200
+ assert(
201
+ /component.free — creative override/.test(workflow),
202
+ 'workflow.md Mode B must instruct writing the `// component-free — creative override: <reason>` ' +
203
+ 'comment into the spec — pulse_review detects creative override by reading the source file.'
204
+ )
205
+ })
206
+
207
+ test('identity.md declares the creative override (Design Freedom) carve-out', () => {
208
+ const identity = fs.readFileSync(path.join(agentDir, 'identity.md'), 'utf8')
209
+ assert(
210
+ /creative override/i.test(identity),
211
+ 'identity.md must include the creative-override carve-out — without it the persona ' +
212
+ 'unconditionally bans raw HTML that workflow.md Mode B explicitly permits.'
213
+ )
214
+ })
215
+
105
216
  // ── Result ───────────────────────────────────────────────────────────────────
106
217
 
107
218
  console.log(`\n${passed} passed, ${failed} failed\n`)
package/src/mcp/server.js CHANGED
@@ -1081,7 +1081,7 @@ ${(() => {
1081
1081
  // Creative override detection — check if spec declares component-free mode
1082
1082
  const creativeOverride = /component.free|creative\s+override|raw\s+HTML\s+throughout/i.test(source)
1083
1083
  checks.push(creativeOverride
1084
- ? '⚡ **Creative override declared** — component pattern checks are advisory only. Lighthouse 100/100/100/100 is the pass bar.'
1084
+ ? '⚡ **Creative override declared** — component pattern checks are advisory only. Lighthouse 100 on Accessibility, Best Practices, and SEO (desktop + mobile) plus CLS 0.00 is the pass bar.'
1085
1085
  : '○ No creative override declared — component checks apply')
1086
1086
 
1087
1087
  return checks.join('\n')
@@ -1122,7 +1122,7 @@ Work through every item. Fix anything that fails. Refer to the spec source at ${
1122
1122
 
1123
1123
  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.
1124
1124
 
1125
- - [ ] **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.
1125
+ - [ ] **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 on Accessibility, Best Practices, and SEO (desktop and mobile) plus CLS 0.00 before closing the review.
1126
1126
  - [ ] No \`data-event\` on text inputs — this destroys focus on every keystroke
1127
1127
  - [ ] No \`className\`, \`htmlFor\`, \`onClick=\`, or other React patterns
1128
1128
  - [ ] No hardcoded hex colours in view — only \`var(--ui-*)\` tokens (anchor \`href="#id"\` values are fine — those are not colours)
package/src/md/index.js CHANGED
@@ -25,6 +25,33 @@ function esc(s) {
25
25
  .replace(/"/g, '&quot;')
26
26
  }
27
27
 
28
+ /**
29
+ * Sanitise a markdown link/image URL.
30
+ *
31
+ * Allows http(s), mailto, tel, relative paths, fragment/query refs, and
32
+ * image data: URIs. Any other scheme — notably javascript:, vbscript:, and
33
+ * non-image data: URIs — is neutralised to '#', preventing XSS when markdown
34
+ * comes from an untrusted source (database, user submission, external API).
35
+ *
36
+ * The value has already passed through esc(), so it is safe to embed in an
37
+ * attribute; this guard only blocks dangerous URL schemes.
38
+ *
39
+ * @param {string} url
40
+ * @param {boolean} [isImage=false] - permit image data: URIs when true
41
+ * @returns {string}
42
+ */
43
+ function safeUrl(url, isImage = false) {
44
+ // Strip leading control chars/whitespace that browsers ignore when parsing
45
+ // the scheme (e.g. "java\tscript:"), then test for a scheme prefix.
46
+ const stripped = url.replace(/[\x00-\x20]+/g, '')
47
+ const scheme = stripped.match(/^([a-zA-Z][a-zA-Z0-9+.\-]*):/)
48
+ if (!scheme) return url // no scheme → relative / fragment / query, safe
49
+ const proto = scheme[1].toLowerCase()
50
+ if (proto === 'http' || proto === 'https' || proto === 'mailto' || proto === 'tel') return url
51
+ if (isImage && /^data:image\/(png|jpe?g|gif|webp|avif|svg\+xml);/i.test(stripped)) return url
52
+ return '#'
53
+ }
54
+
28
55
  // ---------------------------------------------------------------------------
29
56
  // Frontmatter — --- key: value --- block at top of file
30
57
  // ---------------------------------------------------------------------------
@@ -69,12 +96,12 @@ function renderInline(raw) {
69
96
  // 3. Images (before links — same syntax with leading !)
70
97
  s = s.replace(/!\[([^\]]*)\]\(([^)\s"]+)(?:\s+"([^"]*)")?\)/g,
71
98
  (_, alt, src, title) =>
72
- `<img src="${src}" alt="${alt}"${title ? ` title="${title}"` : ''}>`)
99
+ `<img src="${safeUrl(src, true)}" alt="${alt}"${title ? ` title="${title}"` : ''}>`)
73
100
 
74
101
  // 4. Links
75
102
  s = s.replace(/\[([^\]]+)\]\(([^)\s"]+)(?:\s+"([^"]*)")?\)/g,
76
103
  (_, label, href, title) =>
77
- `<a href="${href}"${title ? ` title="${title}"` : ''}>${label}</a>`)
104
+ `<a href="${safeUrl(href)}"${title ? ` title="${title}"` : ''}>${label}</a>`)
78
105
 
79
106
  // 5. Bold + italic (order: *** before ** before *)
80
107
  s = s.replace(/\*{3}([^*\n]+)\*{3}/g, (_, t) => `<strong><em>${t}</em></strong>`)
package/src/md/md.test.js CHANGED
@@ -175,6 +175,42 @@ test('HTML is escaped in inline text', () => {
175
175
  assert(html.includes('&lt;script&gt;'), `Got: ${html}`)
176
176
  })
177
177
 
178
+ // --- URL scheme sanitisation (XSS) -----------------------------------------
179
+
180
+ test('link with javascript: scheme is neutralised to #', () => {
181
+ const { html } = parseMd('[x](javascript:alert(1))')
182
+ assert(!html.toLowerCase().includes('javascript:'), `Got: ${html}`)
183
+ assert(html.includes('href="#"'), `Got: ${html}`)
184
+ })
185
+
186
+ test('javascript: scheme neutralised regardless of case', () => {
187
+ const { html } = parseMd('[x](JaVaScRiPt:alert(1))')
188
+ assert(!html.toLowerCase().includes('javascript:'), `Got: ${html}`)
189
+ })
190
+
191
+ test('image with javascript: scheme is neutralised', () => {
192
+ const { html } = parseMd('![i](javascript:alert(1))')
193
+ assert(!html.toLowerCase().includes('javascript:'), `Got: ${html}`)
194
+ assert(html.includes('src="#"'), `Got: ${html}`)
195
+ })
196
+
197
+ test('non-image data: URI in image is neutralised', () => {
198
+ const { html } = parseMd('![i](data:text/html,<script>alert(1)</script>)')
199
+ assert(html.includes('src="#"'), `Got: ${html}`)
200
+ })
201
+
202
+ test('image data: URI is allowed', () => {
203
+ const { html } = parseMd('![i](data:image/png;base64,iVBOR)')
204
+ assert(html.includes('src="data:image/png;base64,iVBOR"'), `Got: ${html}`)
205
+ })
206
+
207
+ test('safe schemes and relative/fragment URLs are preserved', () => {
208
+ for (const url of ['https://ok.com', 'http://ok.com', '/relative', '#frag', 'mailto:a@b.com', 'tel:+15551234']) {
209
+ const { html } = parseMd(`[x](${url})`)
210
+ assert(html.includes(`href="${url}"`), `Expected ${url} preserved, got: ${html}`)
211
+ }
212
+ })
213
+
178
214
  // ---------------------------------------------------------------------------
179
215
 
180
216
  console.log(`\n${passed + failed} tests: ${passed} passed, ${failed} failed\n`)
@@ -621,4 +621,5 @@ function esc(str) {
621
621
  .replace(/</g, '&lt;')
622
622
  .replace(/>/g, '&gt;')
623
623
  .replace(/"/g, '&quot;')
624
+ .replace(/'/g, '&#39;')
624
625
  }
package/src/runtime/tt.js CHANGED
@@ -4,6 +4,14 @@
4
4
  * everywhere else — behaviour is identical, the protection is simply absent.
5
5
  *
6
6
  * Policy name 'pulse' must match the trusted-types CSP directive on the server.
7
+ *
8
+ * SECURITY NOTE: createHTML/createScriptURL are intentional pass-throughs. The
9
+ * framework owns the HTML it renders, so this policy does NOT sanitise — it
10
+ * exists to satisfy `require-trusted-types-for 'script'` and to confine HTML
11
+ * sinks to a single audited policy (preventing *other* code on the page from
12
+ * creating its own policy). It is NOT a defence against unescaped user data in
13
+ * a view: view authors remain responsible for escaping untrusted values with
14
+ * escHtml() before interpolating them into markup.
7
15
  */
8
16
 
9
17
  let _policy = null
@@ -119,16 +119,25 @@ function negotiateEncoding(req) {
119
119
  return null
120
120
  }
121
121
 
122
+ // Brotli quality for on-the-fly (per-request) compression. The library default
123
+ // is 11 — the maximum, intended for build-time compression of static assets.
124
+ // At q11 each dynamic response costs ~10x the CPU of q5 for a marginal size gain
125
+ // on HTML, inflating TTFB and limiting throughput. q5 matches what CDNs/nginx use
126
+ // for dynamic content. Pre-built /dist/* bundles should be compressed at build time.
127
+ const BROTLI_DYNAMIC = {
128
+ params: { [zlib.constants.BROTLI_PARAM_QUALITY]: 5 },
129
+ }
130
+
122
131
  /** Compress a Buffer using the given encoding. Returns the original if none. */
123
132
  async function compressBuffer(buf, encoding) {
124
- if (encoding === 'br') return brotliAsync(buf)
133
+ if (encoding === 'br') return brotliAsync(buf, BROTLI_DYNAMIC)
125
134
  if (encoding === 'gzip') return gzipAsync(buf)
126
135
  return buf
127
136
  }
128
137
 
129
138
  /** Create a transform stream for the given encoding, or null. */
130
139
  function createCompressor(encoding) {
131
- if (encoding === 'br') return zlib.createBrotliCompress()
140
+ if (encoding === 'br') return zlib.createBrotliCompress(BROTLI_DYNAMIC)
132
141
  if (encoding === 'gzip') return zlib.createGzip()
133
142
  return null
134
143
  }
@@ -211,21 +220,38 @@ function parseMultipart(buf, boundary) {
211
220
  // In-process TTL cache — server data memoization
212
221
  // ---------------------------------------------------------------------------
213
222
 
214
- class TtlCache {
215
- constructor() {
216
- this._store = new Map()
217
- this._interval = null
223
+ export class TtlCache {
224
+ /**
225
+ * @param {number} [maxEntries=1000] - hard cap on stored entries. When exceeded,
226
+ * the least-recently-used entry is evicted. Bounds memory so cache keys derived
227
+ * from attacker-controlled input (query strings, Host header) cannot grow the
228
+ * cache without limit.
229
+ */
230
+ constructor(maxEntries = 1000) {
231
+ this._store = new Map() // Map preserves insertion order → used for LRU
232
+ this._interval = null
233
+ this._maxEntries = maxEntries
218
234
  }
219
235
 
220
236
  get(key) {
221
237
  const entry = this._store.get(key)
222
238
  if (!entry) return undefined
223
239
  if (Date.now() > entry.expiresAt) { this._store.delete(key); return undefined }
240
+ // Mark as most-recently-used: re-insert to move to the end of the Map.
241
+ this._store.delete(key)
242
+ this._store.set(key, entry)
224
243
  return entry.value
225
244
  }
226
245
 
227
246
  set(key, value, ttlSeconds) {
247
+ // Refresh position if it already exists so LRU ordering stays correct.
248
+ this._store.delete(key)
228
249
  this._store.set(key, { value, expiresAt: Date.now() + ttlSeconds * 1000 })
250
+ // Evict the least-recently-used entry (first in insertion order) when over cap.
251
+ if (this._store.size > this._maxEntries) {
252
+ const oldest = this._store.keys().next().value
253
+ this._store.delete(oldest)
254
+ }
229
255
  }
230
256
 
231
257
  /** Remove all entries whose TTL has elapsed. */
@@ -308,9 +334,27 @@ function pageCacheTtl(spec, dev, defaultCache) {
308
334
  return cfg?.maxAge || 0
309
335
  }
310
336
 
311
- /** Build the in-process page cache key scoped to route + request parameters. */
337
+ /**
338
+ * Build the in-process page cache key.
339
+ *
340
+ * The key is scoped to the route + path params + only the query keys the spec
341
+ * explicitly opts into via `spec.cacheVary`. The full query string is NOT part
342
+ * of the key by default: an unauthenticated client can append arbitrary query
343
+ * params (?n=1, ?n=2, …) that don't change a cacheable page's output, and
344
+ * including them would let that client mint unbounded distinct cache entries
345
+ * (memory-exhaustion DoS). Pages whose output genuinely depends on a query
346
+ * param declare it in `cacheVary: ['q']`.
347
+ */
312
348
  function buildCacheKey(spec, ctx) {
313
- return spec.route + '\0' + JSON.stringify(ctx.params) + '\0' + JSON.stringify(ctx.query)
349
+ let varied = ''
350
+ if (Array.isArray(spec.cacheVary) && spec.cacheVary.length) {
351
+ const picked = {}
352
+ for (const k of spec.cacheVary) {
353
+ if (ctx.query[k] !== undefined) picked[k] = ctx.query[k]
354
+ }
355
+ varied = JSON.stringify(picked)
356
+ }
357
+ return spec.route + '\0' + JSON.stringify(ctx.params) + '\0' + varied
314
358
  }
315
359
 
316
360
  /**
@@ -517,8 +561,10 @@ export async function createServer(entries, options = {}) {
517
561
  }
518
562
  }
519
563
 
520
- // Per-host brand cache — avoids hitting the data store on every request
521
- const brandCache = new TtlCache()
564
+ // Per-host brand cache — avoids hitting the data store on every request.
565
+ // Capped (Host header is attacker-controlled, so unbounded distinct keys
566
+ // would otherwise be a memory-exhaustion vector).
567
+ const brandCache = new TtlCache(500)
522
568
  brandCache.startEviction(30_000) // brand TTL is 60s, scan every 30s
523
569
 
524
570
  // Load manifest — maps source hydrate paths to production bundle paths
@@ -554,14 +600,19 @@ export async function createServer(entries, options = {}) {
554
600
  // Build route table — let so it can be swapped on hot reload
555
601
  let router = buildRouter(specs)
556
602
 
603
+ // Resolve the logger module once at startup rather than dynamically importing
604
+ // it on every request's `finish` event. `logRequest` stays null until loaded.
605
+ let logRequest = null
606
+ if (!quiet) {
607
+ import('../cli/logger.js').then(m => { logRequest = m.request }).catch(() => {})
608
+ }
609
+
557
610
  const server = http.createServer(async (req, res) => {
558
611
  const reqStart = Date.now()
559
612
  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(() => {})
613
+ if (logRequest) {
614
+ const url = new URL(req.url, `http://localhost:${port}`)
615
+ logRequest({ method: req.method, pathname: url.pathname, status: res.statusCode, ms: Date.now() - reqStart })
565
616
  }
566
617
  })
567
618
  try {
@@ -691,6 +742,13 @@ export async function createServer(entries, options = {}) {
691
742
  // Canonical path follows the trailingSlash mode so the <link> is consistent with redirects.
692
743
  // spec.meta.canonical (string or function) is resolved inside each handler, where serverState
693
744
  // is available — allowing dynamic canonicals from server fetcher results.
745
+ //
746
+ // SECURITY: proto/host come from client-controllable headers (x-forwarded-*,
747
+ // Host). They are HTML-escaped before being emitted into <link rel=canonical>
748
+ // and og:* tags, so this is not an XSS vector — but if your edge/proxy does
749
+ // not pin or validate these headers, an attacker can influence the canonical
750
+ // URL (SEO/canonical poisoning). Trust x-forwarded-* only behind a proxy you
751
+ // control; set spec.meta.canonical to a fixed string to opt out entirely.
694
752
  const proto = req.headers['x-forwarded-proto'] || 'http'
695
753
  const host = req.headers['x-forwarded-host'] || req.headers.host || `localhost:${port}`
696
754
  const canonicalPath = trailingSlash === 'add' && pathname !== '/'
@@ -1112,7 +1170,7 @@ async function handleRawResponse(spec, ctx, req, res, dev) {
1112
1170
  let content
1113
1171
 
1114
1172
  if (!dev && spec.serverTtl) {
1115
- const key = 'raw:' + spec.route + '\0' + JSON.stringify(ctx.params) + '\0' + JSON.stringify(ctx.query)
1173
+ const key = 'raw:' + buildCacheKey(spec, ctx)
1116
1174
  content = serverDataCache.get(key)
1117
1175
  if (!content) {
1118
1176
  const serverState = await resolveServerState(spec, ctx)
@@ -1181,9 +1239,15 @@ function matchRoute(router, pathname) {
1181
1239
  if (!match) continue
1182
1240
 
1183
1241
  const params = {}
1184
- route.params.forEach((name, i) => {
1185
- params[name] = decodeURIComponent(match[i + 1])
1186
- })
1242
+ try {
1243
+ route.params.forEach((name, i) => {
1244
+ params[name] = decodeURIComponent(match[i + 1])
1245
+ })
1246
+ } catch {
1247
+ // Malformed percent-encoding (e.g. /%ZZ) — treat as no match (404)
1248
+ // rather than letting decodeURIComponent throw a 500.
1249
+ continue
1250
+ }
1187
1251
 
1188
1252
  return { spec: route.spec, params }
1189
1253
  }
@@ -1433,7 +1497,9 @@ function resolveSpec(spec, hydrateMap) {
1433
1497
  */
1434
1498
  function serveStatic(req, res, staticDir, dev = false) {
1435
1499
  const url = new URL(req.url, 'http://localhost')
1436
- const pathname = decodeURIComponent(url.pathname)
1500
+ let pathname
1501
+ try { pathname = decodeURIComponent(url.pathname) }
1502
+ catch { return false } // malformed percent-encoding → not a static file
1437
1503
 
1438
1504
  // Prevent directory traversal
1439
1505
  const filePath = path.join(staticDir, pathname)
@@ -1516,6 +1582,7 @@ function escHtml(str) {
1516
1582
  .replace(/</g, '&lt;')
1517
1583
  .replace(/>/g, '&gt;')
1518
1584
  .replace(/"/g, '&quot;')
1585
+ .replace(/'/g, '&#39;')
1519
1586
  }
1520
1587
 
1521
1588
  function notFoundHtml(pathname) {
@@ -4,7 +4,7 @@
4
4
  */
5
5
 
6
6
  import http from 'http'
7
- import { createServer } from './index.js'
7
+ import { createServer, TtlCache } from './index.js'
8
8
 
9
9
  // ---------------------------------------------------------------------------
10
10
  // Test runner
@@ -1246,6 +1246,111 @@ await test('health check fires before route matching — shadows a user spec at
1246
1246
 
1247
1247
  // ---------------------------------------------------------------------------
1248
1248
 
1249
+ console.log('\nMalformed request handling\n')
1250
+
1251
+ await test('malformed percent-encoding in path returns 404, not 500', async () => {
1252
+ await withServer([helloSpec], { stream: false }, async (port) => {
1253
+ const { status } = await get(port, '/%ZZ')
1254
+ assert(status === 404, `Expected 404 for malformed encoding, got ${status}`)
1255
+ })
1256
+ })
1257
+
1258
+ await test('malformed percent-encoding in param route returns 404', async () => {
1259
+ await withServer([productSpec], { stream: false }, async (port) => {
1260
+ const { status } = await get(port, '/products/%E0%A4%A')
1261
+ assert(status === 404, `Expected 404 for malformed param, got ${status}`)
1262
+ })
1263
+ })
1264
+
1265
+ // ---------------------------------------------------------------------------
1266
+
1267
+ console.log('\nPage cache — query-string DoS guard\n')
1268
+
1269
+ await test('query string is NOT part of the cache key by default', async () => {
1270
+ // A cacheable page should serve identical cached HTML regardless of arbitrary
1271
+ // query params — they must not mint new cache entries.
1272
+ let renders = 0
1273
+ const spec = {
1274
+ route: '/cached',
1275
+ cache: { public: true, maxAge: 60 },
1276
+ view: () => `<h1>render-${++renders}</h1>`,
1277
+ }
1278
+ await withServer([spec], { stream: false }, async (port) => {
1279
+ const a = await get(port, '/cached?n=1')
1280
+ const b = await get(port, '/cached?n=2')
1281
+ const c = await get(port, '/cached?n=3')
1282
+ assert(a.body === b.body && b.body === c.body, 'Cached body should be identical across query strings')
1283
+ assert(a.body.includes('render-1'), `Expected first render cached, got: ${a.body}`)
1284
+ })
1285
+ })
1286
+
1287
+ await test('cacheVary keys DO produce distinct cache entries', async () => {
1288
+ let renders = 0
1289
+ const spec = {
1290
+ route: '/varied',
1291
+ cache: { public: true, maxAge: 60 },
1292
+ cacheVary: ['q'],
1293
+ server: { n: async () => ++renders },
1294
+ view: (_s, server) => `<h1>v-${server.n}</h1>`,
1295
+ }
1296
+ // server fetcher makes this non-cacheable for HTML, so vary only affects
1297
+ // the in-process key; assert distinct outputs per q value.
1298
+ await withServer([spec], { stream: false }, async (port) => {
1299
+ const a = await get(port, '/varied?q=apple')
1300
+ const b = await get(port, '/varied?q=banana')
1301
+ assert(a.body !== b.body, `Expected distinct outputs per cacheVary value, got identical: ${a.body}`)
1302
+ })
1303
+ })
1304
+
1305
+ // ---------------------------------------------------------------------------
1306
+
1307
+ console.log('\nTtlCache — bounded LRU\n')
1308
+
1309
+ await test('evicts least-recently-used entry when over capacity', () => {
1310
+ const c = new TtlCache(2)
1311
+ c.set('a', 1, 60)
1312
+ c.set('b', 2, 60)
1313
+ c.set('c', 3, 60) // exceeds cap → evicts 'a' (LRU)
1314
+ assert(c.get('a') === undefined, 'Expected a to be evicted')
1315
+ assert(c.get('b') === 2, 'Expected b to remain')
1316
+ assert(c.get('c') === 3, 'Expected c to remain')
1317
+ })
1318
+
1319
+ await test('get() refreshes recency so the touched entry survives eviction', () => {
1320
+ const c = new TtlCache(2)
1321
+ c.set('a', 1, 60)
1322
+ c.set('b', 2, 60)
1323
+ c.get('a') // 'a' now most-recently-used
1324
+ c.set('c', 3, 60) // evicts 'b', not 'a'
1325
+ assert(c.get('a') === 1, 'Expected a to survive (recently used)')
1326
+ assert(c.get('b') === undefined, 'Expected b to be evicted')
1327
+ })
1328
+
1329
+ await test('many distinct keys never exceed the cap', () => {
1330
+ const c = new TtlCache(100)
1331
+ for (let i = 0; i < 10_000; i++) c.set(`k${i}`, i, 60)
1332
+ assert(c._store.size === 100, `Expected size capped at 100, got ${c._store.size}`)
1333
+ })
1334
+
1335
+ await test('expired entries are not returned', () => {
1336
+ const c = new TtlCache(10)
1337
+ c.set('x', 1, -1) // already expired
1338
+ assert(c.get('x') === undefined, 'Expected expired entry to be absent')
1339
+ })
1340
+
1341
+ // ---------------------------------------------------------------------------
1342
+
1343
+ console.log('\nCompression\n')
1344
+
1345
+ await test('serves brotli-compressed HTML when br is accepted', async () => {
1346
+ await withServer([helloSpec], { stream: false }, async (port) => {
1347
+ const { headers } = await request(port, 'GET', '/hello', { 'Accept-Encoding': 'br' })
1348
+ assert(headers['content-encoding'] === 'br', `Expected br encoding, got ${headers['content-encoding']}`)
1349
+ })
1350
+ })
1351
+
1352
+ // ---------------------------------------------------------------------------
1353
+
1249
1354
  console.log('\nGraceful shutdown\n')
1250
1355
 
1251
1356
  await test('shutdown() is returned from createServer', async () => {
@@ -371,6 +371,16 @@ export function validateSpec(spec) {
371
371
  }
372
372
  }
373
373
 
374
+ // cacheVary — query-string keys that affect the cached HTML for this page.
375
+ // By default the in-process page cache ignores the query string entirely (so a
376
+ // client cannot mint unbounded cache entries with arbitrary ?n=… params). Pages
377
+ // whose output depends on a query param must list it here, e.g. cacheVary: ['q'].
378
+ if (spec.cacheVary !== undefined) {
379
+ if (!Array.isArray(spec.cacheVary) || !spec.cacheVary.every(k => typeof k === 'string')) {
380
+ errors.push('spec.cacheVary must be an array of query-parameter name strings, e.g. [\'q\', \'page\']')
381
+ }
382
+ }
383
+
374
384
  // hydrate — auto-derived by the framework, should never be set manually
375
385
  if (spec.hydrate !== undefined) {
376
386
  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.')