@invisibleloop/pulse 0.17.0 → 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"]`; otherwise ask in plain prose.
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,8 +15,8 @@ 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"]`; 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.
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
+ 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
22
22
 
@@ -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
@@ -66,8 +68,8 @@ Before finishing any spec, verify every point below. Fix anything that fails.
66
68
  ```js
67
69
  const card = ({ title, body }) => `<div class="card"><h3>${title}</h3><p>${body}</p></div>`
68
70
  ```
69
- - **Create a shared component in `src/ui/` when the same pattern is needed across 2 or more different specs.** Follow the existing pattern: a named export that returns an HTML string.
70
- - **Do not abstract a pattern that appears only once.** Duplication is cheaper than the wrong abstraction. Wait until the third use before extracting.
71
+ - **Create a shared component in `src/components/` when the same pattern is needed across 2 or more different specs.** A named export that returns an HTML string — same shape as the `layout()` pattern in the routing guide. Never paste a section's HTML into a second spec: extract it at the second use, then import it from both. (`src/ui/` is the framework's own library inside the package — project-level shared code always goes in `src/components/`.)
72
+ - **Do not abstract a pattern that appears only once.** Duplication is cheaper than the wrong abstraction. Within a single spec, wait until the third use before extracting a helper; across specs, extract at the second use.
71
73
 
72
74
  ### Correctness
73
75
 
@@ -106,6 +108,7 @@ Before finishing any spec, verify every point below. Fix anything that fails.
106
108
 
107
109
  - **`app.css` must contain no hex values or raw colour values.** A lint hook enforces this and will block the build. Hex values belong in `public/theme.css` as token definitions; `app.css` references them via `var()` only.
108
110
  - **Load order in `meta.styles`:** `pulse-ui.css` → `theme.css` → `app.css`. Theme tokens must be defined before `app.css` references them.
111
+ - **CSS for a shared section lives in the shared `app.css`, once.** Every spec that renders the section lists the same file in `meta.styles`. Never copy a CSS block into a second per-page stylesheet — two copies drift apart. Per-page stylesheets are for styles only one page uses.
109
112
 
110
113
  ### Security
111
114
 
package/CLAUDE.md CHANGED
@@ -6,7 +6,7 @@ Pulse is a spec-first frontend framework. The spec is the source of truth. No co
6
6
 
7
7
  ```bash
8
8
  npm run dev # start dev server at http://localhost:3000 (pulse_build serves production on 3001)
9
- npm test # run all 92 unit tests
9
+ npm test # run the full unit test suite
10
10
  npm run build # bundle for production → public/dist/
11
11
  ```
12
12
 
@@ -37,6 +37,8 @@ public/
37
37
 
38
38
  A spec is a plain JS object. Every property is optional except `route` and `view`. `state` defaults to `{}` — omit it on purely server-rendered pages.
39
39
 
40
+ **One spec = one page = one file.** A site is multiple specs, one per route. A dynamic route (`route: '/blog/:slug'`) is still a single spec serving every matching URL — never one spec per item, and never several pages folded into one spec with `ctx` conditionals. Shared sections live in `src/components/` and are imported by each spec.
41
+
40
42
  ```js
41
43
  export const mySpec = {
42
44
  route: '/path', // URL pattern, supports :params
@@ -45,6 +47,8 @@ export const mySpec = {
45
47
  title: 'Page Title',
46
48
  description: 'Meta description',
47
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
48
52
  ogTitle: '...',
49
53
  ogImage: '...',
50
54
  },
@@ -360,6 +364,8 @@ There are two CSS files and they have distinct roles — do not mix them:
360
364
 
361
365
  **`app.css` must never contain hex values.** `rgba()` and `hsla()` are allowed for translucency — but if the same translucent colour is used more than once, extract it as a token in `theme.css` (e.g. `--brand-overlay-12: rgba(0,0,0,0.12)`). If you need to define or override a colour token, put it in `public/theme.css` and reference it via `var()` in `app.css`.
362
366
 
367
+ **CSS for a section used by more than one spec lives in the shared `app.css`, once** — every spec that renders the section lists the same file in `meta.styles`. Never copy a CSS block into a second per-page stylesheet; two copies drift. Per-page stylesheets are only for styles a single page uses.
368
+
363
369
  ```css
364
370
  /* public/theme.css — hex values live here */
365
371
  [data-theme="light"] {
@@ -464,7 +470,7 @@ For any **new page, landing page, or branded site**, run this sequence first. Do
464
470
  3. pulse_intent(description) → archetype + scaffold + guide list
465
471
  ```
466
472
 
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.
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.
468
474
 
469
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.
470
476
 
package/README.md CHANGED
@@ -7,7 +7,6 @@ Write a plain JavaScript object that describes what a page does. Pulse handles r
7
7
  ```js
8
8
  export default {
9
9
  route: '/counter',
10
- hydrate: '/src/pages/counter.js',
11
10
  meta: {
12
11
  title: 'Counter',
13
12
  styles: ['/app.css'],
@@ -54,7 +53,7 @@ Full documentation: **[pulseframework.dev](https://pulseframework.dev)**
54
53
 
55
54
  **The spec is the source of truth.** Every page is a single JS object — data fetching, state, mutations, async actions, and view all in one place. No separate files for routes, controllers, or templates.
56
55
 
57
- **Performance is built in.** Streaming SSR, immutable asset caching, and zero layout shift are automatic. Every scaffolded project targets Lighthouse 100 across all four categories.
56
+ **Performance is built in.** Streaming SSR, immutable asset caching, and zero layout shift are automatic. Every scaffolded project targets Lighthouse 100 for Accessibility, Best Practices, and SEO — and is fast by design (streaming HTML before data resolves, CLS 0.00).
58
57
 
59
58
  **No client-side dependencies.** Pulse ships no runtime framework to the browser — only a small hydration bundle (~2 kB brotli) that binds your spec's mutations and actions to the DOM.
60
59
 
@@ -83,12 +82,13 @@ Full reference at [pulseframework.dev](https://pulseframework.dev):
83
82
  - **Guard** — authentication and authorisation before data fetches
84
83
  - **Validation & constraints** — declarative form validation and state bounds
85
84
  - **Raw responses** — RSS feeds, sitemaps, JSON APIs
85
+ - **Markdown** — built-in parser for blog posts and docs pages, zero browser JS
86
86
  - **Performance** — how the framework hits Lighthouse 100
87
87
  - **UI components** — 50+ built-in components
88
88
 
89
89
  ## Status
90
90
 
91
- This is an early-access release. The core architecture is stable and production-quality, but the API may evolve before v1. Feedback and issues welcome on [GitHub](https://github.com/invisibleloop/pulse).
91
+ This is an early-access release. The core architecture is stable and production-quality, but the API may evolve before v1. Feedback and issues welcome on [GitHub](https://github.com/invisibleloop/pulse-framework).
92
92
 
93
93
  ## License
94
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
@@ -66,8 +68,8 @@ Before finishing any spec, verify every point below. Fix anything that fails.
66
68
  ```js
67
69
  const card = ({ title, body }) => `<div class="card"><h3>${title}</h3><p>${body}</p></div>`
68
70
  ```
69
- - **Create a shared component in `src/ui/` when the same pattern is needed across 2 or more different specs.** Follow the existing pattern: a named export that returns an HTML string.
70
- - **Do not abstract a pattern that appears only once.** Duplication is cheaper than the wrong abstraction. Wait until the third use before extracting.
71
+ - **Create a shared component in `src/components/` when the same pattern is needed across 2 or more different specs.** A named export that returns an HTML string — same shape as the `layout()` pattern in the routing guide. Never paste a section's HTML into a second spec: extract it at the second use, then import it from both. (`src/ui/` is the framework's own library inside the package — project-level shared code always goes in `src/components/`.)
72
+ - **Do not abstract a pattern that appears only once.** Duplication is cheaper than the wrong abstraction. Within a single spec, wait until the third use before extracting a helper; across specs, extract at the second use.
71
73
 
72
74
  ### Correctness
73
75
 
@@ -106,6 +108,7 @@ Before finishing any spec, verify every point below. Fix anything that fails.
106
108
 
107
109
  - **`app.css` must contain no hex values or raw colour values.** A lint hook enforces this and will block the build. Hex values belong in `public/theme.css` as token definitions; `app.css` references them via `var()` only.
108
110
  - **Load order in `meta.styles`:** `pulse-ui.css` → `theme.css` → `app.css`. Theme tokens must be defined before `app.css` references them.
111
+ - **CSS for a shared section lives in the shared `app.css`, once.** Every spec that renders the section lists the same file in `meta.styles`. Never copy a CSS block into a second per-page stylesheet — two copies drift apart. Per-page stylesheets are for styles only one page uses.
109
112
 
110
113
  ### Security
111
114
 
@@ -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 */
@@ -82,6 +82,8 @@ export default {
82
82
  ${section('one-file-per-page', 'One file per page')}
83
83
  <p>Pulse enforces <strong>one spec per file</strong>. Every page is self-contained — state, view, mutations, actions, and validation in one place. This is not a style preference. It is how the framework eliminates the question of where things live.</p>
84
84
 
85
+ <p>This holds for dynamic routes too: <code>route: '/blog/:slug'</code> is <strong>one spec serving every matching URL</strong> — the <code>:param</code> does the multiplying, so a thousand blog posts are still a single file. A site is therefore a small collection of specs, one per route, sharing layouts and sections from <code>src/components/</code> rather than repeating them.</p>
86
+
85
87
  ${callout('tip', 'For large pages, view fragments and helpers can be imported from other modules. The spec file remains the coordination point — the structure is always clear regardless of how the implementation is organised.')}
86
88
  `,
87
89
  }),
@@ -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
@@ -66,8 +68,8 @@ Before finishing any spec, verify every point below. Fix anything that fails.
66
68
  ```js
67
69
  const card = ({ title, body }) => `<div class="card"><h3>${title}</h3><p>${body}</p></div>`
68
70
  ```
69
- - **Create a shared component in `src/ui/` when the same pattern is needed across 2 or more different specs.** Follow the existing pattern: a named export that returns an HTML string.
70
- - **Do not abstract a pattern that appears only once.** Duplication is cheaper than the wrong abstraction. Wait until the third use before extracting.
71
+ - **Create a shared component in `src/components/` when the same pattern is needed across 2 or more different specs.** A named export that returns an HTML string — same shape as the `layout()` pattern in the routing guide. Never paste a section's HTML into a second spec: extract it at the second use, then import it from both. (`src/ui/` is the framework's own library inside the package — project-level shared code always goes in `src/components/`.)
72
+ - **Do not abstract a pattern that appears only once.** Duplication is cheaper than the wrong abstraction. Within a single spec, wait until the third use before extracting a helper; across specs, extract at the second use.
71
73
 
72
74
  ### Correctness
73
75
 
@@ -106,6 +108,7 @@ Before finishing any spec, verify every point below. Fix anything that fails.
106
108
 
107
109
  - **`app.css` must contain no hex values or raw colour values.** A lint hook enforces this and will block the build. Hex values belong in `public/theme.css` as token definitions; `app.css` references them via `var()` only.
108
110
  - **Load order in `meta.styles`:** `pulse-ui.css` → `theme.css` → `app.css`. Theme tokens must be defined before `app.css` references them.
111
+ - **CSS for a shared section lives in the shared `app.css`, once.** Every spec that renders the section lists the same file in `meta.styles`. Never copy a CSS block into a second per-page stylesheet — two copies drift apart. Per-page stylesheets are for styles only one page uses.
109
112
 
110
113
  ### Security
111
114
 
@@ -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.0",
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.0
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
- }