@invisibleloop/pulse 0.17.1 → 0.17.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -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 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"]`; it waits for the answer without ending the turn. If you must ask in plain prose and end your turn, call `pulse_await_approval` first so the Stop hooks let the turn end. If a `VERIFY REQUIRED` stop-hook block fires while your question is unanswered, that is not permission to continue — call `pulse_await_approval`, re-ask, and end your turn.
53
+ Then **call `pulse_await_approval`** and 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. Always call the tool before asking, whichever way you ask: in some hosts (including Claude Code) even a question tool ends the turn and fires the Stop hooks; the marker is harmless if the turn doesn't end. If a `VERIFY REQUIRED` stop-hook block fires while your question is unanswered, that is not permission to continue — call `pulse_await_approval`, re-ask, and end your turn.
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. 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"]`; it waits for the answer without ending the turn. If you must ask in plain prose and end your turn, call `pulse_await_approval` first so the Stop hooks let the turn end.
18
+ 2. **Call `pulse_await_approval` first**, then 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. Always call the tool before asking, whichever way you ask: in some hosts (including Claude Code) even a question tool ends the turn and fires the Stop hooks. The marker is harmless if the turn doesn't end — it is consumed when the user replies.
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. If a `VERIFY REQUIRED` stop-hook block fires while your question is unanswered, that is not permission to continue — call `pulse_await_approval`, re-ask, and end your turn.
20
20
 
21
21
  ### 3. Validate the spec
@@ -46,7 +46,7 @@ The browser should still be on `http://localhost:3001/` from step 6. Run `mcp__c
46
46
 
47
47
  **Same pass bar: Accessibility, Best Practices, and SEO must all be 100.** Fix any failures and restart from step 3.
48
48
 
49
- After both Lighthouse runs pass, call `pulse_restart_server` to return to the dev server.
49
+ **Stay on the production server (port 3001)** — steps 8 and 9 use it too. Do not call `pulse_restart_server` yet.
50
50
 
51
51
  ### 8. Mobile layout check
52
52
 
@@ -54,7 +54,7 @@ Emulate a mobile viewport and take a screenshot to catch wrapping, overflow, and
54
54
 
55
55
  ```
56
56
  mcp__chrome-devtools__emulate viewport: "390x844x2,mobile,touch"
57
- mcp__chrome-devtools__navigate_page url: "http://localhost:3000/"
57
+ mcp__chrome-devtools__navigate_page url: "http://localhost:3001/"
58
58
  mcp__chrome-devtools__take_screenshot
59
59
  ```
60
60
 
@@ -72,7 +72,9 @@ mcp__chrome-devtools__emulate viewport: "1440x900x1"
72
72
 
73
73
  ### 9. Performance
74
74
 
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.).
75
+ **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
+
77
+ After the trace, call `pulse_restart_server` to return to the dev server.
76
78
 
77
79
  ### 10. Console errors
78
80
 
@@ -20,6 +20,8 @@ Before finishing any spec, verify every point below. Fix anything that fails.
20
20
 
21
21
  - **Do not set `hydrate` in specs.** It is auto-derived by the framework from the URL entry passed to `createServer`. Specs with `mutations`, `actions`, or `persist` are hydrated automatically. Purely server-rendered specs get zero JavaScript — no configuration needed.
22
22
 
23
+ - **The default theme is DARK.** When `meta.theme` is unset, the page renders with the dark palette. If the design calls for a light page, set `meta.theme: 'light'` when you **first write the spec** — do not assume light and discover dark at the screenshot, which costs an edit → restart → re-approval cycle. Declare the theme in your plan/build brief, and ask the user if the brief doesn't say.
24
+
23
25
  - **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
26
 
25
27
  ### Components first — or creative override
package/CLAUDE.md CHANGED
@@ -47,6 +47,8 @@ export const mySpec = {
47
47
  title: 'Page Title',
48
48
  description: 'Meta description',
49
49
  styles: ['/pulse.css'],
50
+ theme: 'light', // 'light' | 'dark' — ⚠ default is DARK when unset; decide at write time
51
+ vibe: 'minimal', // optional preset: radius, weight, spacing
50
52
  ogTitle: '...',
51
53
  ogImage: '...',
52
54
  },
@@ -468,7 +470,7 @@ For any **new page, landing page, or branded site**, run this sequence first. Do
468
470
  3. pulse_intent(description) → archetype + scaffold + guide list
469
471
  ```
470
472
 
471
- **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.
473
+ **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, **theme (light or dark — Pulse defaults to dark when unset)**, vibe, and anti-style if not mentioned.
472
474
 
473
475
  **`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.
474
476
 
@@ -20,6 +20,8 @@ Before finishing any spec, verify every point below. Fix anything that fails.
20
20
 
21
21
  - **Do not set `hydrate` in specs.** It is auto-derived by the framework from the URL entry passed to `createServer`. Specs with `mutations`, `actions`, or `persist` are hydrated automatically. Purely server-rendered specs get zero JavaScript — no configuration needed.
22
22
 
23
+ - **The default theme is DARK.** When `meta.theme` is unset, the page renders with the dark palette. If the design calls for a light page, set `meta.theme: 'light'` when you **first write the spec** — do not assume light and discover dark at the screenshot, which costs an edit → restart → re-approval cycle. Declare the theme in your plan/build brief, and ask the user if the brief doesn't say.
24
+
23
25
  - **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
26
 
25
27
  ### Components first — or creative override
@@ -4031,8 +4031,14 @@ fieldset[disabled] .ui-rating-stars .ui-rating-star:hover ~ .ui-rating-star { co
4031
4031
  }
4032
4032
 
4033
4033
  .ui-drop-cap-letter {
4034
- font-weight: 700;
4035
- color: var(--ui-text);
4034
+ /* line-height ~cap height keeps the float box hugging the glyph so the cap
4035
+ top-aligns with the first text line — a relative line-height makes serif
4036
+ caps float below the opening line. Component sets font-size inline. */
4037
+ float: left;
4038
+ font-weight: 700;
4039
+ line-height: 0.82;
4040
+ margin-right: 0.1em;
4041
+ color: var(--ui-text);
4036
4042
  }
4037
4043
 
4038
4044
  /* Colophon — minimal footer for editorial pages */
@@ -20,6 +20,8 @@ Before finishing any spec, verify every point below. Fix anything that fails.
20
20
 
21
21
  - **Do not set `hydrate` in specs.** It is auto-derived by the framework from the URL entry passed to `createServer`. Specs with `mutations`, `actions`, or `persist` are hydrated automatically. Purely server-rendered specs get zero JavaScript — no configuration needed.
22
22
 
23
+ - **The default theme is DARK.** When `meta.theme` is unset, the page renders with the dark palette. If the design calls for a light page, set `meta.theme: 'light'` when you **first write the spec** — do not assume light and discover dark at the screenshot, which costs an edit → restart → re-approval cycle. Declare the theme in your plan/build brief, and ask the user if the brief doesn't say.
24
+
23
25
  - **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
26
 
25
27
  ### Components first — or creative override
@@ -150,6 +150,20 @@ html, body {
150
150
  color: var(--ui-text);
151
151
  }
152
152
 
153
+ /* ghost-light — use on dark section backgrounds (e.g. navy hero, section--dark).
154
+ Renders with light/white text and a subtle light border so it remains legible
155
+ regardless of whether the surrounding theme is light or dark. */
156
+ .ui-btn--ghost-light {
157
+ background: transparent;
158
+ color: rgba(255, 255, 255, 0.9);
159
+ border: 1px solid rgba(255, 255, 255, 0.35);
160
+ }
161
+ .ui-btn--ghost-light:hover:not(.ui-btn--disabled):not([disabled]) {
162
+ background: rgba(255, 255, 255, 0.1);
163
+ border-color: rgba(255, 255, 255, 0.65);
164
+ color: #ffffff;
165
+ }
166
+
153
167
  .ui-btn--danger {
154
168
  background: var(--ui-red-dim);
155
169
  color: var(--ui-red);
@@ -682,6 +696,36 @@ html, body {
682
696
  .ui-section--alt { background: var(--ui-surface, var(--surface, #111116)); }
683
697
  .ui-section--dark { background: var(--ui-surface-2, var(--surface-2, #18181f)); }
684
698
 
699
+ /* In light theme, --ui-surface-2 resolves to a light grey — not "dark".
700
+ Override so section--dark always renders a genuinely dark background that
701
+ contrasts with the page, regardless of the active theme. */
702
+ [data-theme="light"] .ui-section--dark,
703
+ .ui-theme-light .ui-section--dark {
704
+ background: var(--ui-dark-bg, #1a1a2e);
705
+ color: var(--ui-dark-text, #e2e2ea);
706
+ }
707
+ /* Ensure text children inherit the light colour in the dark section */
708
+ [data-theme="light"] .ui-section--dark .ui-section-title,
709
+ [data-theme="light"] .ui-section--dark .ui-section-eyebrow,
710
+ [data-theme="light"] .ui-section--dark .ui-section-subtitle,
711
+ .ui-theme-light .ui-section--dark .ui-section-title,
712
+ .ui-theme-light .ui-section--dark .ui-section-eyebrow,
713
+ .ui-theme-light .ui-section--dark .ui-section-subtitle {
714
+ color: inherit;
715
+ }
716
+ /* Ghost button inside a dark section on a light-theme page */
717
+ [data-theme="light"] .ui-section--dark .ui-btn--ghost,
718
+ .ui-theme-light .ui-section--dark .ui-btn--ghost {
719
+ color: var(--ui-dark-text, #e2e2ea);
720
+ border-color: rgba(226, 226, 234, 0.35);
721
+ }
722
+ [data-theme="light"] .ui-section--dark .ui-btn--ghost:hover:not(.ui-btn--disabled):not([disabled]),
723
+ .ui-theme-light .ui-section--dark .ui-btn--ghost:hover:not(.ui-btn--disabled):not([disabled]) {
724
+ background: rgba(226, 226, 234, 0.1);
725
+ border-color: rgba(226, 226, 234, 0.65);
726
+ color: var(--ui-dark-text, #e2e2ea);
727
+ }
728
+
685
729
  /* diagonal — tapered bottom edge; follow with a section that starts flush */
686
730
  .ui-section--diagonal {
687
731
  background: var(--ui-surface, var(--surface, #111116));
@@ -3987,8 +4031,14 @@ fieldset[disabled] .ui-rating-stars .ui-rating-star:hover ~ .ui-rating-star { co
3987
4031
  }
3988
4032
 
3989
4033
  .ui-drop-cap-letter {
3990
- font-weight: 700;
3991
- color: var(--ui-text);
4034
+ /* line-height ~cap height keeps the float box hugging the glyph so the cap
4035
+ top-aligns with the first text line — a relative line-height makes serif
4036
+ caps float below the opening line. Component sets font-size inline. */
4037
+ float: left;
4038
+ font-weight: 700;
4039
+ line-height: 0.82;
4040
+ margin-right: 0.1em;
4041
+ color: var(--ui-text);
3992
4042
  }
3993
4043
 
3994
4044
  /* Colophon — minimal footer for editorial pages */
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@invisibleloop/pulse",
3
- "version": "0.17.1",
3
+ "version": "0.17.2",
4
4
  "type": "module",
5
5
  "description": "AI-first frontend framework. The spec is the source of truth.",
6
6
  "license": "MIT",
@@ -57,7 +57,7 @@
57
57
  "scripts": {
58
58
  "dev": "node --watch src/cli/dev.js",
59
59
  "build": "node scripts/build.js",
60
- "test": "node src/spec/schema.test.js && node src/runtime/runtime.test.js && node src/runtime/navigate.test.js && node src/runtime/morph.test.js && node src/runtime/ssr.test.js && node src/runtime/image.test.js && node src/server/server.test.js && node src/cli/cli.test.js && node src/ui/ui.test.js && node src/testing/testing.test.js && node src/store/store.test.js && node src/md/md.test.js && node scripts/strip-server.test.js && node src/mcp/mcp.test.js",
60
+ "test": "node src/spec/schema.test.js && node src/runtime/runtime.test.js && node src/runtime/navigate.test.js && node src/runtime/morph.test.js && node src/runtime/ssr.test.js && node src/runtime/image.test.js && node src/server/server.test.js && node src/cli/cli.test.js && node src/ui/ui.test.js && node src/testing/testing.test.js && node src/store/store.test.js && node src/md/md.test.js && node scripts/strip-server.test.js && node scripts/css-purge.test.js && node src/mcp/mcp.test.js",
61
61
  "typecheck": "tsc --noEmit",
62
62
  "docs": "node --watch src/cli/dev.js --root docs --port 4000",
63
63
  "docs:start": "node docs/server.js"
@@ -1 +1 @@
1
- 0.17.1
1
+ 0.17.2
@@ -4031,8 +4031,14 @@ fieldset[disabled] .ui-rating-stars .ui-rating-star:hover ~ .ui-rating-star { co
4031
4031
  }
4032
4032
 
4033
4033
  .ui-drop-cap-letter {
4034
- font-weight: 700;
4035
- color: var(--ui-text);
4034
+ /* line-height ~cap height keeps the float box hugging the glyph so the cap
4035
+ top-aligns with the first text line — a relative line-height makes serif
4036
+ caps float below the opening line. Component sets font-size inline. */
4037
+ float: left;
4038
+ font-weight: 700;
4039
+ line-height: 0.82;
4040
+ margin-right: 0.1em;
4041
+ color: var(--ui-text);
4036
4042
  }
4037
4043
 
4038
4044
  /* Colophon — minimal footer for editorial pages */
package/scripts/build.js CHANGED
@@ -20,6 +20,7 @@ import { createHash } from 'crypto'
20
20
  import { discoverPages } from '../src/cli/discover.js'
21
21
  import { renderToString } from '../src/runtime/ssr.js'
22
22
  import { c, header, table, ok, fail, info, warn, elapsed, icon } from '../src/cli/fmt.js'
23
+ import { extractUsedClasses, purgeCss, minifyCss } from './css-purge.js'
23
24
 
24
25
  // ---------------------------------------------------------------------------
25
26
  // Server-only property stripping
@@ -783,207 +784,3 @@ async function purgeCssStep(pages, manifest, root, outDir) {
783
784
  fs.writeFileSync(path.join(outDir, 'manifest.json'), JSON.stringify(manifest, null, 2))
784
785
  return cssStats
785
786
  }
786
-
787
- // ---------------------------------------------------------------------------
788
- // CSS class extractor
789
- // ---------------------------------------------------------------------------
790
-
791
- function extractUsedClasses(htmlContents, jsContents, safelist = []) {
792
- const used = new Set()
793
-
794
- // Extract from class="..." in both rendered HTML and JS template literals
795
- for (const content of [...htmlContents, ...jsContents]) {
796
- const re = /class(?:Name)?=["']([^"'\n]+)["']/g
797
- let m
798
- while ((m = re.exec(content)) !== null) {
799
- for (const cls of m[1].trim().split(/\s+/)) {
800
- if (cls) used.add(cls)
801
- }
802
- }
803
- }
804
-
805
- // Scan JS source for quoted strings that look like CSS class names (contain hyphens)
806
- // Catches conditionally-applied classes like 'ui-btn--disabled' in ternaries
807
- for (const js of jsContents) {
808
- const re = /['"`]((?:[a-zA-Z][a-zA-Z0-9]*-[a-zA-Z0-9-]*)(?:\s+[a-zA-Z][a-zA-Z0-9-]*)*)['"`]/g
809
- let m
810
- while ((m = re.exec(js)) !== null) {
811
- for (const cls of m[1].trim().split(/\s+/)) {
812
- if (cls && cls.includes('-')) used.add(cls)
813
- }
814
- }
815
- }
816
-
817
- // Apply safelist from pulse.config.js — css.safelist accepts strings or RegExp.
818
- // String entries are added directly; RegExp entries are matched against all
819
- // class names defined in the CSS (resolved later in purgeCss via selectorUsed
820
- // because we don't have the CSS text here). To handle RegExp, we mark them as
821
- // a sentinel that selectorUsed checks separately.
822
- for (const entry of safelist) {
823
- if (typeof entry === 'string') {
824
- used.add(entry)
825
- }
826
- // RegExp entries are stored on the Set as a symbol property for selectorUsed to check
827
- }
828
- if (safelist.some(e => e instanceof RegExp)) {
829
- used._safelistPatterns = safelist.filter(e => e instanceof RegExp)
830
- }
831
-
832
- return used
833
- }
834
-
835
- // ---------------------------------------------------------------------------
836
- // CSS purger — strips unused rules, preserves @media / :root / element rules
837
- // ---------------------------------------------------------------------------
838
-
839
- /**
840
- * Parse CSS text into top-level blocks using a bracket-depth counter.
841
- * Returns [{ type, selector?, prelude?, inner?, raw }]
842
- */
843
- function parseCssBlocks(css) {
844
- const blocks = []
845
- let i = 0
846
- const len = css.length
847
-
848
- while (i < len) {
849
- // Skip whitespace
850
- while (i < len && /\s/.test(css[i])) i++
851
- if (i >= len) break
852
-
853
- // Skip block comments
854
- if (css[i] === '/' && css[i + 1] === '*') {
855
- const end = css.indexOf('*/', i + 2)
856
- i = end === -1 ? len : end + 2
857
- continue
858
- }
859
-
860
- const start = i
861
- let depth = 0
862
-
863
- while (i < len) {
864
- // Skip inline comments
865
- if (css[i] === '/' && css[i + 1] === '*') {
866
- const end = css.indexOf('*/', i + 2)
867
- i = end === -1 ? len : end + 2
868
- continue
869
- }
870
- if (css[i] === '{') { depth++; i++; continue }
871
- if (css[i] === '}') { depth--; i++; if (depth === 0) break; continue }
872
- if (css[i] === ';' && depth === 0) { i++; break }
873
- i++
874
- }
875
-
876
- const raw = css.slice(start, i).trim()
877
- if (!raw) continue
878
-
879
- if (raw.startsWith('@')) {
880
- const braceIdx = raw.indexOf('{')
881
- if (braceIdx === -1) {
882
- blocks.push({ type: 'at-simple', raw })
883
- } else {
884
- const prelude = raw.slice(0, braceIdx).trim()
885
- const inner = raw.slice(braceIdx + 1, raw.lastIndexOf('}')).trim()
886
- blocks.push({ type: 'at-block', prelude, inner, raw })
887
- }
888
- } else {
889
- const braceIdx = raw.indexOf('{')
890
- if (braceIdx !== -1) {
891
- blocks.push({ type: 'rule', selector: raw.slice(0, braceIdx).trim(), raw })
892
- }
893
- }
894
- }
895
-
896
- return blocks
897
- }
898
-
899
- /**
900
- * Return true if any selector in the rule should be kept.
901
- *
902
- * Matching strategy: check the *rightmost* class token in the selector (the
903
- * subject of the rule). A rule like `.parent .child` should survive only when
904
- * `.child` is used — keeping it whenever `.parent` is used would retain dead
905
- * rules whose actual target class was never referenced.
906
- *
907
- * Pseudo-classes/elements and attribute selectors are stripped before checking
908
- * so `.btn:hover` is treated as `.btn`, `.btn--active::before` as `.btn--active`.
909
- *
910
- * Element-only selectors (`a`, `*`, `:root`) and rules that contain no class
911
- * tokens at all are always kept.
912
- */
913
- function selectorUsed(selector, usedClasses) {
914
- const selectors = selector.split(',').map(s => s.trim())
915
-
916
- for (const sel of selectors) {
917
- // Strip pseudo-classes/elements and attribute selectors to find the base
918
- const base = sel
919
- .replace(/::?[a-z-]+(\([^)]*\))?/gi, '')
920
- .replace(/\[[^\]]*\]/g, '')
921
- .trim()
922
-
923
- // Keep element-only selectors — no class tokens present
924
- if (!base.includes('.')) return true
925
-
926
- // Extract all class tokens and check the rightmost one (the rule subject).
927
- // Using the rightmost class correctly handles descendant selectors:
928
- // .nav .link → subject is .link
929
- // .btn--active → subject is .btn--active
930
- const classes = [...base.matchAll(/\.([a-zA-Z][a-zA-Z0-9_-]*)/g)].map(m => m[1])
931
- if (classes.length === 0) return true
932
- const subject = classes[classes.length - 1]
933
- if (usedClasses.has(subject)) return true
934
-
935
- // Check RegExp safelist patterns if any were provided
936
- const patterns = usedClasses._safelistPatterns
937
- if (patterns && patterns.some(re => re.test(subject))) return true
938
- }
939
-
940
- return false
941
- }
942
-
943
- /**
944
- * Purge unused CSS rules from a CSS string.
945
- * Recursively processes @media and @supports blocks.
946
- */
947
- function minifyCss(css) {
948
- return css
949
- .replace(/\/\*[\s\S]*?\*\//g, '') // strip comments
950
- .replace(/\s+/g, ' ') // collapse whitespace
951
- // Remove spaces around safe punctuation — NOT + or - (would break calc() expressions)
952
- .replace(/\s*([{}:;,>~])\s*/g, '$1')
953
- .replace(/;}/g, '}') // remove trailing semicolons
954
- .trim()
955
- }
956
-
957
- function purgeCss(cssText, usedClasses) {
958
- const blocks = parseCssBlocks(cssText)
959
- const kept = []
960
-
961
- for (const block of blocks) {
962
- if (block.type === 'at-simple') {
963
- kept.push(block.raw)
964
- } else if (block.type === 'at-block') {
965
- const p = block.prelude.toLowerCase()
966
- if (/^@(?:keyframes|font-face|charset)/.test(p)) {
967
- kept.push(block.raw)
968
- } else if (/^@(?:media|supports)/.test(p)) {
969
- const innerPurged = purgeCss(block.inner, usedClasses)
970
- if (innerPurged.trim()) kept.push(`${block.prelude} {\n${innerPurged}\n}`)
971
- } else if (/^@layer/.test(p)) {
972
- // Always preserve empty @layer declarations — they establish cascade order
973
- // and removing them silently changes specificity even when no rules remain.
974
- const innerPurged = purgeCss(block.inner, usedClasses)
975
- if (innerPurged.trim()) {
976
- kept.push(`${block.prelude} {\n${innerPurged}\n}`)
977
- } else {
978
- kept.push(`${block.prelude} {}`)
979
- }
980
- } else {
981
- kept.push(block.raw) // unknown at-blocks — keep to be safe
982
- }
983
- } else if (block.type === 'rule') {
984
- if (selectorUsed(block.selector, usedClasses)) kept.push(block.raw)
985
- }
986
- }
987
-
988
- return kept.join('\n\n')
989
- }
@@ -0,0 +1,241 @@
1
+ /**
2
+ * CSS purge + minify — used by scripts/build.js.
3
+ *
4
+ * Extracted into its own module so the parser can be unit-tested
5
+ * (build.js runs the build at import time and cannot be imported by tests).
6
+ */
7
+
8
+ // ---------------------------------------------------------------------------
9
+ // CSS class extractor
10
+ // ---------------------------------------------------------------------------
11
+
12
+ export function extractUsedClasses(htmlContents, jsContents, safelist = []) {
13
+ const used = new Set()
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
18
+ let m
19
+ while ((m = re.exec(content)) !== null) {
20
+ for (const cls of m[1].trim().split(/\s+/)) {
21
+ if (cls) used.add(cls)
22
+ }
23
+ }
24
+ }
25
+
26
+ // Scan JS source for quoted strings that look like CSS class names (contain hyphens)
27
+ // Catches conditionally-applied classes like 'ui-btn--disabled' in ternaries
28
+ for (const js of jsContents) {
29
+ const re = /['"`]((?:[a-zA-Z][a-zA-Z0-9]*-[a-zA-Z0-9-]*)(?:\s+[a-zA-Z][a-zA-Z0-9-]*)*)['"`]/g
30
+ let m
31
+ while ((m = re.exec(js)) !== null) {
32
+ for (const cls of m[1].trim().split(/\s+/)) {
33
+ if (cls && cls.includes('-')) used.add(cls)
34
+ }
35
+ }
36
+ }
37
+
38
+ // Apply safelist from pulse.config.js — css.safelist accepts strings or RegExp.
39
+ // String entries are added directly; RegExp entries are matched against all
40
+ // class names defined in the CSS (resolved later in purgeCss via selectorUsed
41
+ // because we don't have the CSS text here). To handle RegExp, we mark them as
42
+ // a sentinel that selectorUsed checks separately.
43
+ for (const entry of safelist) {
44
+ if (typeof entry === 'string') {
45
+ used.add(entry)
46
+ }
47
+ // RegExp entries are stored on the Set as a symbol property for selectorUsed to check
48
+ }
49
+ if (safelist.some(e => e instanceof RegExp)) {
50
+ used._safelistPatterns = safelist.filter(e => e instanceof RegExp)
51
+ }
52
+
53
+ return used
54
+ }
55
+
56
+ // ---------------------------------------------------------------------------
57
+ // CSS purger — strips unused rules, preserves @media / :root / element rules
58
+ // ---------------------------------------------------------------------------
59
+
60
+ /**
61
+ * Parse CSS text into top-level blocks using a bracket-depth counter.
62
+ * Quote-aware: `;` and braces inside '...' or "..." strings are content, not
63
+ * structure — an @import URL like url('...?family=Inter:wght@400;600&display=swap')
64
+ * must not be split at the `;` inside the quotes.
65
+ * Returns [{ type, selector?, prelude?, inner?, raw }]
66
+ */
67
+ export function parseCssBlocks(css) {
68
+ const blocks = []
69
+ let i = 0
70
+ const len = css.length
71
+
72
+ // Advance past a quoted string starting at css[i] (which is ' or ").
73
+ // Handles backslash escapes. Returns the index just past the closing quote.
74
+ const skipString = (idx) => {
75
+ const quote = css[idx]
76
+ idx++
77
+ while (idx < len && css[idx] !== quote) {
78
+ if (css[idx] === '\\') idx++
79
+ idx++
80
+ }
81
+ return idx + 1 // past closing quote (or len)
82
+ }
83
+
84
+ while (i < len) {
85
+ // Skip whitespace
86
+ while (i < len && /\s/.test(css[i])) i++
87
+ if (i >= len) break
88
+
89
+ // Skip block comments
90
+ if (css[i] === '/' && css[i + 1] === '*') {
91
+ const end = css.indexOf('*/', i + 2)
92
+ i = end === -1 ? len : end + 2
93
+ continue
94
+ }
95
+
96
+ const start = i
97
+ let depth = 0
98
+
99
+ while (i < len) {
100
+ // Skip inline comments
101
+ if (css[i] === '/' && css[i + 1] === '*') {
102
+ const end = css.indexOf('*/', i + 2)
103
+ i = end === -1 ? len : end + 2
104
+ continue
105
+ }
106
+ // Skip quoted strings — ; { } inside them are content, not structure
107
+ if (css[i] === '"' || css[i] === "'") { i = skipString(i); continue }
108
+ if (css[i] === '{') { depth++; i++; continue }
109
+ if (css[i] === '}') { depth--; i++; if (depth === 0) break; continue }
110
+ if (css[i] === ';' && depth === 0) { i++; break }
111
+ i++
112
+ }
113
+
114
+ const raw = css.slice(start, i).trim()
115
+ if (!raw) continue
116
+
117
+ if (raw.startsWith('@')) {
118
+ const braceIdx = raw.indexOf('{')
119
+ if (braceIdx === -1) {
120
+ blocks.push({ type: 'at-simple', raw })
121
+ } else {
122
+ const prelude = raw.slice(0, braceIdx).trim()
123
+ const inner = raw.slice(braceIdx + 1, raw.lastIndexOf('}')).trim()
124
+ blocks.push({ type: 'at-block', prelude, inner, raw })
125
+ }
126
+ } else {
127
+ const braceIdx = raw.indexOf('{')
128
+ if (braceIdx !== -1) {
129
+ blocks.push({ type: 'rule', selector: raw.slice(0, braceIdx).trim(), raw })
130
+ }
131
+ }
132
+ }
133
+
134
+ return blocks
135
+ }
136
+
137
+ /**
138
+ * Return true if any selector in the rule should be kept.
139
+ *
140
+ * Matching strategy: check the *rightmost* class token in the selector (the
141
+ * subject of the rule). A rule like `.parent .child` should survive only when
142
+ * `.child` is used — keeping it whenever `.parent` is used would retain dead
143
+ * rules whose actual target class was never referenced.
144
+ *
145
+ * Pseudo-classes/elements and attribute selectors are stripped before checking
146
+ * so `.btn:hover` is treated as `.btn`, `.btn--active::before` as `.btn--active`.
147
+ *
148
+ * Element-only selectors (`a`, `*`, `:root`) and rules that contain no class
149
+ * tokens at all are always kept.
150
+ */
151
+ export function selectorUsed(selector, usedClasses) {
152
+ const selectors = selector.split(',').map(s => s.trim())
153
+
154
+ for (const sel of selectors) {
155
+ // Strip pseudo-classes/elements and attribute selectors to find the base
156
+ const base = sel
157
+ .replace(/::?[a-z-]+(\([^)]*\))?/gi, '')
158
+ .replace(/\[[^\]]*\]/g, '')
159
+ .trim()
160
+
161
+ // Keep element-only selectors — no class tokens present
162
+ if (!base.includes('.')) return true
163
+
164
+ // Extract all class tokens and check the rightmost one (the rule subject).
165
+ // Using the rightmost class correctly handles descendant selectors:
166
+ // .nav .link → subject is .link
167
+ // .btn--active → subject is .btn--active
168
+ const classes = [...base.matchAll(/\.([a-zA-Z][a-zA-Z0-9_-]*)/g)].map(m => m[1])
169
+ if (classes.length === 0) return true
170
+ const subject = classes[classes.length - 1]
171
+ if (usedClasses.has(subject)) return true
172
+
173
+ // Check RegExp safelist patterns if any were provided
174
+ const patterns = usedClasses._safelistPatterns
175
+ if (patterns && patterns.some(re => re.test(subject))) return true
176
+ }
177
+
178
+ return false
179
+ }
180
+
181
+ /**
182
+ * Minify a CSS string. String-aware: content inside '...' or "..." is left
183
+ * untouched so quoted URLs (e.g. Google Fonts @import with ; and : in the
184
+ * query string) survive intact.
185
+ */
186
+ export function minifyCss(css) {
187
+ // Protect quoted strings from the whitespace/punctuation passes
188
+ const strings = []
189
+ const protectedCss = css.replace(/(['"])(?:\\.|(?!\1).)*\1/g, (m) => {
190
+ strings.push(m)
191
+ return `\x00S${strings.length - 1}\x00`
192
+ })
193
+
194
+ const minified = protectedCss
195
+ .replace(/\/\*[\s\S]*?\*\//g, '') // strip comments
196
+ .replace(/\s+/g, ' ') // collapse whitespace
197
+ // Remove spaces around safe punctuation — NOT + or - (would break calc() expressions)
198
+ .replace(/\s*([{}:;,>~])\s*/g, '$1')
199
+ .replace(/;}/g, '}') // remove trailing semicolons
200
+ .trim()
201
+
202
+ return minified.replace(/\x00S(\d+)\x00/g, (_, i) => strings[+i])
203
+ }
204
+
205
+ /**
206
+ * Purge unused CSS rules from a CSS string.
207
+ * Recursively processes @media and @supports blocks.
208
+ */
209
+ export function purgeCss(cssText, usedClasses) {
210
+ const blocks = parseCssBlocks(cssText)
211
+ const kept = []
212
+
213
+ for (const block of blocks) {
214
+ if (block.type === 'at-simple') {
215
+ kept.push(block.raw)
216
+ } else if (block.type === 'at-block') {
217
+ const p = block.prelude.toLowerCase()
218
+ if (/^@(?:keyframes|font-face|charset)/.test(p)) {
219
+ kept.push(block.raw)
220
+ } else if (/^@(?:media|supports)/.test(p)) {
221
+ const innerPurged = purgeCss(block.inner, usedClasses)
222
+ if (innerPurged.trim()) kept.push(`${block.prelude} {\n${innerPurged}\n}`)
223
+ } else if (/^@layer/.test(p)) {
224
+ // Always preserve empty @layer declarations — they establish cascade order
225
+ // and removing them silently changes specificity even when no rules remain.
226
+ const innerPurged = purgeCss(block.inner, usedClasses)
227
+ if (innerPurged.trim()) {
228
+ kept.push(`${block.prelude} {\n${innerPurged}\n}`)
229
+ } else {
230
+ kept.push(`${block.prelude} {}`)
231
+ }
232
+ } else {
233
+ kept.push(block.raw) // unknown at-blocks — keep to be safe
234
+ }
235
+ } else if (block.type === 'rule') {
236
+ if (selectorUsed(block.selector, usedClasses)) kept.push(block.raw)
237
+ }
238
+ }
239
+
240
+ return kept.join('\n\n')
241
+ }
@@ -0,0 +1,94 @@
1
+ /**
2
+ * CSS purge/minify tests — run with: node scripts/css-purge.test.js
3
+ *
4
+ * Regression focus: @import URLs containing `;` and `&` in their query string
5
+ * (Google Fonts) were truncated by the production build — the block parser
6
+ * split the statement at the `;` inside the quoted URL, silently breaking the
7
+ * theme in production while dev (which serves CSS unprocessed) looked fine.
8
+ */
9
+
10
+ import { test } from 'node:test'
11
+ import assert from 'node:assert/strict'
12
+ import { parseCssBlocks, purgeCss, minifyCss, extractUsedClasses, selectorUsed } from './css-purge.js'
13
+
14
+ const FONT_IMPORT = `@import url('https://fonts.googleapis.com/css2?family=Inter:wght@400;600&display=swap');`
15
+
16
+ // ---------------------------------------------------------------------------
17
+ // The @import regression
18
+ // ---------------------------------------------------------------------------
19
+
20
+ test('parseCssBlocks: does not split @import at a ; inside a quoted URL', () => {
21
+ const css = `${FONT_IMPORT}\n:root { --accent: #e25; }`
22
+ const blocks = parseCssBlocks(css)
23
+ assert.equal(blocks.length, 2, `Expected 2 blocks, got ${blocks.length}: ${JSON.stringify(blocks.map(b => b.raw))}`)
24
+ assert.equal(blocks[0].type, 'at-simple')
25
+ assert.equal(blocks[0].raw, FONT_IMPORT)
26
+ })
27
+
28
+ test('parseCssBlocks: handles double-quoted URLs and escapes', () => {
29
+ const css = `@import url("https://example.com/a;b&c=d");\n.x { color: red; }`
30
+ const blocks = parseCssBlocks(css)
31
+ assert.equal(blocks[0].raw, `@import url("https://example.com/a;b&c=d");`)
32
+ })
33
+
34
+ test('purgeCss + minifyCss: Google Fonts @import survives the full pipeline intact', () => {
35
+ const css = `${FONT_IMPORT}\n:root { --font: 'Inter', sans-serif; }\n.unused { color: red; }`
36
+ const out = minifyCss(purgeCss(css, new Set([])))
37
+ assert.ok(
38
+ out.includes(`url('https://fonts.googleapis.com/css2?family=Inter:wght@400;600&display=swap')`),
39
+ `@import URL was mangled by the build pipeline. Output: ${out}`
40
+ )
41
+ })
42
+
43
+ test('minifyCss: quoted strings are untouched by punctuation collapsing', () => {
44
+ const css = `.x { content: "a ; b : c , d" ; }`
45
+ const out = minifyCss(css)
46
+ assert.ok(out.includes(`"a ; b : c , d"`), `Quoted string was altered: ${out}`)
47
+ })
48
+
49
+ // ---------------------------------------------------------------------------
50
+ // Behaviour preserved through the extraction refactor
51
+ // ---------------------------------------------------------------------------
52
+
53
+ test('purgeCss: drops unused class rules, keeps used ones', () => {
54
+ const css = `.used { color: red; }\n.unused { color: blue; }`
55
+ const out = purgeCss(css, new Set(['used']))
56
+ assert.ok(out.includes('.used'))
57
+ assert.ok(!out.includes('.unused'))
58
+ })
59
+
60
+ test('purgeCss: element and :root rules are always kept', () => {
61
+ const css = `:root { --x: 1; }\nbody { margin: 0; }`
62
+ const out = purgeCss(css, new Set([]))
63
+ assert.ok(out.includes(':root'))
64
+ assert.ok(out.includes('body'))
65
+ })
66
+
67
+ test('purgeCss: recurses into @media and keeps @font-face', () => {
68
+ const css = `@media (min-width: 600px) { .used { color: red; } .unused { color: blue; } }\n@font-face { font-family: X; src: url('/x.woff2'); }`
69
+ const out = purgeCss(css, new Set(['used']))
70
+ assert.ok(out.includes('.used'))
71
+ assert.ok(!out.includes('.unused'))
72
+ assert.ok(out.includes('@font-face'))
73
+ })
74
+
75
+ test('selectorUsed: rightmost class is the subject', () => {
76
+ assert.equal(selectorUsed('.nav .link', new Set(['link'])), true)
77
+ assert.equal(selectorUsed('.nav .link', new Set(['nav'])), false)
78
+ assert.equal(selectorUsed('.btn:hover', new Set(['btn'])), true)
79
+ })
80
+
81
+ test('extractUsedClasses: finds class attributes and hyphenated JS strings', () => {
82
+ const used = extractUsedClasses(
83
+ ['<div class="hero card">'],
84
+ [`el.classList.add('ui-btn--active')`]
85
+ )
86
+ assert.ok(used.has('hero'))
87
+ assert.ok(used.has('card'))
88
+ assert.ok(used.has('ui-btn--active'))
89
+ })
90
+
91
+ test('minifyCss: calc() expressions keep their operator spacing', () => {
92
+ const out = minifyCss(`.x { width: calc(100% - 2rem); }`)
93
+ assert.ok(out.includes('calc(100% - 2rem)'), `calc broken: ${out}`)
94
+ })
@@ -20,6 +20,8 @@ Before finishing any spec, verify every point below. Fix anything that fails.
20
20
 
21
21
  - **Do not set `hydrate` in specs.** It is auto-derived by the framework from the URL entry passed to `createServer`. Specs with `mutations`, `actions`, or `persist` are hydrated automatically. Purely server-rendered specs get zero JavaScript — no configuration needed.
22
22
 
23
+ - **The default theme is DARK.** When `meta.theme` is unset, the page renders with the dark palette. If the design calls for a light page, set `meta.theme: 'light'` when you **first write the spec** — do not assume light and discover dark at the screenshot, which costs an edit → restart → re-approval cycle. Declare the theme in your plan/build brief, and ask the user if the brief doesn't say.
24
+
23
25
  - **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
26
 
25
27
  ### Components first — or creative override
@@ -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. 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"]`; it waits for the answer without ending the turn. If you must ask in plain prose and end your turn, call `pulse_await_approval` first so the Stop hooks let the turn end.
18
+ 2. **Call `pulse_await_approval` first**, then 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. Always call the tool before asking, whichever way you ask: in some hosts (including Claude Code) even a question tool ends the turn and fires the Stop hooks. The marker is harmless if the turn doesn't end — it is consumed when the user replies.
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. If a `VERIFY REQUIRED` stop-hook block fires while your question is unanswered, that is not permission to continue — call `pulse_await_approval`, re-ask, and end your turn.
20
20
 
21
21
  ### 3. Validate the spec
@@ -46,7 +46,7 @@ The browser should still be on `http://localhost:3001/` from step 6. Run `mcp__c
46
46
 
47
47
  **Same pass bar: Accessibility, Best Practices, and SEO must all be 100.** Fix any failures and restart from step 3.
48
48
 
49
- After both Lighthouse runs pass, call `pulse_restart_server` to return to the dev server.
49
+ **Stay on the production server (port 3001)** — steps 8 and 9 use it too. Do not call `pulse_restart_server` yet.
50
50
 
51
51
  ### 8. Mobile layout check
52
52
 
@@ -54,7 +54,7 @@ Emulate a mobile viewport and take a screenshot to catch wrapping, overflow, and
54
54
 
55
55
  ```
56
56
  mcp__chrome-devtools__emulate viewport: "390x844x2,mobile,touch"
57
- mcp__chrome-devtools__navigate_page url: "http://localhost:3000/"
57
+ mcp__chrome-devtools__navigate_page url: "http://localhost:3001/"
58
58
  mcp__chrome-devtools__take_screenshot
59
59
  ```
60
60
 
@@ -72,7 +72,9 @@ mcp__chrome-devtools__emulate viewport: "1440x900x1"
72
72
 
73
73
  ### 9. Performance
74
74
 
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.).
75
+ **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
+
77
+ After the trace, call `pulse_restart_server` to return to the dev server.
76
78
 
77
79
  ### 10. Console errors
78
80
 
@@ -16,6 +16,10 @@ export default {
16
16
  title: 'Page Title', // string or async (ctx) => string
17
17
  description: 'Meta description', // string or async (ctx) => string
18
18
  styles: ['/app.css'], // string[] or (ctx) => string[]
19
+ theme: 'light', // 'light' | 'dark' — ⚠ DEFAULT IS DARK when unset.
20
+ // Decide at write time: a light design needs
21
+ // meta.theme: 'light' or the page renders dark.
22
+ vibe: 'minimal', // optional — radius/weight/spacing preset
19
23
  ogTitle: 'Social title', // optional — falls back to title
20
24
  ogImage: 'https://…/og.jpg', // optional — 1200×630 recommended
21
25
  canonical: 'https://mysite.com/…', // optional — string or (ctx) => string
@@ -199,6 +199,8 @@ The default CSP does **not** restrict `img-src` — it inherits `default-src 'se
199
199
 
200
200
  **Do this at build time, before writing any image tags.** If you know you'll use external images, add the CSP entry to `pulse.config.js` first — discovering the block at Lighthouse time costs an extra round-trip.
201
201
 
202
+ **Use `pulse_check_csp` to get the origin list right first time.** Many hosts redirect to a CDN origin (picsum.photos → fastly.picsum.photos), and allowing only the URL you wrote still blocks the actual bytes. Pass it the URLs you plan to use and it follows each redirect chain and returns ready-to-paste CSP config with every origin included.
203
+
202
204
  ```js
203
205
  // pulse.config.js
204
206
  export default {
@@ -213,7 +215,7 @@ Or via `createServer`:
213
215
  ```js
214
216
  createServer(specs, {
215
217
  csp: {
216
- 'img-src': ['https://images.unsplash.com', 'https://picsum.photos'],
218
+ 'img-src': ['https://images.unsplash.com', 'https://picsum.photos', 'https://fastly.picsum.photos'],
217
219
  },
218
220
  })
219
221
  ```
@@ -53,7 +53,7 @@ Check for:
53
53
  - Layout looks correct
54
54
  - No console errors via list_console_messages
55
55
 
56
- Then ask the user: *"Happy with the design and layout, or would you like any changes before I run tests and Lighthouse?"* — use your host's question tool if it has one (e.g. AskUserQuestion); it waits for the answer without ending the turn. If you must ask in plain prose and end your turn, call `pulse_await_approval` first so the Stop hooks let the turn end. **Do not proceed until the user explicitly approves.** If a `VERIFY REQUIRED` stop-hook block fires while your question is unanswered, that is not permission to continue — call `pulse_await_approval`, re-ask, and end your turn.
56
+ Then **call `pulse_await_approval`** and ask the user: *"Happy with the design and layout, or would you like any changes before I run tests and Lighthouse?"* Always call the tool before asking, even when using a question tool like AskUserQuestion — in some hosts (including Claude Code) the question still ends the turn and fires the Stop hooks; the marker is harmless if the turn doesn't end. **Do not proceed until the user explicitly approves.** If a `VERIFY REQUIRED` stop-hook block fires while your question is unanswered, that is not permission to continue — call `pulse_await_approval`, re-ask, and end your turn.
57
57
 
58
58
  ### 7. Lighthouse
59
59
 
@@ -34,7 +34,7 @@ Navigate to the page route with `mcp__chrome-devtools__navigate_page`, then take
34
34
  **If `pulse_intake` ran earlier in this session** (i.e. this is a new page or site, not an edit or bug fix):
35
35
 
36
36
  1. Show the screenshot to the user.
37
- 2. Ask: *"Happy with the layout and direction, or any changes before I run Lighthouse?"* — use your host's question tool if it has one (e.g. AskUserQuestion); it waits for the answer without ending the turn. If you must ask in plain prose and end your turn, call `pulse_await_approval` first so the Stop hooks let the turn end.
37
+ 2. **Call `pulse_await_approval` first, then ask:** *"Happy with the layout and direction, or any changes before I run Lighthouse?"* Always call the tool before asking, even when using a question tool like AskUserQuestion — in some hosts (including Claude Code) the question still ends the turn and fires the Stop hooks. The marker is harmless if the turn doesn't end; it is consumed when the user replies.
38
38
  3. **Stop and wait for their response.** Do not proceed to step 4b or Lighthouse until the user explicitly approves. If a `VERIFY REQUIRED` stop-hook block fires while your question is unanswered, that is not permission to continue — call `pulse_await_approval`, re-ask, and end your turn.
39
39
 
40
40
  If the user requests changes: make edits, restart the server, take a new screenshot, describe what changed, ask again. Repeat until approved. No Lighthouse between design rounds.
@@ -67,7 +67,7 @@ Run `mcp__chrome-devtools__lighthouse_audit` with `device: "mobile"` against the
67
67
 
68
68
  ### 8. Performance
69
69
 
70
- Navigate to the page route, then run `mcp__chrome-devtools__performance_start_trace` with `reload: true` and `autoStop: true`. Report LCP and CLS as a single line, e.g. `LCP 73ms · CLS 0.00`. Flag any insight that identifies a fixable cause (render-blocking resources, large image delay). CLS must be 0.00.
70
+ **Stay on the production server (port 3001) for the trace** — navigate to `http://localhost:3001/<route>` first; 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 as a single line, e.g. `LCP 73ms · CLS 0.00`. Flag any insight that identifies a fixable cause (render-blocking resources, large image delay). CLS must be 0.00.
71
71
 
72
72
  ### 9. Console errors
73
73
 
@@ -32,7 +32,7 @@ For any **new page, landing page, or branded site**, run the intake sequence fir
32
32
 
33
33
  - **Step 0 — Ask for inspiration first (mandatory):** Before calling any tool or presenting any plan, ask the user: *"Do you have any design inspiration — a site you love, a screenshot, or a mood board image? Drop images into `public/intake/` or paste a URL and I'll extract the design intent before we start."* Wait for the answer. Do not skip this question, even if the user's brief already seems detailed. Check `public/intake/` for any images already dropped there.
34
34
  - **`pulse_extract_inspiration`** — call this after step 0 if the user supplies a URL, site name, or image they like. Also call it for any images already present in `public/intake/`. Gives you a structured extraction template: visit the URL or analyse the image with your vision tools, extract colours, layout, typography, and feel, then feed those findings into pulse_intake. If the user explicitly says they have no reference, skip this step.
35
- - **`pulse_intake`** — captures the real content (name, pitch, features, palette, vibe, what it should NOT look like). Returns a brief with copy-ready content and early contrast warnings. Always ask the user one question at a time — never multi-choice for open-ended questions.
35
+ - **`pulse_intake`** — captures the real content (name, pitch, features, palette, theme, vibe, what it should NOT look like). Returns a brief with copy-ready content and early contrast warnings. Always ask the user one question at a time — never multi-choice for open-ended questions. **Always ask light or dark** — Pulse renders dark when `meta.theme` is unset, so an unstated light design ships dark.
36
36
  - **`pulse_sketch`** — generates 3 structurally distinct layout directions (full-bleed, asymmetric split, typography-only, editorial, dense grid, story scroll, content-first). **Call this before writing any code.** Prevents defaulting to centred hero + three-column features every time.
37
37
  - **Skip `pulse_sketch` only** when the vibe is `corporate` or `minimal` AND the user hasn't expressed any structural preference. For `playful`, `bold`, `brutalist`, `retro`, or `neon` vibes, `pulse_sketch` is mandatory — templates have default section orders that actively fight these vibes.
38
38
  - **`pulse_intent`** — maps the chosen direction to a spec scaffold and tells you which guides to read.
@@ -70,6 +70,7 @@ Do not fetch every guide section for every task. Fetch what you need.
70
70
  Decide the complexity tier (see below) and confirm with the user if the task is non-trivial. Output a concise plan:
71
71
 
72
72
  - Route, state shape, mutations/actions, server fetchers
73
+ - **Theme: light or dark** — Pulse defaults to **dark** when `meta.theme` is unset. Decide now (ask the user if the brief doesn't say) — discovering a wrong theme at the screenshot costs a full edit → restart → re-approval cycle.
73
74
  - Which UI components you will use
74
75
  - Files you will create or modify
75
76
 
@@ -179,6 +180,7 @@ Before writing any file, output a build brief:
179
180
  ```
180
181
  Building: <page name> (<route>)
181
182
  Mode: A — components first | B — creative override (raw HTML, reason: ...)
183
+ Theme: light | dark (+ vibe, if any) — default is DARK when meta.theme is unset
182
184
  State: <fields and types, or "none">
183
185
  Mutations: <list, or "none">
184
186
  Actions: <list with brief description, or "none">
@@ -242,8 +244,8 @@ Do this before every screenshot, console check, or visual inspection. Never debu
242
244
 
243
245
  **How to wait without tripping the Stop hooks.** The project's Stop hooks (missing tests, coverage, verify stamp) block any turn that ends with unverified spec edits — they cannot tell "abandoning the work" from "pausing for the user's answer". So:
244
246
 
245
- - **Preferred:** ask the question with your host's question tool (e.g. AskUserQuestion in Claude Code). A question tool waits for the answer *without ending the turn*, so the Stop hooks never fire.
246
- - **Otherwise:** call `pulse_await_approval` immediately before asking in plain prose and ending your turn. It writes a one-turn marker the Stop hooks respect; the marker is consumed when the user replies.
247
+ - **Always call `pulse_await_approval` immediately before asking** — regardless of whether you ask via a question tool or in plain prose. In some hosts (including Claude Code) even AskUserQuestion ends the turn and fires the Stop hooks. The marker is harmless if the turn doesn't end: it is simply consumed when the user replies.
248
+ - Then ask the question (question tool with choices where available, plain prose otherwise) and end your turn.
247
249
  - **Never** respond to a `VERIFY REQUIRED` block by running `/verify` while your approval question is unanswered. The block does not override the approval gate — an unanswered question means the design is not approved, and Lighthouse on an unapproved design wastes ~90 seconds and pre-empts the user's changes.
248
250
 
249
251
  **If the user requests changes:**
@@ -274,6 +276,16 @@ Do this before every screenshot, console check, or visual inspection. Never debu
274
276
  2. `pulse_layout_review <url>` — 390/768/1280px overflow, broken images, collapsed sections
275
277
  3. `/verify` — Lighthouse desktop + mobile (100/100/100) + CLS 0.00 + `pulse_review`
276
278
 
279
+ **Two different reviews — don't confuse them.** `pulse_design_review` is the *visual* review (7 signals) and runs **before** Lighthouse. `pulse_review` is the *code* review and runs **after** Lighthouse, always last. The canonical end-to-end order is:
280
+
281
+ ```
282
+ pulse_validate → design approval → pulse_design_review → pulse_layout_review
283
+ → pulse_build → Lighthouse desktop + mobile → performance trace
284
+ → pulse_review → pulse_stamp
285
+ ```
286
+
287
+ If a step's fixes touch the spec, re-run from `pulse_validate` — never re-run `pulse_review` on code that hasn't re-passed Lighthouse.
288
+
277
289
  **Do not report the page as done until `/verify` writes the `.pulse-verified` stamp.** Skipping Lighthouse means broken contrast, missing landmarks, and Best Practices failures ship to the user.
278
290
 
279
291
  Pass gates:
@@ -261,7 +261,61 @@ test('approval pause mechanism is wired end to end', () => {
261
261
 
262
262
  // 5. The workflow must teach the mechanism at the design-approval gate
263
263
  assert(workflow.includes('pulse_await_approval'),
264
- 'workflow.md Phase 5a must explain how to pause: host question tool preferred, pulse_await_approval otherwise')
264
+ 'workflow.md Phase 5a must explain how to pause: always pulse_await_approval before asking')
265
+
266
+ // 6. No doc may claim a host question tool avoids ending the turn — field
267
+ // testing showed AskUserQuestion in Claude Code still ends the turn and
268
+ // fires the Stop hooks. The safe instruction is: always write the marker
269
+ // first, regardless of how the question is asked.
270
+ const claimDocs = agentDocs.filter(f =>
271
+ fs.existsSync(path.join(ROOT, f)) &&
272
+ /without ending the turn/i.test(fs.readFileSync(path.join(ROOT, f), 'utf8'))
273
+ )
274
+ if (/without ending the turn.*Stop hooks never fire/is.test(serverSrc)) claimDocs.push('src/mcp/server.js')
275
+ assert(claimDocs.length === 0,
276
+ `These docs claim a question tool avoids ending the turn (empirically false in Claude Code): ${claimDocs.join(', ')}. ` +
277
+ `Instruct: always call pulse_await_approval before asking, whichever way the question is asked.`)
278
+ })
279
+
280
+ test('pulse_check_contrast extracts variables from [data-theme="light"] blocks', () => {
281
+ // Regression: the light-theme block pattern was an ungrouped alternation
282
+ // (a|b) + '\\s*\\{([^}]+)\\}' — the body matcher bound only to the second
283
+ // alternative, so [data-theme="light"] blocks matched with no body capture
284
+ // and the checker silently reported nothing for the light theme.
285
+ // Recreate extractVars with the exact pattern strings from server.js source.
286
+ const patterns = [...serverSrc.matchAll(/extractVars\(source,\s*'((?:[^'\\]|\\.)*)'\)/g)].map(m =>
287
+ m[1].replace(/\\\\/g, '\\').replace(/\\'/g, "'")
288
+ )
289
+ assert(patterns.length === 2, `Expected 2 extractVars call patterns in server.js, found ${patterns.length}`)
290
+ const lightPattern = patterns.find(p => p.includes('data-theme'))
291
+ assert(lightPattern, 'No data-theme pattern found in extractVars calls')
292
+
293
+ const sampleCss = `:root { --accent: #112233; }\n[data-theme="light"] { --accent: #445566; --bg: #ffffff; }`
294
+ const blockRegex = new RegExp(lightPattern + '\\s*\\{([^}]+)\\}', 'gi')
295
+ const m = blockRegex.exec(sampleCss)
296
+ assert(m && m[1] && m[1].includes('--accent'),
297
+ `The light-theme block pattern in pulse_check_contrast fails to capture the block body. ` +
298
+ `Pattern: ${lightPattern} — alternations must be wrapped in (?:...).`)
299
+ })
300
+
301
+ test('dark theme default is declared at every spec-writing entry point', () => {
302
+ // Agents repeatedly assumed the default theme is light, built the page, then
303
+ // discovered dark at the screenshot — costing an edit → restart → re-approval
304
+ // cycle. The dark default must be stated wherever a spec is first written:
305
+ // the spec skeleton, the plan/build brief, and the checklist.
306
+ const guideSpec = fs.readFileSync(path.join(agentDir, 'guide-spec.md'), 'utf8')
307
+ const workflow = fs.readFileSync(path.join(agentDir, 'workflow.md'), 'utf8')
308
+ const checklist = fs.readFileSync(path.join(agentDir, 'checklist.md'), 'utf8')
309
+ const claudeMd = fs.readFileSync(path.join(ROOT, 'CLAUDE.md'), 'utf8')
310
+
311
+ assert(/theme:/.test(guideSpec) && /DEFAULT IS DARK/i.test(guideSpec),
312
+ 'guide-spec.md meta skeleton must include the theme field with the dark-default warning')
313
+ assert(/Theme:\s+light \| dark/.test(workflow),
314
+ 'workflow.md build brief template must include a Theme line')
315
+ assert(/default theme is DARK/i.test(checklist),
316
+ 'checklist.md Critical section must state the dark default')
317
+ assert(/theme:\s+'light'/.test(claudeMd) && /default is DARK/i.test(claudeMd),
318
+ 'CLAUDE.md spec meta block must show theme with the dark-default warning')
265
319
  })
266
320
 
267
321
  test('identity.md declares the creative override (Design Freedom) carve-out', () => {
package/src/mcp/server.js CHANGED
@@ -1210,11 +1210,11 @@ The stop hook compares each changed spec's mtime against this stamp. Any spec ne
1210
1210
  server.registerTool(
1211
1211
  'pulse_await_approval',
1212
1212
  {
1213
- description: `Pause the workflow to wait for the user's answer — call this immediately BEFORE ending your turn with a blocking question (e.g. the design-approval gate: "Happy with the layout and direction…?").
1213
+ description: `Pause the workflow to wait for the user's answer — call this immediately BEFORE asking a blocking question (e.g. the design-approval gate: "Happy with the layout and direction…?").
1214
1214
 
1215
- It writes a .pulse-awaiting-approval marker that tells the Stop hooks (missing tests, coverage, verify stamp) to let this one turn end without demanding /verify first. The marker is deleted automatically when the user sends their next message — the gates return in full force for your following turn.
1215
+ It writes a .pulse-awaiting-approval marker that tells the Stop hooks (missing tests, coverage, verify stamp) to let the turn end without demanding /verify first. The marker is deleted automatically when the user sends their next message — the gates return in full force for your following turn.
1216
1216
 
1217
- **Prefer your host's question tool if it has one** (e.g. AskUserQuestion in Claude Code): a question tool waits for the answer *without ending the turn*, so the Stop hooks never fire and this marker is unnecessary. Use pulse_await_approval only when you must ask in plain prose and end the turn.
1217
+ **Always call this before the approval question, regardless of how you ask.** In some hosts (including Claude Code) even a dedicated question tool like AskUserQuestion ends the turn, which fires the Stop hooks. The marker is harmless if the turn does not end — it is simply consumed when the user replies. Calling it first works in every host; relying on the question tool to keep the turn open does not.
1218
1218
 
1219
1219
  Do NOT use this to skip verification — it buys exactly one turn-end, paired with a question the user must answer. Calling it without asking anything just delays the gates by one message.`,
1220
1220
  inputSchema: {},
@@ -2099,6 +2099,7 @@ Returns observations grouped by category: completeness, data flow, components, s
2099
2099
  if (!content.includes('title:')) quickWins.push('`meta.title` is missing — every page needs a unique title.')
2100
2100
  if (!content.includes('description:')) quickWins.push('`meta.description` is missing — used for SEO and social previews.')
2101
2101
  if (!content.includes('styles:')) quickWins.push('`meta.styles` not set — add `[\'/pulse-ui.css\']` if you are using any UI components.')
2102
+ if (!content.includes('theme:')) quickWins.push('`meta.theme` not set — Pulse renders DARK by default. If this design is light, add `theme: \'light\'` now rather than discovering a dark page at the screenshot.')
2102
2103
  }
2103
2104
 
2104
2105
  // --- Export ---
@@ -2432,7 +2433,7 @@ Accepts antiStyle ("what should this NOT look like?") and inspiration (a site or
2432
2433
  targetUser: z.string().optional().describe('Who this is for — shapes copy tone. E.g. "busy professionals", "indie developers", "home cooks"'),
2433
2434
  palette: z.string().optional().describe('Brand colours as hex values, comma-separated. E.g. "#6366f1, #f8fafc, #1e1b4b". Used for early contrast check.'),
2434
2435
  font: z.string().optional().describe('Brand font name, or omit for system-ui'),
2435
- theme: z.enum(['light', 'dark']).optional().describe('Colour theme preference'),
2436
+ theme: z.enum(['light', 'dark']).optional().describe('Colour theme — ALWAYS ask the user for this; Pulse renders DARK when meta.theme is unset, so an unstated light design ships dark and costs a rework cycle. Pass the answer through to meta.theme in the spec.'),
2436
2437
  vibe: z.enum(['warm', 'editorial', 'playful', 'minimal', 'bold', 'brutalist', 'retro', 'corporate', 'neon', 'paper']).optional().describe('Visual personality — see pulse://guide/design-references for full descriptions. warm (rounded, inviting), editorial (serif, sharp), playful (very rounded), minimal (clean), bold (impact headings), brutalist (zero radius, raw), retro (slab serif, nostalgic), corporate (conservative), neon (monospace, futuristic), paper (organic, serif, journal)'),
2437
2438
  styleNotes: z.string().optional().describe('Free-form style direction — e.g. "like a print poster", "market stall chalkboard feel", "clinical and professional"'),
2438
2439
  antiStyle: z.string().optional().describe('"What should this NOT look like?" — negative aesthetic constraint. E.g. "not corporate SaaS", "not another Vercel dark card page", "not a startup landing page". Used to steer component and layout choices away from over-used patterns.'),
@@ -2997,6 +2998,78 @@ Returns tokens grouped by category.`,
2997
2998
  }
2998
2999
  )
2999
3000
 
3001
+ server.registerTool(
3002
+ 'pulse_check_csp',
3003
+ {
3004
+ description: `Resolve the redirect chains of external asset URLs (images, fonts, stylesheets) and return the complete set of origins your CSP must allow — as ready-to-paste config.
3005
+
3006
+ Many asset hosts redirect to a CDN origin (picsum.photos → fastly.picsum.photos): allowing only the URL you wrote still blocks the actual bytes, and the browser error names the CDN origin, which is confusing to debug. This tool follows each redirect chain (up to 5 hops) and reports every origin involved.
3007
+
3008
+ Call it with the external URLs you plan to use, before your first Lighthouse run. Suggested directive: img-src for images, font-src for font files, style-src for stylesheets.`,
3009
+ inputSchema: {
3010
+ urls: z.array(z.string()).describe('External asset URLs to check, e.g. ["https://picsum.photos/id/10/1200/600"]'),
3011
+ directive: z.string().optional().describe("CSP directive these assets belong to (default: 'img-src')"),
3012
+ },
3013
+ },
3014
+ async ({ urls, directive = 'img-src' }) => {
3015
+ const MAX_HOPS = 5
3016
+ const origins = new Set()
3017
+ const lines = []
3018
+
3019
+ for (const rawUrl of urls.slice(0, 10)) {
3020
+ let url
3021
+ try { url = new URL(rawUrl) } catch { lines.push(`✗ ${rawUrl} — not a valid URL`); continue }
3022
+
3023
+ const chain = [url.origin]
3024
+ let blocked = null
3025
+ try {
3026
+ for (let hop = 0; hop < MAX_HOPS; hop++) {
3027
+ const res = await fetch(url, { method: 'HEAD', redirect: 'manual', signal: AbortSignal.timeout(5000) })
3028
+ if (res.status >= 300 && res.status < 400 && res.headers.get('location')) {
3029
+ url = new URL(res.headers.get('location'), url)
3030
+ chain.push(url.origin)
3031
+ continue
3032
+ }
3033
+ if (res.status === 405) {
3034
+ // Host rejects HEAD — confirm with a ranged GET so we still see redirects
3035
+ const get = await fetch(url, { method: 'GET', redirect: 'manual', headers: { Range: 'bytes=0-0' }, signal: AbortSignal.timeout(5000) })
3036
+ if (get.status >= 300 && get.status < 400 && get.headers.get('location')) {
3037
+ url = new URL(get.headers.get('location'), url)
3038
+ chain.push(url.origin)
3039
+ continue
3040
+ }
3041
+ }
3042
+ break
3043
+ }
3044
+ } catch (err) {
3045
+ blocked = err?.name === 'TimeoutError' ? 'timed out' : (err?.message || 'request failed')
3046
+ }
3047
+
3048
+ const uniqueChain = [...new Set(chain)]
3049
+ for (const o of uniqueChain) origins.add(o)
3050
+ lines.push(
3051
+ blocked
3052
+ ? `⚠ ${rawUrl} — ${blocked}; allowing origin from the URL only: ${uniqueChain.join(' → ')}`
3053
+ : uniqueChain.length > 1
3054
+ ? `↪ ${rawUrl} — redirects: ${uniqueChain.join(' → ')} (allow ALL of these)`
3055
+ : `✓ ${rawUrl} — no redirect, origin: ${uniqueChain[0]}`
3056
+ )
3057
+ }
3058
+
3059
+ const config = [...origins].map(o => `'${o}'`).join(', ')
3060
+ lines.push('')
3061
+ lines.push('Ready to paste:')
3062
+ lines.push('```js')
3063
+ lines.push(`csp: {`)
3064
+ lines.push(` '${directive}': [${[...origins].map(o => `'${o}'`).join(', ')}],`)
3065
+ lines.push(`}`)
3066
+ lines.push('```')
3067
+ if (config.includes('http://')) lines.push('⚠ An origin resolved to plain http: — use https URLs in production.')
3068
+
3069
+ return text(lines.join('\n'))
3070
+ }
3071
+ )
3072
+
3000
3073
  server.registerTool(
3001
3074
  'pulse_check_contrast',
3002
3075
  {
@@ -3036,8 +3109,12 @@ Run this immediately after writing a theme file — before production build and
3036
3109
  return vars
3037
3110
  }
3038
3111
 
3112
+ // NOTE: alternations must be wrapped in a non-capturing group. An ungrouped
3113
+ // pattern (a|b) followed by '\\s*\\{...' binds the body matcher to the second
3114
+ // alternative only — [data-theme="light"] blocks then match the selector
3115
+ // alone with no body capture, silently returning zero variables.
3039
3116
  const rootVars = extractVars(source, ':root')
3040
- const lightVars = extractVars(source, '\\[data-theme=["\']light["\']\\]|[.]ui-theme-light')
3117
+ const lightVars = extractVars(source, '(?:\\[data-theme=["\']light["\']\\]|[.]ui-theme-light)')
3041
3118
 
3042
3119
  // pulse-ui.css maps --NAME → --ui-NAME (e.g. --accent → --ui-accent).
3043
3120
  // Theme files written per the guide define bare --* tokens. Synthesize the
@@ -24,6 +24,6 @@ export function dropCap({
24
24
  const classes = ['ui-drop-cap', cls].filter(Boolean).join(' ')
25
25
 
26
26
  return `<p class="${e(classes)}">
27
- <span class="ui-drop-cap-letter" style="float:left;font-size:${lines * 1.2}em;line-height:${lines * 0.8};margin-right:0.1em" aria-hidden="true">${e(letter)}</span>${e(content)}
27
+ <span class="ui-drop-cap-letter" style="font-size:${+(lines * 1.2).toFixed(2)}em" aria-hidden="true">${e(letter)}</span>${e(content)}
28
28
  </p>`
29
29
  }
package/src/ui/gallery.js CHANGED
@@ -11,6 +11,9 @@
11
11
  * @param {'sm'|'md'|'lg'} opts.gap - Gap between images (default: 'md')
12
12
  * @param {boolean} opts.rounded - Rounded corners on images
13
13
  * @param {string} opts.ratio - CSS aspect-ratio for grid/strip cells e.g. '4/3', '1/1', '16/9'
14
+ * @param {boolean} opts.lazy - loading="lazy" on images (default: true). Set false for
15
+ * above-the-fold galleries or when full-page screenshots
16
+ * during development need every image rendered eagerly.
14
17
  * @param {string} opts.class
15
18
  */
16
19
 
@@ -26,6 +29,7 @@ export function gallery({
26
29
  gap = 'md',
27
30
  rounded = false,
28
31
  ratio = '4/3',
32
+ lazy = true,
29
33
  class: cls = '',
30
34
  } = {}) {
31
35
  if (!LAYOUTS.has(layout)) layout = 'grid'
@@ -42,7 +46,7 @@ export function gallery({
42
46
  ].filter(Boolean).join(' ')
43
47
 
44
48
  const items = images.map(img => {
45
- const imgTag = `<img src="${e(img.src)}" alt="${e(img.alt || '')}" class="ui-gallery-img" loading="lazy" decoding="async">`
49
+ const imgTag = `<img src="${e(img.src)}" alt="${e(img.alt || '')}" class="ui-gallery-img"${lazy ? ' loading="lazy"' : ''} decoding="async">`
46
50
  const cap = img.caption ? `<figcaption class="ui-gallery-caption">${e(img.caption)}</figcaption>` : ''
47
51
  const inner = img.href
48
52
  ? `<a href="${e(img.href)}" class="ui-gallery-link">${imgTag}</a>`
package/src/ui/ui.test.js CHANGED
@@ -25,6 +25,8 @@ import { pricing } from './pricing.js'
25
25
  import { accordion } from './accordion.js'
26
26
  import { nav } from './nav.js'
27
27
  import { appBadge } from './app-badge.js'
28
+ import { dropCap } from './drop-cap.js'
29
+ import { gallery } from './gallery.js'
28
30
  import { container } from './container.js'
29
31
  import { section } from './section.js'
30
32
  import { grid } from './grid.js'
@@ -1319,4 +1321,36 @@ test('uiImage: escapes src to prevent XSS', () => {
1319
1321
  assert.doesNotMatch(html, /<script>/)
1320
1322
  })
1321
1323
 
1324
+ test('dropCap: inline style carries only the dynamic font-size — alignment lives in CSS', () => {
1325
+ // Regression: a relative inline line-height (lines * 0.8 → 2.4 for 3 lines)
1326
+ // made the float box ~3x the glyph height, so serif caps floated below the
1327
+ // opening line instead of top-aligning. line-height/float/margin now live in
1328
+ // .ui-drop-cap-letter (pulse-ui.css) with a fixed ~cap-height value.
1329
+ const html = dropCap({ letter: 'O', content: 'nce upon a time.' })
1330
+ assert.match(html, /class="ui-drop-cap-letter"/)
1331
+ assert.match(html, /style="font-size:3\.6em"/)
1332
+ assert.doesNotMatch(html, /line-height/)
1333
+ assert.doesNotMatch(html, /float:left/)
1334
+ })
1335
+
1336
+ test('dropCap: lines option scales the font-size', () => {
1337
+ const html = dropCap({ letter: 'O', content: 'nce.', lines: 5 })
1338
+ assert.match(html, /style="font-size:6em"/)
1339
+ })
1340
+
1341
+ test('gallery: images are lazy by default', () => {
1342
+ const html = gallery({ images: [{ src: '/a.jpg', alt: 'A' }] })
1343
+ assert.match(html, /loading="lazy"/)
1344
+ })
1345
+
1346
+ test('gallery: lazy:false renders eager images', () => {
1347
+ const html = gallery({ images: [{ src: '/a.jpg', alt: 'A' }], lazy: false })
1348
+ assert.doesNotMatch(html, /loading="lazy"/)
1349
+ })
1350
+
1351
+ test('dropCap: escapes letter and content', () => {
1352
+ const html = dropCap({ letter: '<', content: '<script>alert(1)</script>' })
1353
+ assert.doesNotMatch(html, /<script>/)
1354
+ })
1355
+
1322
1356
  console.log('✓ All UI component tests passed')