@invisibleloop/pulse 0.20.11 → 0.20.19
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/package.json +6 -5
- package/public/.pulse-ui-version +1 -1
- package/scripts/build.js +26 -0
- package/scripts/release-version.js +17 -8
- package/scripts/strip-server.test.js +60 -0
- package/src/agent/checklist.md +8 -12
- package/src/agent/commands/pulse-start.md +2 -0
- package/src/agent/commands/verify.md +30 -8
- package/src/agent/guide-components.md +7 -25
- package/src/agent/guide-routing.md +0 -12
- package/src/agent/guide-server.md +102 -1
- package/src/agent/guide-spec.md +8 -1
- package/src/agent/guide-styles.md +23 -0
- package/src/agent/identity.md +9 -0
- package/src/agent/workflow.md +4 -23
- package/src/cli/check-bundles.js +113 -0
- package/src/cli/check-bundles.test.js +137 -0
- package/src/cli/cli.test.js +105 -0
- package/src/cli/dev.js +48 -3
- package/src/cli/diagnose.js +105 -0
- package/src/cli/diagnose.test.js +124 -0
- package/src/cli/discover.js +9 -2
- package/src/cli/index.js +171 -9
- package/src/cli/process-utils.js +57 -0
- package/src/cli/process-utils.test.js +114 -0
- package/src/cli/review.js +391 -0
- package/src/cli/review.test.js +143 -0
- package/src/cli/scaffold.js +9 -4
- package/src/cli/stamp.js +102 -0
- package/src/cli/stamp.test.js +149 -0
- package/src/cli/start.js +15 -2
- package/src/cli/validate.js +184 -0
- package/src/cli/validate.test.js +153 -0
- package/src/mcp/mcp.test.js +261 -30
- package/src/mcp/server.js +207 -515
- package/src/mcp/validate-worker.js +18 -1
- package/src/runtime/index.js +144 -11
- package/src/runtime/runtime.test.js +199 -0
- package/src/runtime/ssr.js +13 -6
- package/src/runtime/ssr.test.js +31 -0
- package/src/server/index.js +162 -9
- package/src/server/server.test.js +225 -2
- package/src/spec/schema.js +1 -1
- package/src/ui/index.js +0 -5
- package/src/ui/ui.test.js +0 -75
- package/types/ui.d.ts +0 -21
- package/.claude/commands/build-page.md +0 -63
- package/.claude/commands/new-doc-page.md +0 -45
- package/.claude/commands/pulse-start.md +0 -11
- package/.claude/commands/pulse-stop.md +0 -5
- package/.claude/commands/verify.md +0 -118
- package/.claude/pulse-checklist.md +0 -189
- package/.github/workflows/ci.yml +0 -25
- package/.github/workflows/publish.yml +0 -45
- package/.pulse/load-reports/home/1773432711417.json +0 -22
- package/.pulse-verified +0 -1
- package/AGENTS.md +0 -697
- package/CLAUDE.md +0 -697
- package/README.md +0 -95
- package/benchmark/public/dist/counter.boot-PO6SNSJZ.js +0 -8
- package/benchmark/public/dist/home.boot-YK6VAEQI.js +0 -6
- package/benchmark/public/dist/manifest.json +0 -5
- package/benchmark/public/dist/runtime-5Y7ESAIB.js +0 -2
- package/benchmark/public/dist/runtime-KO4BHUQ3.js +0 -49
- package/benchmark/results/2026-03-27-09-20.json +0 -188
- package/benchmark/results/2026-03-27-09-21.json +0 -188
- package/benchmark/results/2026-03-27-09-29.json +0 -188
- package/benchmark/results/2026-03-27-10-14.json +0 -188
- package/benchmark/results/2026-03-27-10-18.json +0 -232
- package/benchmark/src/pages/counter.js +0 -37
- package/benchmark/src/pages/home.js +0 -21
- package/docs/.claude/commands/pulse-build.md +0 -7
- package/docs/.claude/commands/pulse-dev.md +0 -7
- package/docs/.claude/commands/pulse-start.md +0 -9
- package/docs/.claude/commands/pulse-stop.md +0 -5
- package/docs/.claude/commands/verify.md +0 -84
- package/docs/.claude/coverage-check.js +0 -196
- package/docs/.claude/pulse-checklist.md +0 -189
- package/docs/public/.pulse-ui-version +0 -1
- package/docs/public/andypixel.css +0 -237
- package/docs/public/bookworms.css +0 -422
- package/docs/public/coutumes.css +0 -561
- package/docs/public/docs.css +0 -2388
- package/docs/public/favicon.svg +0 -4
- package/docs/public/home-brut.css +0 -982
- package/docs/public/images/bookworms/hero-books.jpg +0 -0
- package/docs/public/images/bookworms/how-we-read-now.jpg +0 -0
- package/docs/public/images/bookworms/lantern-keepers.jpg +0 -0
- package/docs/public/images/bookworms/provincial-bookseller.jpg +0 -0
- package/docs/public/images/journey/178.jpg +0 -0
- package/docs/public/images/journey/29.jpg +0 -0
- package/docs/public/images/retrowheels/hero.jpg +0 -0
- package/docs/public/images/shop/devbook-pro.jpg +0 -0
- package/docs/public/images/suzukibento/1015.jpg +0 -0
- package/docs/public/images/suzukibento/1025.jpg +0 -0
- package/docs/public/images/suzukibento/1043.jpg +0 -0
- package/docs/public/images/suzukibento/1059.jpg +0 -0
- package/docs/public/images/suzukibento/1062.jpg +0 -0
- package/docs/public/images/suzukibento/1084.jpg +0 -0
- package/docs/public/images/swish/1469334031218-e382a71b716b.jpg +0 -0
- package/docs/public/images/swish/1483985988355-763728e1935b.jpg +0 -0
- package/docs/public/images/swish/1485518882345-15568b007407.jpg +0 -0
- package/docs/public/images/swish/1490481651871-ab68de25d43d.jpg +0 -0
- package/docs/public/images/swish/1496747611176-843222e1e57c.jpg +0 -0
- package/docs/public/images/swish/1502716119720-b23a93e5fe1b.jpg +0 -0
- package/docs/public/images/swish/1539109136881-3be0616acf4b.jpg +0 -0
- package/docs/public/images/swish/1551803091-e20673f15770.jpg +0 -0
- package/docs/public/images/swish/1558769132-cb1aea458c5e.jpg +0 -0
- package/docs/public/images/swish/1572804013309-59a88b7e92f1.jpg +0 -0
- package/docs/public/img/heic1501a.jpg +0 -0
- package/docs/public/img/placeholder-landscape.svg +0 -18
- package/docs/public/img/placeholder-square.svg +0 -14
- package/docs/public/img/placeholder-tall.svg +0 -22
- package/docs/public/journey.css +0 -528
- package/docs/public/menu.js +0 -327
- package/docs/public/pulse-ui.css +0 -3028
- package/docs/public/pulse-ui.js +0 -257
- package/docs/public/retrowheels.css +0 -425
- package/docs/public/shop.css +0 -269
- package/docs/public/sparkys.css +0 -382
- package/docs/public/suzukibento.css +0 -517
- package/docs/public/swish.css +0 -394
- package/docs/public/theme.css +0 -52
- package/docs/public/themes/lumio.css +0 -50
- package/docs/public/themes/observatory.css +0 -104
- package/docs/server.js +0 -161
- package/docs/src/lib/component-page.js +0 -49
- package/docs/src/lib/highlight.js +0 -2
- package/docs/src/lib/layout.js +0 -190
- package/docs/src/lib/metrics-store.js +0 -6
- package/docs/src/lib/nav.js +0 -171
- package/docs/src/lib/stats.js +0 -81
- package/docs/src/pages/accessibility.js +0 -157
- package/docs/src/pages/actions.js +0 -194
- package/docs/src/pages/auth.js +0 -176
- package/docs/src/pages/caching.js +0 -95
- package/docs/src/pages/components/accordion.js +0 -48
- package/docs/src/pages/components/alert.js +0 -35
- package/docs/src/pages/components/app-badge.js +0 -41
- package/docs/src/pages/components/avatar.js +0 -35
- package/docs/src/pages/components/badge.js +0 -36
- package/docs/src/pages/components/breadcrumbs.js +0 -94
- package/docs/src/pages/components/button.js +0 -84
- package/docs/src/pages/components/card.js +0 -225
- package/docs/src/pages/components/charts.js +0 -283
- package/docs/src/pages/components/checkbox.js +0 -129
- package/docs/src/pages/components/cluster.js +0 -47
- package/docs/src/pages/components/container.js +0 -40
- package/docs/src/pages/components/cta.js +0 -54
- package/docs/src/pages/components/divider.js +0 -37
- package/docs/src/pages/components/empty.js +0 -36
- package/docs/src/pages/components/feature.js +0 -60
- package/docs/src/pages/components/fieldset.js +0 -65
- package/docs/src/pages/components/fileupload.js +0 -128
- package/docs/src/pages/components/footer.js +0 -58
- package/docs/src/pages/components/grid.js +0 -169
- package/docs/src/pages/components/heading.js +0 -107
- package/docs/src/pages/components/hero.js +0 -123
- package/docs/src/pages/components/icons.js +0 -410
- package/docs/src/pages/components/image.js +0 -71
- package/docs/src/pages/components/input.js +0 -51
- package/docs/src/pages/components/media.js +0 -51
- package/docs/src/pages/components/modal.js +0 -111
- package/docs/src/pages/components/nav.js +0 -136
- package/docs/src/pages/components/phone-frame.js +0 -74
- package/docs/src/pages/components/progress.js +0 -102
- package/docs/src/pages/components/prose.js +0 -111
- package/docs/src/pages/components/pullquote.js +0 -71
- package/docs/src/pages/components/radio.js +0 -194
- package/docs/src/pages/components/rating.js +0 -106
- package/docs/src/pages/components/search.js +0 -61
- package/docs/src/pages/components/section.js +0 -98
- package/docs/src/pages/components/segmented.js +0 -121
- package/docs/src/pages/components/select.js +0 -45
- package/docs/src/pages/components/slider.js +0 -115
- package/docs/src/pages/components/spinner.js +0 -73
- package/docs/src/pages/components/stack.js +0 -48
- package/docs/src/pages/components/stat.js +0 -55
- package/docs/src/pages/components/stepper.js +0 -66
- package/docs/src/pages/components/table.js +0 -45
- package/docs/src/pages/components/textarea.js +0 -31
- package/docs/src/pages/components/toggle.js +0 -84
- package/docs/src/pages/components/tooltip.js +0 -48
- package/docs/src/pages/components.js +0 -200
- package/docs/src/pages/config.js +0 -193
- package/docs/src/pages/constraints.js +0 -100
- package/docs/src/pages/deploy.js +0 -233
- package/docs/src/pages/error-pages.js +0 -99
- package/docs/src/pages/extending.js +0 -198
- package/docs/src/pages/faq.js +0 -99
- package/docs/src/pages/forms.js +0 -129
- package/docs/src/pages/getting-started.js +0 -147
- package/docs/src/pages/guard.js +0 -120
- package/docs/src/pages/home.js +0 -350
- package/docs/src/pages/how-it-works.js +0 -244
- package/docs/src/pages/hydration.js +0 -108
- package/docs/src/pages/images.js +0 -120
- package/docs/src/pages/markdown.js +0 -156
- package/docs/src/pages/meta.js +0 -193
- package/docs/src/pages/mutations.js +0 -107
- package/docs/src/pages/navigation.js +0 -85
- package/docs/src/pages/performance.js +0 -157
- package/docs/src/pages/persist.js +0 -89
- package/docs/src/pages/project-structure.js +0 -91
- package/docs/src/pages/prompt-examples.js +0 -186
- package/docs/src/pages/raw-responses.js +0 -122
- package/docs/src/pages/routing.js +0 -120
- package/docs/src/pages/server-api.js +0 -291
- package/docs/src/pages/server-data.js +0 -203
- package/docs/src/pages/slash-commands.js +0 -55
- package/docs/src/pages/spec.js +0 -207
- package/docs/src/pages/state.js +0 -102
- package/docs/src/pages/store.js +0 -203
- package/docs/src/pages/streaming.js +0 -107
- package/docs/src/pages/stripe.js +0 -194
- package/docs/src/pages/supabase.js +0 -324
- package/docs/src/pages/templates/andypixel.js +0 -260
- package/docs/src/pages/templates/blog-post.js +0 -226
- package/docs/src/pages/templates/bookworms.js +0 -227
- package/docs/src/pages/templates/coutumes.js +0 -237
- package/docs/src/pages/templates/journey.js +0 -186
- package/docs/src/pages/templates/mobile-app.js +0 -371
- package/docs/src/pages/templates/retrowheels.js +0 -355
- package/docs/src/pages/templates/shop.js +0 -268
- package/docs/src/pages/templates/sparkys.js +0 -264
- package/docs/src/pages/templates/suzukibento.js +0 -200
- package/docs/src/pages/templates/swish.js +0 -313
- package/docs/src/pages/templates.js +0 -534
- package/docs/src/pages/testing.js +0 -199
- package/docs/src/pages/validation.js +0 -138
- package/examples/.claude/commands/pulse-build.md +0 -7
- package/examples/.claude/commands/pulse-dev.md +0 -7
- package/examples/.claude/commands/pulse-start.md +0 -9
- package/examples/.claude/commands/pulse-stop.md +0 -5
- package/examples/.claude/commands/verify.md +0 -84
- package/examples/.claude/coverage-check.js +0 -196
- package/examples/.claude/pulse-checklist.md +0 -189
- package/examples/andypixel.css +0 -237
- package/examples/andypixel.js +0 -258
- package/examples/bookworms.css +0 -422
- package/examples/bookworms.js +0 -225
- package/examples/contact.js +0 -166
- package/examples/counter.js +0 -94
- package/examples/dev.server.js +0 -110
- package/examples/examples.test.js +0 -394
- package/examples/journey.css +0 -528
- package/examples/journey.js +0 -186
- package/examples/pricing.js +0 -262
- package/examples/products.js +0 -190
- package/examples/public/.pulse-ui-version +0 -1
- package/examples/public/pulse-ui.css +0 -3028
- package/examples/public/pulse-ui.js +0 -257
- package/examples/quiz.js +0 -208
- package/examples/retrowheels.css +0 -425
- package/examples/retrowheels.js +0 -353
- package/examples/shared.js +0 -84
- package/examples/suzukibento.css +0 -513
- package/examples/suzukibento.js +0 -200
- package/examples/swish.css +0 -394
- package/examples/swish.js +0 -311
- package/examples/todos.js +0 -162
- package/src/ui/fieldset.js +0 -37
- package/src/ui/phone-frame.js +0 -40
- package/src/ui/search.js +0 -78
- package/src/ui/segmented.js +0 -50
- package/src/ui/tooltip.js +0 -28
package/AGENTS.md
DELETED
|
@@ -1,697 +0,0 @@
|
|
|
1
|
-
# Pulse — AI Agent Guide
|
|
2
|
-
|
|
3
|
-
Pulse is a spec-first frontend framework. The spec is the source of truth. No codegen, no virtual DOM, no dependencies (esbuild is dev-only).
|
|
4
|
-
|
|
5
|
-
## Commands
|
|
6
|
-
|
|
7
|
-
```bash
|
|
8
|
-
npm run dev # start dev server at http://localhost:3000 (pulse_build serves production on 3001)
|
|
9
|
-
npm test # run the full unit test suite
|
|
10
|
-
npm run build # bundle for production → public/dist/
|
|
11
|
-
```
|
|
12
|
-
|
|
13
|
-
## Project Structure
|
|
14
|
-
|
|
15
|
-
```
|
|
16
|
-
src/
|
|
17
|
-
spec/schema.js # authoritative spec definition + validation
|
|
18
|
-
runtime/index.js # client mount() + dispatch + constraints
|
|
19
|
-
runtime/ssr.js # server-side rendering (string + stream)
|
|
20
|
-
runtime/navigate.js # client-side navigation + history
|
|
21
|
-
server/index.js # HTTP server (Node built-in, zero deps)
|
|
22
|
-
examples/
|
|
23
|
-
counter.js # simple client-only spec
|
|
24
|
-
contact.js # spec with server data + async action
|
|
25
|
-
dev.server.js # combined dev server for both specs
|
|
26
|
-
scripts/
|
|
27
|
-
build.js # esbuild bundler — generates public/dist/
|
|
28
|
-
public/
|
|
29
|
-
pulse.css # base stylesheet (dark theme)
|
|
30
|
-
dist/ # generated — do not edit
|
|
31
|
-
runtime-[hash].js # shared runtime chunk (mount + navigate)
|
|
32
|
-
[name].boot-[hash].js # per-page spec bundle
|
|
33
|
-
manifest.json # source hydrate paths → bundle paths
|
|
34
|
-
```
|
|
35
|
-
|
|
36
|
-
## The Spec
|
|
37
|
-
|
|
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
|
-
|
|
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
|
-
|
|
42
|
-
```js
|
|
43
|
-
export const mySpec = {
|
|
44
|
-
route: '/path', // URL pattern, supports :params. Use '*' for the custom
|
|
45
|
-
// not-found page — rendered with status 404 when no route matches
|
|
46
|
-
|
|
47
|
-
meta: {
|
|
48
|
-
title: 'Page Title',
|
|
49
|
-
description: 'Meta description',
|
|
50
|
-
styles: ['/pulse.css'],
|
|
51
|
-
theme: 'light', // 'light' | 'dark' — ⚠ default is DARK when unset; decide at write time
|
|
52
|
-
vibe: 'minimal', // optional preset: radius, weight, spacing
|
|
53
|
-
ogTitle: '...',
|
|
54
|
-
ogImage: '...',
|
|
55
|
-
},
|
|
56
|
-
|
|
57
|
-
// Server data — resolved before render, passed to view as second arg
|
|
58
|
-
server: {
|
|
59
|
-
data: async (ctx) => ({ ... }) // ctx has params, query, headers, cookies
|
|
60
|
-
},
|
|
61
|
-
|
|
62
|
-
// Timeout for all server fetchers on this page (ms). Overrides createServer fetcherTimeout.
|
|
63
|
-
serverTimeout: 5000,
|
|
64
|
-
|
|
65
|
-
// Query-string keys that change this page's output, for the in-process page cache.
|
|
66
|
-
// The cache IGNORES the query string by default — arbitrary ?n=… params cannot mint
|
|
67
|
-
// unbounded cache entries (memory-DoS guard). If the rendered output depends on a
|
|
68
|
-
// query param, list it here so distinct values get distinct cache entries.
|
|
69
|
-
cacheVary: ['q', 'page'],
|
|
70
|
-
|
|
71
|
-
// Sitemap inclusion (when createServer has sitemap: true).
|
|
72
|
-
// Static routes are included automatically — only set this to:
|
|
73
|
-
// false → exclude this page
|
|
74
|
-
// true → include a guarded page (excluded by default)
|
|
75
|
-
// async () => [...] → enumerate a dynamic :param route's URLs
|
|
76
|
-
sitemap: async () => (await db.posts.list()).map(p => `/blog/${p.slug}`),
|
|
77
|
-
|
|
78
|
-
// Global store keys this page subscribes to — appears in view's server arg.
|
|
79
|
-
// The store is defined in pulse.store.js at the project root — AUTO-DISCOVERED
|
|
80
|
-
// by pulse dev/start (no registration step). Mutations update all subscribed
|
|
81
|
-
// pages without a server round-trip.
|
|
82
|
-
store: ['user', 'cart'],
|
|
83
|
-
|
|
84
|
-
// Initial client state — deep cloned on mount, never mutated directly
|
|
85
|
-
// Optional — omit entirely on purely server-rendered pages (no mutations/actions/persist)
|
|
86
|
-
state: { count: 0 },
|
|
87
|
-
|
|
88
|
-
// Declarative min/max bounds — always enforced after mutations
|
|
89
|
-
constraints: {
|
|
90
|
-
count: { min: 0, max: 10 }
|
|
91
|
-
},
|
|
92
|
-
|
|
93
|
-
// Validation rules — checked when action.validate === true
|
|
94
|
-
validation: {
|
|
95
|
-
'fields.email': { required: true, format: 'email' },
|
|
96
|
-
'fields.name': { required: true, minLength: 2, maxLength: 100 }
|
|
97
|
-
// formats: email | url | numeric
|
|
98
|
-
// rules: required | minLength | maxLength | min | max | pattern
|
|
99
|
-
},
|
|
100
|
-
|
|
101
|
-
// Pure function(s) — return HTML string, no side effects
|
|
102
|
-
// Can be a single function or a keyed object of segment functions
|
|
103
|
-
view: (state, server) => `<main>...</main>`,
|
|
104
|
-
|
|
105
|
-
// Optional — called client-side when view() throws. Return an HTML string.
|
|
106
|
-
// Without this, the runtime shows an inline error message and logs to console.
|
|
107
|
-
// On the server, a throwing view always propagates to the server error handler
|
|
108
|
-
// unless onViewError is defined, in which case it returns the fallback HTML with 200.
|
|
109
|
-
onViewError: (err, state, serverState) => `<p>Something went wrong</p>`,
|
|
110
|
-
|
|
111
|
-
// Synchronous state changes — return partial state to merge
|
|
112
|
-
mutations: {
|
|
113
|
-
increment: (state, event) => ({ count: state.count + 1 }),
|
|
114
|
-
},
|
|
115
|
-
|
|
116
|
-
// Async operations — lifecycle: onStart → validate → run → onSuccess/onError
|
|
117
|
-
actions: {
|
|
118
|
-
submit: {
|
|
119
|
-
onStart: (state, formData) => ({ status: 'loading', ... }),
|
|
120
|
-
validate: true, // runs validation before run()
|
|
121
|
-
run: async (state, serverState, formData) => { /* fetch, etc */ },
|
|
122
|
-
onSuccess: (state, payload) => ({
|
|
123
|
-
status: 'success',
|
|
124
|
-
_toast: { message: 'Saved!', variant: 'success' }, // show a toast
|
|
125
|
-
}),
|
|
126
|
-
onError: (state, err) => ({
|
|
127
|
-
status: 'error',
|
|
128
|
-
errors: err?.validation ?? [{ message: err.message }],
|
|
129
|
-
_toast: { message: 'Something went wrong', variant: 'error' },
|
|
130
|
-
}),
|
|
131
|
-
}
|
|
132
|
-
},
|
|
133
|
-
|
|
134
|
-
// Server-side form handling — POST to this route, no client JS required.
|
|
135
|
-
// CSRF protection is automatic: include ${server.csrf} inside the <form>.
|
|
136
|
-
// { redirect: '/path' } → 303 POST-redirect-GET
|
|
137
|
-
// anything else → page re-renders; return value appears as server.form
|
|
138
|
-
// (optional status field sets the response status, e.g. 422)
|
|
139
|
-
submit: async (ctx) => {
|
|
140
|
-
const data = await ctx.formData()
|
|
141
|
-
if (!data?.email) return { errors: { email: 'Required' }, values: data ?? {} }
|
|
142
|
-
await saveSignup(data)
|
|
143
|
-
return { redirect: '/thanks' }
|
|
144
|
-
},
|
|
145
|
-
// csrf: false, // opt out ONLY for endpoints with their own auth (e.g. signed webhooks)
|
|
146
|
-
|
|
147
|
-
// Streaming SSR — split view into shell (instant) + deferred segments
|
|
148
|
-
stream: {
|
|
149
|
-
shell: ['header', 'nav'],
|
|
150
|
-
deferred: ['feed'],
|
|
151
|
-
|
|
152
|
-
// Optional: scope each segment to only the server fetchers it needs.
|
|
153
|
-
// Shell sends as soon as its own fetchers resolve — deferred-only fetchers
|
|
154
|
-
// never block the shell. Each deferred segment streams independently.
|
|
155
|
-
// Omit scope (or omit a segment from scope) to give it all server state.
|
|
156
|
-
scope: {
|
|
157
|
-
header: ['user'], // 'user' fetcher resolves → shell writes
|
|
158
|
-
nav: ['user'],
|
|
159
|
-
feed: ['posts', 'ads'], // 'posts' + 'ads' fetchers resolve → feed writes
|
|
160
|
-
}
|
|
161
|
-
}
|
|
162
|
-
}
|
|
163
|
-
|
|
164
|
-
export default mySpec // required for hydration imports
|
|
165
|
-
```
|
|
166
|
-
|
|
167
|
-
## Server-Side Forms (no client JS)
|
|
168
|
-
|
|
169
|
-
A spec with `submit` accepts POST on its route — the form works with JavaScript disabled. **Always include `${server.csrf}` inside the form** (CSRF is enforced; POSTs without the token get 403):
|
|
170
|
-
|
|
171
|
-
```js
|
|
172
|
-
view: (state, server) => `
|
|
173
|
-
<main id="main-content">
|
|
174
|
-
${server.form?.errors?.email ? alert({ variant: 'error', message: server.form.errors.email }) : ''}
|
|
175
|
-
<form method="POST">
|
|
176
|
-
${server.csrf}
|
|
177
|
-
${input({ label: 'Email', name: 'email', type: 'email', required: true, value: server.form?.values?.email ?? '' })}
|
|
178
|
-
${button({ label: 'Sign up', type: 'submit' })}
|
|
179
|
-
</form>
|
|
180
|
-
</main>`,
|
|
181
|
-
```
|
|
182
|
-
|
|
183
|
-
- `server.form` is the return value of `submit()` on a re-render (validation errors, submitted values) — `undefined` on plain GET.
|
|
184
|
-
- **Progressive enhancement:** the same form can also carry `data-action="signup"` — hydrated visitors get the async action (no page reload), no-JS visitors fall back to the POST. Echo submitted values back into inputs via `value=` so a failed validation doesn't wipe the form.
|
|
185
|
-
- Always `return { redirect }` after a successful mutation (POST-redirect-GET) — never render success directly from the POST, or refresh resubmits the form.
|
|
186
|
-
- Multi-instance deployments: set a stable `secret` in `createServer` so CSRF tokens validate across instances.
|
|
187
|
-
|
|
188
|
-
## Custom 404 Page
|
|
189
|
-
|
|
190
|
-
Create a spec with `route: '*'` — it renders through the normal pipeline (layout, styles, hydration, validation) with status 404 whenever no route matches:
|
|
191
|
-
|
|
192
|
-
```js
|
|
193
|
-
// src/pages/not-found.js
|
|
194
|
-
export default {
|
|
195
|
-
route: '*',
|
|
196
|
-
meta: { title: 'Page not found', styles: ['/pulse-ui.css', '/theme.css', '/app.css'] },
|
|
197
|
-
view: () => `<main id="main-content"><h1>Page not found</h1><p><a href="/">Back home</a></p></main>`,
|
|
198
|
-
}
|
|
199
|
-
```
|
|
200
|
-
|
|
201
|
-
Without a `'*'` spec, the framework's plain default 404 is served. 500s are customised via `createServer`'s `onError` option.
|
|
202
|
-
|
|
203
|
-
## HTML Event Binding
|
|
204
|
-
|
|
205
|
-
Pulse binds to DOM attributes — no JSX, no templates.
|
|
206
|
-
|
|
207
|
-
```html
|
|
208
|
-
<button data-event="increment">+</button> <!-- click → mutation -->
|
|
209
|
-
<input data-event="change:setName"> <!-- change → mutation -->
|
|
210
|
-
<input data-event="input:setQuery"> <!-- input → mutation -->
|
|
211
|
-
<input data-event="input:search" data-debounce="300"> <!-- debounced input (300ms) -->
|
|
212
|
-
<input data-event="input:filter" data-throttle="100"> <!-- throttled input (100ms) -->
|
|
213
|
-
<form data-action="submit">...</form> <!-- submit → action, passes FormData -->
|
|
214
|
-
<button data-store-event="toggleTheme">Toggle</button> <!-- click → store mutation -->
|
|
215
|
-
<select data-store-event="change:setLang">...</select> <!-- change → store mutation -->
|
|
216
|
-
<button data-dialog-open="my-modal">Open modal</button> <!-- opens <dialog id="my-modal"> -->
|
|
217
|
-
<button data-dialog-close>Cancel</button> <!-- closes nearest ancestor <dialog> -->
|
|
218
|
-
```
|
|
219
|
-
|
|
220
|
-
**List keys — use `data-key` on repeated elements** to enable key-based DOM reconciliation. When all element children of a container carry `data-key`, the runtime matches nodes by key instead of position — inserts, removals, and reorders become O(1) rather than O(n) patches, and existing DOM nodes (inputs, images) are preserved correctly:
|
|
221
|
-
|
|
222
|
-
```html
|
|
223
|
-
<ul>
|
|
224
|
-
${items.map(item => `<li data-key="${item.id}">${item.name}</li>`).join('')}
|
|
225
|
-
</ul>
|
|
226
|
-
```
|
|
227
|
-
|
|
228
|
-
All sibling elements in the container must have `data-key` to activate key mode — mixed keyed/unkeyed siblings fall back to position-based matching.
|
|
229
|
-
|
|
230
|
-
**Modal pattern — never use `state.modalOpen`.** Always render the `<dialog>` in the DOM unconditionally and use `data-dialog-open` to show it. ESC key, backdrop click, and `<form method="dialog">` all close it natively with no spec state.
|
|
231
|
-
|
|
232
|
-
**Important:** Do not use `data-event` on text inputs to mirror their value into state. The `innerHTML` replacement on every keystroke destroys focus. Instead, use uncontrolled inputs and capture `FormData` in `action.onStart` before `validate` runs.
|
|
233
|
-
|
|
234
|
-
## Dev vs Production Hydration
|
|
235
|
-
|
|
236
|
-
**Dev mode** — the HTML includes an inline bootstrap script importing source files directly:
|
|
237
|
-
```html
|
|
238
|
-
<script type="module">
|
|
239
|
-
import spec from '/examples/counter.js'
|
|
240
|
-
import { mount } from '/src/runtime/index.js'
|
|
241
|
-
import { initNavigation } from '/src/runtime/navigate.js'
|
|
242
|
-
mount(spec, root, window.__PULSE_SERVER__ || {}, { ssr: true })
|
|
243
|
-
initNavigation(root, mount)
|
|
244
|
-
</script>
|
|
245
|
-
```
|
|
246
|
-
|
|
247
|
-
**Production** — after `npm run build`, `spec.hydrate` is resolved via `manifest.json` to a content-hashed bundle. The HTML becomes a single external tag:
|
|
248
|
-
```html
|
|
249
|
-
<script type="module" src="/dist/counter.boot-HASH.js"></script>
|
|
250
|
-
```
|
|
251
|
-
|
|
252
|
-
The bundle is self-executing (imports spec, calls `mount` + `initNavigation` internally). The `{ ssr: true }` option tells `mount` to skip the initial re-render and only bind events — this preserves the SSR-painted LCP element.
|
|
253
|
-
|
|
254
|
-
## Build Output
|
|
255
|
-
|
|
256
|
-
`npm run build` generates three things per app:
|
|
257
|
-
|
|
258
|
-
- `public/dist/runtime-[hash].js` — shared runtime (mount + navigate + store, ~3.8 kB brotli)
|
|
259
|
-
- `public/dist/[name].boot-[hash].js` — per-page spec bundle (~0.5–0.9 kB brotli)
|
|
260
|
-
- `public/dist/manifest.json` — maps `/examples/foo.js` → `/dist/foo.boot-HASH.js`
|
|
261
|
-
|
|
262
|
-
To add a new page to the build, add its spec path to the `ENTRIES` array in `scripts/build.js`.
|
|
263
|
-
|
|
264
|
-
The server auto-detects the manifest from `staticDir/dist/manifest.json` when `staticDir` is set. No config needed.
|
|
265
|
-
|
|
266
|
-
## Server
|
|
267
|
-
|
|
268
|
-
```js
|
|
269
|
-
import { createServer } from './src/server/index.js'
|
|
270
|
-
|
|
271
|
-
// Pass URL objects — hydrate is auto-derived, no need to set it in specs.
|
|
272
|
-
// Specs with mutations/actions/persist are hydrated automatically.
|
|
273
|
-
// Purely server-rendered specs get zero JavaScript.
|
|
274
|
-
await createServer(
|
|
275
|
-
[
|
|
276
|
-
new URL('./src/pages/home.js', import.meta.url),
|
|
277
|
-
new URL('./src/pages/contact.js', import.meta.url),
|
|
278
|
-
],
|
|
279
|
-
{
|
|
280
|
-
port: 3000,
|
|
281
|
-
stream: true, // streaming SSR (default)
|
|
282
|
-
staticDir: 'public', // serve static files + auto-load manifest
|
|
283
|
-
root: new URL('.', import.meta.url), // project root for deriving browser paths
|
|
284
|
-
manifest: null, // explicit manifest path or object (overrides auto-detect)
|
|
285
|
-
defaultCache: 3600, // default HTML cache TTL in seconds for all pages (prod only)
|
|
286
|
-
// also accepts true (3600s + swr 86400s) or { public, maxAge, staleWhileRevalidate }
|
|
287
|
-
// spec.cache overrides per-page; in-process + Cache-Control headers both set
|
|
288
|
-
fetcherTimeout: 5000, // ms before any server fetcher times out (null = no limit)
|
|
289
|
-
// spec.serverTimeout overrides per-page
|
|
290
|
-
shutdownTimeout: 30000, // ms to wait for in-flight requests before force-exit on SIGTERM/SIGINT
|
|
291
|
-
healthCheck: '/healthz', // built-in health endpoint path, or false to disable
|
|
292
|
-
secret: process.env.PULSE_SECRET, // HMAC secret for CSRF tokens — set a stable value
|
|
293
|
-
// when running multiple instances (default: random per boot)
|
|
294
|
-
sitemap: true, // auto-serve /sitemap.xml + robots.txt from registered routes
|
|
295
|
-
// also accepts { origin: 'https://mysite.com' } to pin the URL origin;
|
|
296
|
-
// dynamic :param routes contribute via spec.sitemap enumerators;
|
|
297
|
-
// a physical sitemap.xml/robots.txt in staticDir always wins
|
|
298
|
-
robots: null, // robots.txt when sitemap on: null = auto, false = off, string = verbatim
|
|
299
|
-
redirects: { // declarative redirects for legacy URLs — 301 by default,
|
|
300
|
-
'/old-blog/:slug': '/blog/:slug', // :params carry over, query string preserved
|
|
301
|
-
'/promo': { to: '/pricing', status: 302 }, // custom status: 301 | 302 | 307 | 308
|
|
302
|
-
}, // checked before routing (GET/HEAD only); validated at startup
|
|
303
|
-
live: true, // SSE store-push channel (default path /__pulse/live) —
|
|
304
|
-
// createServer returns pushStore(partial) for broadcasting
|
|
305
|
-
csp: { // extra sources merged into the framework's default CSP
|
|
306
|
-
'style-src': ['https://fonts.googleapis.com'],
|
|
307
|
-
'font-src': ['https://fonts.gstatic.com'],
|
|
308
|
-
},
|
|
309
|
-
resolveBrand: async (host) => db.brands.findBySlug(host.split('.')[0]),
|
|
310
|
-
// multi-brand: result cached 60s, attached to ctx.brand
|
|
311
|
-
onRequest: (req, res) => { /* return false to short-circuit */ },
|
|
312
|
-
onError: (err, req, res) => { /* custom error handling */ }
|
|
313
|
-
}
|
|
314
|
-
)
|
|
315
|
-
```
|
|
316
|
-
|
|
317
|
-
`createServer` is async — always `await` it. Spec objects are still accepted alongside URL objects for backwards compatibility.
|
|
318
|
-
|
|
319
|
-
All specs are validated at startup — bad specs throw before the server accepts connections.
|
|
320
|
-
|
|
321
|
-
## Live Store Push (SSE)
|
|
322
|
-
|
|
323
|
-
With `live: true`, `createServer` returns a **`pushStore(partial)`** function that broadcasts a store patch to every connected browser. Pages subscribed via `spec.store` re-render immediately — no polling, no client code:
|
|
324
|
-
|
|
325
|
-
```js
|
|
326
|
-
// Enable in pulse.config.js (CLI-managed apps) or createServer options:
|
|
327
|
-
export default { live: true }
|
|
328
|
-
```
|
|
329
|
-
|
|
330
|
-
```js
|
|
331
|
-
// Broadcast from any spec file — e.g. a webhook that updates stock:
|
|
332
|
-
import { pushStore } from '@invisibleloop/pulse'
|
|
333
|
-
|
|
334
|
-
export default {
|
|
335
|
-
route: '/hooks/stock',
|
|
336
|
-
contentType: 'text/plain',
|
|
337
|
-
state: {},
|
|
338
|
-
render: async (ctx) => {
|
|
339
|
-
const { sku, stock } = await ctx.json()
|
|
340
|
-
pushStore({ stock }) // every page with store: ['stock'] re-renders
|
|
341
|
-
return 'ok'
|
|
342
|
-
},
|
|
343
|
-
}
|
|
344
|
-
```
|
|
345
|
-
|
|
346
|
-
When you own the server entry, `createServer` also returns the handle directly: `const { pushStore } = await createServer(pages, { live: true })`.
|
|
347
|
-
|
|
348
|
-
- The client connects automatically — only on pages that declare `store: [...]` keys; one EventSource per tab, survives client navigations, reconnects natively.
|
|
349
|
-
- **Broadcast channel: shared data only** (stock counts, announcements, live scores). Never push per-user data — every connected client receives every patch.
|
|
350
|
-
- Views must handle keys that haven't arrived yet: `server.stock ?? '—'`.
|
|
351
|
-
|
|
352
|
-
## Multi-brand Sites
|
|
353
|
-
|
|
354
|
-
Pass `resolveBrand: async (host) => brandConfig` to `createServer`. The result is cached per host for 60 seconds and attached to `ctx.brand`. It is available in `guard`, `server` fetchers, and any `meta` field that is a function.
|
|
355
|
-
|
|
356
|
-
Any `meta` field can be a function `(ctx) => value` — called per request, not at startup. Meta functions can also be **async**, so you can fetch data for `title`, `description`, `ogImage` etc.:
|
|
357
|
-
|
|
358
|
-
```js
|
|
359
|
-
export default {
|
|
360
|
-
meta: {
|
|
361
|
-
title: async (ctx) => {
|
|
362
|
-
const product = await fetchProduct(ctx.params.id)
|
|
363
|
-
return product ? `${product.name} — Store` : 'Store'
|
|
364
|
-
},
|
|
365
|
-
styles: (ctx) => ['/pulse-ui.css', `/themes/${ctx.brand.slug}.css`],
|
|
366
|
-
},
|
|
367
|
-
server: {
|
|
368
|
-
brand: (ctx) => ctx.brand, // expose to view as serverState.brand
|
|
369
|
-
},
|
|
370
|
-
view: (state, { brand }) => `<h1>${brand.name}</h1>`,
|
|
371
|
-
guard: async (ctx) => {
|
|
372
|
-
if (!ctx.brand) return { redirect: '/not-found' }
|
|
373
|
-
},
|
|
374
|
-
}
|
|
375
|
-
```
|
|
376
|
-
|
|
377
|
-
If `meta` and `server` both need the same API data, use a **request-scoped cache** on `ctx` to avoid fetching twice. `ctx` is created fresh per request, so `ctx._cache` never leaks between requests:
|
|
378
|
-
|
|
379
|
-
```js
|
|
380
|
-
// shared-fetchers.js
|
|
381
|
-
export async function getProduct(ctx) {
|
|
382
|
-
ctx._cache ??= {}
|
|
383
|
-
ctx._cache.product ??= await db.products.find(ctx.params.id)
|
|
384
|
-
return ctx._cache.product
|
|
385
|
-
}
|
|
386
|
-
```
|
|
387
|
-
|
|
388
|
-
```js
|
|
389
|
-
// spec
|
|
390
|
-
import { getProduct } from './shared-fetchers.js'
|
|
391
|
-
|
|
392
|
-
export default {
|
|
393
|
-
meta: {
|
|
394
|
-
title: async (ctx) => {
|
|
395
|
-
const p = await getProduct(ctx) // fetches once, cached on ctx
|
|
396
|
-
return p ? `${p.name} — Store` : 'Store'
|
|
397
|
-
},
|
|
398
|
-
},
|
|
399
|
-
server: {
|
|
400
|
-
product: (ctx) => getProduct(ctx), // hits the same cache, no second fetch
|
|
401
|
-
},
|
|
402
|
-
}
|
|
403
|
-
```
|
|
404
|
-
|
|
405
|
-
Keep brand differences in CSS custom properties — one `/pulse-ui.css` for components, one small `/themes/slug.css` per brand that overrides `:root` variables only.
|
|
406
|
-
|
|
407
|
-
## Custom Fonts
|
|
408
|
-
|
|
409
|
-
`pulse-ui.css` exposes `--ui-font: var(--font, system-ui, ...)` and `--ui-mono: var(--mono, ...)`. All components use these tokens. To set a custom font, override `--font` in `:root` — it cascades into every component automatically.
|
|
410
|
-
|
|
411
|
-
```css
|
|
412
|
-
/* app.css */
|
|
413
|
-
:root {
|
|
414
|
-
--font: 'Inter', system-ui, sans-serif;
|
|
415
|
-
}
|
|
416
|
-
```
|
|
417
|
-
|
|
418
|
-
Load the font via `meta.styles` before `pulse-ui.css`, or self-host in `staticDir/fonts/` with `@font-face`:
|
|
419
|
-
|
|
420
|
-
```js
|
|
421
|
-
meta: {
|
|
422
|
-
styles: [
|
|
423
|
-
'https://fonts.googleapis.com/css2?family=Inter&display=swap',
|
|
424
|
-
'/pulse-ui.css',
|
|
425
|
-
'/app.css',
|
|
426
|
-
]
|
|
427
|
-
}
|
|
428
|
-
```
|
|
429
|
-
|
|
430
|
-
For multi-brand setups, each brand theme file just overrides `--font` in `:root`.
|
|
431
|
-
|
|
432
|
-
## Overriding Theme Colours
|
|
433
|
-
|
|
434
|
-
Use **input tokens** (unprefixed: `--accent`, `--bg`, `--text`, etc.) in `theme.css`. `pulse-ui.css` maps them to output tokens via `var()` in **both** the dark (`:root`) and light (`[data-theme="light"]`) blocks:
|
|
435
|
-
|
|
436
|
-
```css
|
|
437
|
-
/* Dark theme — in :root */
|
|
438
|
-
:root {
|
|
439
|
-
--accent: #9b8dff;
|
|
440
|
-
}
|
|
441
|
-
|
|
442
|
-
/* Light theme — in [data-theme="light"] */
|
|
443
|
-
[data-theme="light"] {
|
|
444
|
-
--accent: #e25; /* overrides the light default */
|
|
445
|
-
}
|
|
446
|
-
```
|
|
447
|
-
|
|
448
|
-
Setting `--accent` in `[data-theme="light"]` works correctly — `pulse-ui.css` resolves `var(--accent, ...)` there just like it does in `:root`.
|
|
449
|
-
|
|
450
|
-
If you need to override an output token directly (e.g. one with no input equivalent), target `[data-theme="light"]` explicitly:
|
|
451
|
-
|
|
452
|
-
```css
|
|
453
|
-
/* Direct output token override for light theme only */
|
|
454
|
-
[data-theme="light"] {
|
|
455
|
-
--ui-nav-sticky-bg: rgba(255, 255, 255, 0.95);
|
|
456
|
-
}
|
|
457
|
-
```
|
|
458
|
-
|
|
459
|
-
## CSS File Responsibilities
|
|
460
|
-
|
|
461
|
-
There are two CSS files and they have distinct roles — do not mix them:
|
|
462
|
-
|
|
463
|
-
| File | Purpose | Allowed content |
|
|
464
|
-
|---|---|---|
|
|
465
|
-
| `public/theme.css` | Token definitions — hex values, raw colours, font URLs | Hex values, `rgb()`, `rgba()`, `hsl()`, `hsla()`, raw values, `@font-face`, `@import` |
|
|
466
|
-
| `app.css` (or per-page) | Layout and component overrides | `var()` tokens **only** — no hex values |
|
|
467
|
-
|
|
468
|
-
**`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`.
|
|
469
|
-
|
|
470
|
-
**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.
|
|
471
|
-
|
|
472
|
-
```css
|
|
473
|
-
/* public/theme.css — hex values live here */
|
|
474
|
-
[data-theme="light"] {
|
|
475
|
-
--color-accent: #e25;
|
|
476
|
-
--color-bg: #ffffff;
|
|
477
|
-
}
|
|
478
|
-
|
|
479
|
-
/* app.css — only var() references */
|
|
480
|
-
.hero {
|
|
481
|
-
background: var(--color-bg);
|
|
482
|
-
color: var(--color-accent);
|
|
483
|
-
}
|
|
484
|
-
```
|
|
485
|
-
|
|
486
|
-
Load both files via `meta.styles`, theme first:
|
|
487
|
-
|
|
488
|
-
```js
|
|
489
|
-
meta: {
|
|
490
|
-
styles: ['/pulse-ui.css', '/theme.css', '/app.css'],
|
|
491
|
-
}
|
|
492
|
-
```
|
|
493
|
-
|
|
494
|
-
## HTTP Response Behaviour
|
|
495
|
-
|
|
496
|
-
- **Full page request** — SSR HTML with hydration bootstrap
|
|
497
|
-
- **`X-Pulse-Navigate: true`** — returns JSON `{ html, title, hydrate, serverState }` for client-side navigation
|
|
498
|
-
- **Security headers** — on every response: `X-Content-Type-Options`, `X-Frame-Options`, `Referrer-Policy`, `Permissions-Policy`, `Cross-Origin-Opener-Policy`, `Cross-Origin-Resource-Policy`
|
|
499
|
-
- **Compression** — brotli preferred, gzip fallback for all compressible types
|
|
500
|
-
- **Cache** — `/dist/*` bundles: `immutable, max-age=31536000`; static assets: `max-age=3600`; HTML: `no-store`
|
|
501
|
-
|
|
502
|
-
## Client Navigation
|
|
503
|
-
|
|
504
|
-
`initNavigation(root, mount)` intercepts same-origin `<a>` clicks:
|
|
505
|
-
1. Fetches the new page with `X-Pulse-Navigate: true`
|
|
506
|
-
2. Swaps `root.innerHTML` + updates `document.title`
|
|
507
|
-
3. Dynamically imports the new spec bundle (`import(hydrate)`)
|
|
508
|
-
4. Calls `mount(spec, root, serverState)` to re-attach interactivity
|
|
509
|
-
|
|
510
|
-
Falls back to `location.href` on any error.
|
|
511
|
-
|
|
512
|
-
## Testing
|
|
513
|
-
|
|
514
|
-
Tests use Node.js built-in `node:test` and `node:assert`. Each test file is run directly:
|
|
515
|
-
|
|
516
|
-
```bash
|
|
517
|
-
node src/spec/schema.test.js
|
|
518
|
-
node src/runtime/runtime.test.js
|
|
519
|
-
node src/runtime/ssr.test.js
|
|
520
|
-
node src/server/server.test.js
|
|
521
|
-
```
|
|
522
|
-
|
|
523
|
-
Server tests use an incrementing port counter (`withServer` helper) to avoid TCP TIME_WAIT conflicts between tests. Each test gets its own port.
|
|
524
|
-
|
|
525
|
-
### View testing — `@invisibleloop/pulse/testing`
|
|
526
|
-
|
|
527
|
-
Use `renderSync` and `render` to test view HTML output. Both return a `RenderResult` with CSS-like query helpers — no DOM, no jsdom, pure Node.js.
|
|
528
|
-
|
|
529
|
-
```js
|
|
530
|
-
import { renderSync, render } from '@invisibleloop/pulse/testing'
|
|
531
|
-
|
|
532
|
-
// Sync — calls view directly, pass mock state/server
|
|
533
|
-
const result = renderSync(mySpec, { state: { count: 5 }, server: { items: [] } })
|
|
534
|
-
result.has('button') // → true/false
|
|
535
|
-
result.get('#count').text // → '5' (throws if not found)
|
|
536
|
-
result.find('.title')?.text // → string | null
|
|
537
|
-
result.findAll('li') // → Element[]
|
|
538
|
-
result.count('li') // → number
|
|
539
|
-
result.attr('input[name="email"]', 'value') // → string | null
|
|
540
|
-
result.text() // → all text, tags stripped
|
|
541
|
-
|
|
542
|
-
// Async — runs real spec.server fetchers (pass server to mock them)
|
|
543
|
-
const result = await render(mySpec, { server: { product: mockProduct } })
|
|
544
|
-
const result = await render(mySpec, { ctx: { params: { id: '1' } } }) // real fetchers
|
|
545
|
-
```
|
|
546
|
-
|
|
547
|
-
**Supported selectors:** `button`, `.class`, `#id`, `[attr]`, `[attr="value"]`, and combinations: `button.primary[type="submit"]`. **Descendant combinators are supported:** `result.count('.parent li')` or `result.find('tbody tr')`.
|
|
548
|
-
|
|
549
|
-
`Element` also has `.find()`, `.findAll()`, `.has()`, `.attr()`, `.text`, `.tag`, `.attrs`.
|
|
550
|
-
|
|
551
|
-
## Key Decisions (Do Not Reverse)
|
|
552
|
-
|
|
553
|
-
| Decision | Reason |
|
|
554
|
-
|---|---|
|
|
555
|
-
| `{ ssr: true }` skips initial render in `mount()` | Preserves SSR-painted LCP — re-rendering would cause a flash and push LCP to ~500ms |
|
|
556
|
-
| `onStart` captures `FormData` before `validate` runs | Uncontrolled inputs — `innerHTML` replacement on keystroke destroys focus |
|
|
557
|
-
| Self-executing bundles with `export default spec` | Single HTTP request for JS; spec exported so client navigation can re-mount |
|
|
558
|
-
| `splitting: true` in esbuild | Shared runtime extracted once, cached across all page navigations |
|
|
559
|
-
| `window.__PULSE_SERVER__` injected by `renderToStream` | Streaming path must serialize server state into HTML — client hydration reads it without a second request |
|
|
560
|
-
| Bundle path detection via `/dist/` prefix | `wrapDocument` and streaming path emit `<script src>` for bundles, inline bootstrap for dev source files |
|
|
561
|
-
| Security headers on every response, including 404/405 | Defense in depth — error responses are also entry points |
|
|
562
|
-
|
|
563
|
-
## Build Workflow
|
|
564
|
-
|
|
565
|
-
### New page or new site? Start with intake — always
|
|
566
|
-
|
|
567
|
-
For any **new page, landing page, or branded site**, run this sequence first. Do not skip to building.
|
|
568
|
-
|
|
569
|
-
```
|
|
570
|
-
0. pulse_extract_inspiration(url/image) → structured design brief [if user shares reference]
|
|
571
|
-
1. pulse_intake(name, pitch, features, ...) → product brief + contrast check
|
|
572
|
-
2. pulse_sketch(brief, vibe?, antiStyle?) → 3 layout directions to choose from
|
|
573
|
-
3. pulse_intent(description) → archetype + scaffold + guide list
|
|
574
|
-
```
|
|
575
|
-
|
|
576
|
-
**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.
|
|
577
|
-
|
|
578
|
-
**`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.
|
|
579
|
-
|
|
580
|
-
For **small edits, bug fixes, or "add X to existing Y"** — skip intake/sketch and go straight to Understand below.
|
|
581
|
-
|
|
582
|
-
---
|
|
583
|
-
|
|
584
|
-
Every build task follows this sequence. Each phase has a pass gate — do not advance until it clears.
|
|
585
|
-
|
|
586
|
-
**Before calling any slow tool (`pulse_build`, `lighthouse_audit`), output a status message to the user first** — e.g. "Building for production — ~30 s…" or "Running Lighthouse desktop audit…". Never call a slow tool silently.
|
|
587
|
-
|
|
588
|
-
> **Canonical workflow:** `pulse://workflow` (`src/agent/workflow.md`) is the authoritative version, including complexity tiers (not every task needs all phases). The table below is the summary — if they ever disagree, the MCP resource wins.
|
|
589
|
-
|
|
590
|
-
| Phase | Action | Gate |
|
|
591
|
-
|---|---|---|
|
|
592
|
-
| 1. Understand | Read guides, call `pulse_list_structure` | — |
|
|
593
|
-
| 2. Plan | Present plan to user, wait for confirmation | User confirms |
|
|
594
|
-
| 3. Build | Write spec + related files | — |
|
|
595
|
-
| 4. Validate | `pulse_validate` — fix all errors + warnings | Clean output |
|
|
596
|
-
| 5a. Design approval *(new builds only)* | Screenshot → describe → ask the user | ⛔ User approves layout before any slow checks run (skip for edits/fixes) |
|
|
597
|
-
| 5b. Browser | `pulse_design_review` (if intake ran) → `pulse_layout_review` → `/verify` (Lighthouse desktop + mobile + `pulse_review`) | ⛔ Do not skip: all gates must pass + `.pulse-verified` stamp written |
|
|
598
|
-
| 6. Tests | Write tests, run them, fix failures | All pass |
|
|
599
|
-
| 7. Code Review | `pulse_review` — only after phases 4–6 pass | — |
|
|
600
|
-
| 8. Fix | Fix every review issue, re-run affected gates | All gates still pass |
|
|
601
|
-
|
|
602
|
-
**Skip phase 2 confirmation only for trivially small, unambiguous tasks.** When in doubt, confirm.
|
|
603
|
-
|
|
604
|
-
**Template mode exception:** when adapting a pre-built template (see `pulse://guide/templates`), replace the full plan with a single compact confirmation block — template, substitutions, files. One turn is enough; do not run the full phase 2 ceremony.
|
|
605
|
-
|
|
606
|
-
**The Code Review is always last.** Never invoke `pulse_review` before validate, Lighthouse, design review, and tests all pass.
|
|
607
|
-
|
|
608
|
-
The `/verify` command runs the browser check loop (phases 4–5b) automatically. Use it.
|
|
609
|
-
|
|
610
|
-
## Pulse vs React — Do Not Do These
|
|
611
|
-
|
|
612
|
-
Pulse views are plain JS template literals, not JSX. These React patterns are **wrong in Pulse**:
|
|
613
|
-
|
|
614
|
-
| Wrong (React) | Correct (Pulse) |
|
|
615
|
-
|---|---|
|
|
616
|
-
| `className="foo"` | `class="foo"` |
|
|
617
|
-
| `htmlFor="id"` | `for="id"` |
|
|
618
|
-
| `onClick={handler}` | `data-event="click:mutationName"` |
|
|
619
|
-
| `onChange={handler}` | `data-event="change:mutationName"` |
|
|
620
|
-
| `<input value={state.x}>` | Uncontrolled — read via `FormData` in `onStart` |
|
|
621
|
-
| `{expression}` in JSX | `${expression}` in template literal |
|
|
622
|
-
| `<>...</>` fragments | No fragments — use a real wrapper element |
|
|
623
|
-
| `import React from 'react'` | No import needed — plain JS |
|
|
624
|
-
| `useState`, `useEffect`, `useRef` | No hooks — use `state`, `mutations`, `actions` |
|
|
625
|
-
| `key={item.id}` on list items | No `key` props — no virtual DOM |
|
|
626
|
-
| Capitalized `<MyComponent />` | Call as a plain function: `${myComponent(props)}` |
|
|
627
|
-
| `setState(prev => ...)` | Mutations return partial state: `(state) => ({ x: state.x + 1 })` |
|
|
628
|
-
|
|
629
|
-
Also: mutations must be pure (no fetch, no DOM access). Only `actions` can be async.
|
|
630
|
-
|
|
631
|
-
## Markdown
|
|
632
|
-
|
|
633
|
-
Pulse has a built-in markdown parser. Use it for blog posts, docs pages, and any static content written in `.md` files. Import from `@invisibleloop/pulse/md`. All parsing is server-side — zero browser JS.
|
|
634
|
-
|
|
635
|
-
```js
|
|
636
|
-
import { md } from '@invisibleloop/pulse/md'
|
|
637
|
-
import { prose } from '@invisibleloop/pulse/ui'
|
|
638
|
-
|
|
639
|
-
// Static path
|
|
640
|
-
const page = md('content/about.md')
|
|
641
|
-
|
|
642
|
-
// Dynamic route — :param resolved from ctx.params
|
|
643
|
-
const post = md('content/blog/:slug.md')
|
|
644
|
-
|
|
645
|
-
export default {
|
|
646
|
-
route: '/blog/:slug',
|
|
647
|
-
meta: {
|
|
648
|
-
title: async (ctx) => (await post(ctx)).frontmatter.title,
|
|
649
|
-
description: async (ctx) => (await post(ctx)).frontmatter.description,
|
|
650
|
-
},
|
|
651
|
-
server: { post },
|
|
652
|
-
view: (state, { post }) => `
|
|
653
|
-
<main id="main-content">
|
|
654
|
-
${prose({ content: post.html })}
|
|
655
|
-
</main>
|
|
656
|
-
`,
|
|
657
|
-
onViewError: () => `<main id="main-content"><p>Post not found.</p></main>`,
|
|
658
|
-
}
|
|
659
|
-
```
|
|
660
|
-
|
|
661
|
-
- `md(path)` returns an async fetcher — call it in both `meta` and `server`, the file is read only once per request
|
|
662
|
-
- Result is `{ html, frontmatter }` — `html` goes into `prose()`, `frontmatter` holds the `---` key/value pairs
|
|
663
|
-
- Missing file throws `{ status: 404 }` — always add `onViewError` on dynamic markdown routes
|
|
664
|
-
- For markdown from a database/API use `parseMd(source)` directly: `import { parseMd } from '@invisibleloop/pulse/md'`
|
|
665
|
-
- Use `new URL('./content/file.md', import.meta.url)` to resolve relative to the spec file rather than `process.cwd()`
|
|
666
|
-
|
|
667
|
-
## Check Components Before Building
|
|
668
|
-
|
|
669
|
-
Before writing any UI HTML by hand, check `src/ui/index.js` — there are 50+ components available. Use them. Do not reinvent `button`, `card`, `alert`, `modal`, `spinner`, `badge`, `input`, etc.
|
|
670
|
-
|
|
671
|
-
**Common patterns with dedicated components — never write these from scratch:**
|
|
672
|
-
|
|
673
|
-
- Hero sections → `hero({ title, subtitle, actions, image, layout })`
|
|
674
|
-
- Product/service cards → `card({ title, body, image, footer })`
|
|
675
|
-
- Image + text layouts → `media({ image, content, reverse })`
|
|
676
|
-
- Feature tiles → `feature({ icon, title, description })`
|
|
677
|
-
- Testimonials → `card({ content, footer })` with quote + avatar/name in the content slot
|
|
678
|
-
- Call-to-action sections → `cta({ title, subtitle, actions })`
|
|
679
|
-
|
|
680
|
-
If you're about to write `class="hero"` or `class="product-card"`, stop — import the component instead. You can add custom CSS on top of components for brand-specific styling, but the structure and accessibility must come from the component.
|
|
681
|
-
|
|
682
|
-
@src/agent/checklist.md
|
|
683
|
-
|
|
684
|
-
## Performance Baseline
|
|
685
|
-
|
|
686
|
-
| Page | CLS | JS (brotli) |
|
|
687
|
-
|---|---|---|
|
|
688
|
-
| /counter | 0.00 | 4.2 kB (first visit) / 0.4 kB (cached runtime) |
|
|
689
|
-
| /contact | 0.00 | 4.3 kB (first visit) / 0.5 kB (cached runtime) |
|
|
690
|
-
|
|
691
|
-
First-visit JS = runtime chunk (~3.8 kB) + per-page boot file (~0.4–0.9 kB). On repeat visits the runtime is served from cache — only the boot file is fetched. If multiple pages share UI components, esbuild's code splitting extracts those into the runtime chunk, so the runtime chunk grows with shared code.
|
|
692
|
-
|
|
693
|
-
Lighthouse: 100/100/100 (Accessibility / Best Practices / SEO) on both pages.
|
|
694
|
-
|
|
695
|
-
LCP is fast by design (streaming SSR sends HTML before data resolves) but actual millisecond values depend on machine speed, browser, network conditions, and server location — do not quote specific numbers.
|
|
696
|
-
|
|
697
|
-
**Caveat — third-party ads:** Pages that load ad network scripts will not achieve 100 across all categories. Ad scripts introduce render-blocking JS, third-party cookies (Best Practices), and late-loading content that causes CLS. Reserving fixed space for ad slots reduces CLS but cannot fully compensate for what ad networks inject at runtime. Treat 100/100/100 as the baseline for ad-free pages only.
|