@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.
- package/.claude/commands/build-page.md +1 -1
- package/.claude/commands/verify.md +7 -5
- package/.claude/pulse-checklist.md +5 -2
- package/CLAUDE.md +8 -2
- package/README.md +3 -3
- package/docs/.claude/pulse-checklist.md +5 -2
- package/docs/public/pulse-ui.css +8 -2
- package/docs/src/pages/project-structure.js +2 -0
- package/examples/.claude/pulse-checklist.md +5 -2
- package/examples/public/pulse-ui.css +52 -2
- package/package.json +2 -2
- package/public/.pulse-ui-version +1 -1
- package/public/pulse-ui.css +8 -2
- package/scripts/build.js +1 -204
- package/scripts/css-purge.js +241 -0
- package/scripts/css-purge.test.js +94 -0
- package/src/agent/checklist.md +5 -2
- package/src/agent/commands/verify.md +7 -5
- package/src/agent/coverage-check.js +5 -0
- package/src/agent/guide-spec.md +10 -0
- package/src/agent/guide-styles.md +3 -1
- package/src/agent/skills/build-page/SKILL.md +7 -5
- package/src/agent/skills/verify/SKILL.md +3 -3
- package/src/agent/workflow.md +23 -1
- package/src/cli/scaffold.js +18 -2
- package/src/mcp/mcp.test.js +116 -2
- package/src/mcp/server.js +106 -2
- package/src/ui/drop-cap.js +1 -1
- package/src/ui/gallery.js +5 -1
- package/src/ui/ui.test.js +34 -0
|
@@ -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.
|
|
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
|
-
|
|
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:
|
|
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
|
|
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/
|
|
70
|
-
- **Do not abstract a pattern that appears only once.** Duplication is cheaper than the wrong abstraction.
|
|
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
|
|
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
|
|
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/
|
|
70
|
-
- **Do not abstract a pattern that appears only once.** Duplication is cheaper than the wrong abstraction.
|
|
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/docs/public/pulse-ui.css
CHANGED
|
@@ -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
|
-
|
|
4035
|
-
|
|
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/
|
|
70
|
-
- **Do not abstract a pattern that appears only once.** Duplication is cheaper than the wrong abstraction.
|
|
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
|
-
|
|
3991
|
-
|
|
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.
|
|
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"
|
package/public/.pulse-ui-version
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
0.17.
|
|
1
|
+
0.17.2
|
package/public/pulse-ui.css
CHANGED
|
@@ -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
|
-
|
|
4035
|
-
|
|
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
|
-
}
|