@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/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@invisibleloop/pulse",
|
|
3
|
-
"version": "0.20.
|
|
3
|
+
"version": "0.20.19",
|
|
4
4
|
"type": "module",
|
|
5
5
|
"description": "AI-first frontend framework. The spec is the source of truth.",
|
|
6
6
|
"license": "MIT",
|
|
@@ -61,10 +61,10 @@
|
|
|
61
61
|
"scripts": {
|
|
62
62
|
"dev": "node --watch src/cli/dev.js",
|
|
63
63
|
"build": "node scripts/build.js",
|
|
64
|
-
"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",
|
|
64
|
+
"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/cli/validate.test.js && node src/cli/diagnose.test.js && node src/cli/check-bundles.test.js && node src/cli/review.test.js && node src/cli/process-utils.test.js && node src/cli/stamp.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",
|
|
65
65
|
"typecheck": "tsc --noEmit",
|
|
66
|
-
"docs": "node --watch src/cli/dev.js --root docs --port 4000",
|
|
67
|
-
"docs:start": "node docs/server.js"
|
|
66
|
+
"docs": "node --watch src/cli/dev.js --root ../../docs --port 4000",
|
|
67
|
+
"docs:start": "node ../../docs/server.js"
|
|
68
68
|
},
|
|
69
69
|
"engines": {
|
|
70
70
|
"node": ">=22"
|
|
@@ -75,7 +75,8 @@
|
|
|
75
75
|
},
|
|
76
76
|
"repository": {
|
|
77
77
|
"type": "git",
|
|
78
|
-
"url": "https://github.com/invisibleloop/pulse-framework"
|
|
78
|
+
"url": "https://github.com/invisibleloop/pulse-framework",
|
|
79
|
+
"directory": "packages/pulse"
|
|
79
80
|
},
|
|
80
81
|
"publishConfig": {
|
|
81
82
|
"access": "public"
|
package/public/.pulse-ui-version
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
0.20.
|
|
1
|
+
0.20.19
|
package/scripts/build.js
CHANGED
|
@@ -59,6 +59,24 @@ function stripServerOnlyKeys(source) {
|
|
|
59
59
|
return source
|
|
60
60
|
}
|
|
61
61
|
|
|
62
|
+
/**
|
|
63
|
+
* A page needs a client boot bundle only if it needs hydration — matches
|
|
64
|
+
* discover.js's needsHydration() exactly (mutations || actions || persist ||
|
|
65
|
+
* store). Call this on source that has already had stripServerOnlyKeys/Imports
|
|
66
|
+
* applied, and that has already been confirmed to have a `view:` key
|
|
67
|
+
* (a view alone is necessary but not sufficient — a purely server-rendered
|
|
68
|
+
* page, e.g. a redirect handler or a spec.submit-only form, has a view and
|
|
69
|
+
* no hydration need at all).
|
|
70
|
+
*
|
|
71
|
+
* `store` counts even with no local mutations/actions: mount() is what wires
|
|
72
|
+
* up data-store-event dispatch AND the live SSE store-push subscription — a
|
|
73
|
+
* store-only page with no client bundle would silently never receive
|
|
74
|
+
* pushStore() broadcasts and have a dead data-store-event button.
|
|
75
|
+
*/
|
|
76
|
+
function needsClientBundle(strippedSource) {
|
|
77
|
+
return /\b(mutations|actions|persist|store)\s*:/.test(strippedSource)
|
|
78
|
+
}
|
|
79
|
+
|
|
62
80
|
/**
|
|
63
81
|
* Strip import statements for server-only modules and any top-level
|
|
64
82
|
* variable declarations whose right-hand side calls the imported names.
|
|
@@ -456,6 +474,14 @@ const bootstrapFiles = pages.flatMap(({ filePath }) => {
|
|
|
456
474
|
const source = fs.readFileSync(filePath, 'utf8')
|
|
457
475
|
const stripped = stripServerOnlyImports(stripServerOnlyKeys(source))
|
|
458
476
|
if (!/\bview\s*:/.test(stripped)) return []
|
|
477
|
+
// A view alone doesn't need hydration — see needsClientBundle's doc comment.
|
|
478
|
+
// Without this, a purely server-rendered page (a redirect handler, a custom
|
|
479
|
+
// 404, any spec.submit-only page) got a client bundle generated anyway —
|
|
480
|
+
// wasted build output that's silently never referenced, and worse, a real
|
|
481
|
+
// build failure if that page imports a local server-only helper module
|
|
482
|
+
// (e.g. a persistence layer using node:fs) that esbuild then tries to
|
|
483
|
+
// bundle for the browser.
|
|
484
|
+
if (!needsClientBundle(stripped)) return []
|
|
459
485
|
|
|
460
486
|
// Use the path relative to src/pages/ so nested pages get unique names.
|
|
461
487
|
// e.g. src/pages/api/products.js → 'api--products' (not 'products')
|
|
@@ -2,29 +2,38 @@
|
|
|
2
2
|
/**
|
|
3
3
|
* Determine the next version from conventional commits.
|
|
4
4
|
*
|
|
5
|
-
* Reads all commits since the last "chore: release X.Y.Z [skip ci]" commit
|
|
6
|
-
* applies conventional commit rules to
|
|
7
|
-
* the new version string to stdout.
|
|
5
|
+
* Reads all commits since the last "chore: release X.Y.Z [skip ci]" commit
|
|
6
|
+
* that touched packages/pulse, applies conventional commit rules to
|
|
7
|
+
* determine the bump level, then prints the new version string to stdout.
|
|
8
8
|
*
|
|
9
9
|
* Bump rules:
|
|
10
10
|
* BREAKING CHANGE footer or "type!:" prefix → major
|
|
11
11
|
* feat: → minor
|
|
12
12
|
* anything else (fix, perf, chore, docs…) → patch
|
|
13
13
|
*
|
|
14
|
-
*
|
|
14
|
+
* Scoped to `-- packages/pulse`: this repo is a monorepo where sibling
|
|
15
|
+
* packages could share the same git history — without the path filter, a
|
|
16
|
+
* commit that only touches a sibling package would incorrectly drive a
|
|
17
|
+
* version bump for the @invisibleloop/pulse framework package.
|
|
18
|
+
*
|
|
19
|
+
* Usage: node scripts/release-version.js (run from packages/pulse/)
|
|
15
20
|
*/
|
|
16
21
|
|
|
17
22
|
import { execSync } from 'child_process'
|
|
18
23
|
import { readFileSync } from 'fs'
|
|
24
|
+
import path from 'path'
|
|
19
25
|
|
|
20
|
-
const
|
|
26
|
+
const PKG_DIR = path.resolve(import.meta.dirname, '..')
|
|
27
|
+
const pkg = JSON.parse(readFileSync(path.join(PKG_DIR, 'package.json'), 'utf8'))
|
|
21
28
|
const [major, minor, patch] = pkg.version.split('.').map(Number)
|
|
22
29
|
|
|
23
|
-
// Collect commit subjects since the last release commit
|
|
24
|
-
// git log outputs newest-first; we stop at the first release
|
|
30
|
+
// Collect commit subjects since the last release commit that touched this
|
|
31
|
+
// package. git log outputs newest-first; we stop at the first release
|
|
32
|
+
// commit we see. Must be run with cwd inside the repo (any subdirectory) —
|
|
33
|
+
// `-- packages/pulse` is resolved relative to the repo root by git itself.
|
|
25
34
|
let commits = []
|
|
26
35
|
try {
|
|
27
|
-
const log = execSync('git log --pretty=format:%s', { encoding: 'utf8' }).trim()
|
|
36
|
+
const log = execSync('git log --pretty=format:%s -- packages/pulse', { encoding: 'utf8' }).trim()
|
|
28
37
|
for (const line of log.split('\n')) {
|
|
29
38
|
if (/^chore: release \d+\.\d+\.\d+/.test(line)) break
|
|
30
39
|
if (line) commits.push(line)
|
|
@@ -50,6 +50,11 @@ function strip(source) {
|
|
|
50
50
|
return stripServerOnlyImports(stripServerOnlyKeys(source))
|
|
51
51
|
}
|
|
52
52
|
|
|
53
|
+
// Mirrors needsClientBundle() in build.js exactly — see its doc comment there.
|
|
54
|
+
function needsClientBundle(strippedSource) {
|
|
55
|
+
return /\b(mutations|actions|persist|store)\s*:/.test(strippedSource)
|
|
56
|
+
}
|
|
57
|
+
|
|
53
58
|
function isInsideString(source, pos) {
|
|
54
59
|
let i = 0
|
|
55
60
|
const stack = []
|
|
@@ -425,6 +430,61 @@ test('subpath imports (/ui) are NOT stripped by the root entry',
|
|
|
425
430
|
`import { button } from '@invisibleloop/pulse/ui'\nexport default {\n route: '/x',\n view: () => button({ label: 'x' }),\n}`
|
|
426
431
|
)
|
|
427
432
|
|
|
433
|
+
// ---------------------------------------------------------------------------
|
|
434
|
+
// needsClientBundle — must match discover.js's needsHydration() exactly.
|
|
435
|
+
// Regression: found via dogfooding. This check used to be "does the spec have
|
|
436
|
+
// a `view:` key" alone, so a purely server-rendered page (a redirect handler,
|
|
437
|
+
// a custom 404, a spec.submit-only form) still got a client bundle generated —
|
|
438
|
+
// and if that page imported a local server-only helper module (e.g. a
|
|
439
|
+
// node:fs-based persistence layer), the production build failed outright when
|
|
440
|
+
// esbuild tried to bundle Node built-ins for the browser.
|
|
441
|
+
// ---------------------------------------------------------------------------
|
|
442
|
+
|
|
443
|
+
console.log('\nneedsClientBundle — matches discover.js hydration logic\n')
|
|
444
|
+
|
|
445
|
+
function boolTest(label, input, expected) {
|
|
446
|
+
const got = needsClientBundle(strip(input))
|
|
447
|
+
if (got === expected) {
|
|
448
|
+
console.log(' ✓ ' + label)
|
|
449
|
+
pass++
|
|
450
|
+
} else {
|
|
451
|
+
console.log(' ✗ ' + label)
|
|
452
|
+
console.log(' expected: ' + expected)
|
|
453
|
+
console.log(' got: ' + got)
|
|
454
|
+
fail++
|
|
455
|
+
}
|
|
456
|
+
}
|
|
457
|
+
|
|
458
|
+
boolTest('a view-only spec (no mutations/actions/persist) does not need a bundle — the exact bug this fixes',
|
|
459
|
+
`export default {\n route: '/:code',\n guard: async (ctx) => {},\n view: () => '',\n}`,
|
|
460
|
+
false
|
|
461
|
+
)
|
|
462
|
+
|
|
463
|
+
boolTest('a spec.submit-only page does not need a bundle',
|
|
464
|
+
`export default {\n route: '/',\n submit: async (ctx) => ({ redirect: '/' }),\n view: () => '',\n}`,
|
|
465
|
+
false
|
|
466
|
+
)
|
|
467
|
+
|
|
468
|
+
boolTest('mutations require a bundle',
|
|
469
|
+
`export default {\n route: '/x',\n state: {},\n mutations: {\n inc: (s) => ({ n: s.n + 1 }),\n },\n view: () => '',\n}`,
|
|
470
|
+
true
|
|
471
|
+
)
|
|
472
|
+
|
|
473
|
+
boolTest('actions require a bundle',
|
|
474
|
+
`export default {\n route: '/x',\n state: {},\n actions: {\n save: { run: async () => {} },\n },\n view: () => '',\n}`,
|
|
475
|
+
true
|
|
476
|
+
)
|
|
477
|
+
|
|
478
|
+
boolTest('persist requires a bundle',
|
|
479
|
+
`export default {\n route: '/x',\n state: { count: 0 },\n persist: ['count'],\n view: () => '',\n}`,
|
|
480
|
+
true
|
|
481
|
+
)
|
|
482
|
+
|
|
483
|
+
boolTest('store alone (no local mutations/actions) requires a bundle — regression: mount() is what wires up data-store-event dispatch AND the live SSE store-push subscription; a store-only page with no client bundle would silently never receive pushStore() broadcasts and have a dead data-store-event button',
|
|
484
|
+
`export default {\n route: '/x',\n store: ['count'],\n state: {},\n view: (state, server) => \`\${server.count}\`,\n}`,
|
|
485
|
+
true
|
|
486
|
+
)
|
|
487
|
+
|
|
428
488
|
// ---------------------------------------------------------------------------
|
|
429
489
|
|
|
430
490
|
console.log(`\n${pass + fail} tests: ${pass} passed, ${fail} failed\n`)
|
package/src/agent/checklist.md
CHANGED
|
@@ -2,6 +2,8 @@
|
|
|
2
2
|
|
|
3
3
|
Before finishing any spec, verify every point below. Fix anything that fails.
|
|
4
4
|
|
|
5
|
+
**Most of these are already mechanically checked** by `pulse_review` (auto-checked items: hex colours, tabindex, `data-event` on inputs, React patterns, main landmark, XSS test presence, modal-state pattern, CSRF token presence, `_storeUpdate` shape, creative-override detection). Where a rule is auto-checked, this file states *why* the rule exists and how to judge edge cases (e.g. "is this a real creative override?") — it does not restate the mechanical check itself. Rules with no entry below in "Auto-checked" are prose-only — nothing catches them but you.
|
|
6
|
+
|
|
5
7
|
### Critical
|
|
6
8
|
|
|
7
9
|
- **`meta` must be a plain object — never a function.** Individual fields (`title`, `description`, `styles`) can be `async (ctx) => value` functions, but `meta` itself is always `{}`.
|
|
@@ -37,15 +39,9 @@ Before finishing any spec, verify every point below. Fix anything that fails.
|
|
|
37
39
|
- Navigation → `nav({ logo, links, actions })`
|
|
38
40
|
- Footers → `footer({ logo, links, columns, legal, ... })`
|
|
39
41
|
|
|
40
|
-
- **Creative override — raw HTML throughout is permitted** when the design intent genuinely calls for it: a highly custom visual identity, an asymmetric or typographically-driven layout, a brutalist/editorial/retro/neon/paper vibe where components would constrain the expression, or a layout that requires features a component simply doesn't support (e.g. full-viewport height, custom gradient glows, clamp-scaled display type). This is a deliberate design decision, not a shortcut.
|
|
41
|
-
- **Declare it in a comment at the top of the spec** so the reviewer recognises it: `// component-free — creative override: <reason>`. This is the signal the review tool reads.
|
|
42
|
-
- Functional atoms (`button`, `input`, `badge`, `modal`) should still come from components unless there is a specific design reason not to.
|
|
43
|
-
- `/pulse-ui.css` is always required — it provides the token system your custom CSS will use.
|
|
44
|
-
- The quality gate replaces the component checklist: **Lighthouse 100 on Accessibility, Best Practices, and SEO — desktop and mobile — plus CLS 0.00 is the pass bar.** (Performance is reported but not gated; it varies with machine load.) A component-free page that passes every audit is correct. A component-heavy page that fails accessibility is not.
|
|
45
|
-
|
|
46
|
-
- **When reviewing**: before flagging a class name like `.hero` as a violation, first ask whether the `hero()` component could actually reproduce the layout. If the answer is no, it is a creative override — check Lighthouse instead of refactoring. Do not flatten a good custom layout into a generic component just to clear a pattern-match.
|
|
42
|
+
- **Creative override — raw HTML throughout is permitted** when the design intent genuinely calls for it: a highly custom visual identity, an asymmetric or typographically-driven layout, a brutalist/editorial/retro/neon/paper vibe where components would constrain the expression, or a layout that requires features a component simply doesn't support (e.g. full-viewport height, custom gradient glows, clamp-scaled display type). This is a deliberate design decision, not a shortcut. **Declare it in a comment at the top of the spec** — `// component-free — creative override: <reason>` — that's the only signal `pulse_review` reads to switch component checks from failures to advisory. Functional atoms (`button`, `input`, `badge`, `modal`) should still come from components unless there's a specific design reason not to. The quality gate replaces the component checklist: Lighthouse 100 on Accessibility/Best Practices/SEO, desktop and mobile, plus CLS 0.00.
|
|
47
43
|
|
|
48
|
-
- **The
|
|
44
|
+
- **The judgment call, not the mechanics:** before flagging a class name like `.hero` as a violation, ask whether the `hero()` component could actually reproduce the layout. If no, it's a legitimate creative override — check Lighthouse instead of refactoring. Don't flatten a good custom layout into a generic component just to clear a pattern-match.
|
|
49
45
|
|
|
50
46
|
- **Never write App Store or Google Play download buttons by hand.** Always use `appBadge({ store: 'apple', href })` and `appBadge({ store: 'google', href })`. Raw `<a>` tags with badge images are incorrect — `appBadge` renders the correct accessible, styled badge for each store.
|
|
51
47
|
|
|
@@ -76,7 +72,7 @@ Before finishing any spec, verify every point below. Fix anything that fails.
|
|
|
76
72
|
- `persist` contains only serialisable state that should survive a page reload — not ephemeral UI state like a loading flag or temporary selection.
|
|
77
73
|
- `e.target` assumptions in mutations are safe if the element has child nodes — use `e.target.closest('[data-index]')` rather than assuming `e.target` is the element with the attribute.
|
|
78
74
|
- State shape is consistent — avoid a single field that is sometimes `null`, sometimes a string, sometimes a boolean. Use a dedicated `status` field instead.
|
|
79
|
-
- **Never use `state.modalOpen` or conditional modal rendering
|
|
75
|
+
- **Never use `state.modalOpen` or conditional modal rendering** (auto-checked). This destroys the `<dialog>` on every render, breaking focus, animation, and native ESC handling. Instead, always render the `<dialog>` in the DOM and use `data-dialog-open="id"` to open it — the runtime handles this without any spec state:
|
|
80
76
|
```html
|
|
81
77
|
<!-- always in the view, never conditional -->
|
|
82
78
|
${modal({ id: 'confirm', title: 'Confirm', content: '...' })}
|
|
@@ -105,14 +101,14 @@ Before finishing any spec, verify every point below. Fix anything that fails.
|
|
|
105
101
|
|
|
106
102
|
### CSS
|
|
107
103
|
|
|
108
|
-
- **`app.css` must contain no hex values or raw colour values
|
|
104
|
+
- **`app.css` must contain no hex values or raw colour values** (auto-checked, lint hook also blocks the build). Hex values belong in `public/theme.css` as token definitions; `app.css` references them via `var()` only.
|
|
109
105
|
- **Load order in `meta.styles`:** `pulse-ui.css` → `theme.css` → `app.css`. Theme tokens must be defined before `app.css` references them.
|
|
110
106
|
- **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.
|
|
111
107
|
|
|
112
108
|
### Security
|
|
113
109
|
|
|
114
110
|
- Any value from user input (URL params, form fields, external APIs) interpolated into view HTML must be escaped.
|
|
115
|
-
- **Every `<form method="POST">` on a `submit` page includes `${server.csrf}
|
|
111
|
+
- **Every `<form method="POST">` on a `submit` page includes `${server.csrf}`** (auto-checked). CSRF is enforced — a form without the hidden token field gets 403 on submit. Never set `csrf: false` to "fix" a 403; that disables the protection. `csrf: false` is only for endpoints with their own authentication (e.g. signed webhooks).
|
|
116
112
|
- **`submit` returns `{ redirect }` after a successful mutation** (POST-redirect-GET). Rendering success directly from the POST means browser refresh resubmits the form.
|
|
117
113
|
|
|
118
114
|
### Server-side forms & error pages
|
|
@@ -162,7 +158,7 @@ Before finishing any spec, verify every point below. Fix anything that fails.
|
|
|
162
158
|
|
|
163
159
|
### Store updates from actions
|
|
164
160
|
|
|
165
|
-
- **Use `_storeUpdate` to push changes to the global store from an action.** Return it from `onSuccess` alongside the local state update — it is stripped from page state and forwarded to the store. All mounted pages that subscribe to the affected keys re-render immediately:
|
|
161
|
+
- **Use `_storeUpdate` to push changes to the global store from an action.** Return it from `onSuccess` alongside the local state update — it is stripped from page state and forwarded to the store. It must be an object, not a primitive (auto-checked). All mounted pages that subscribe to the affected keys re-render immediately:
|
|
166
162
|
```js
|
|
167
163
|
onSuccess: (state, theme) => ({
|
|
168
164
|
saved: true,
|
|
@@ -9,3 +9,5 @@ pulse start --port 3001
|
|
|
9
9
|
If `pulse.config.js` sets a custom `port`, use that value + 1 instead.
|
|
10
10
|
|
|
11
11
|
> **Why the flag:** plain `pulse start` binds the configured port (3000) because in real deployment it is the only server and platforms inject `PORT`. Locally that collides with the dev server and breaks the convention used by `pulse_build`, Lighthouse, and the performance trace — production is always dev port + 1. Never use `--port` in actual deployment.
|
|
12
|
+
|
|
13
|
+
**If the server doesn't come up on the port you expected:** check whether a `PORT` environment variable is set in this shell (`echo $PORT`) — it silently overrides `--port` (correct behavior for real PaaS deployment, where the platform's injected `PORT` must always win, but a stray local `PORT` from a sandbox/CI/shell profile will do the same thing here with no indication why). `pulse start` now warns to stderr when this happens; if you see that warning, either unset `PORT` for this session or work with whatever port it actually bound to.
|
|
@@ -40,18 +40,33 @@ Use `mcp__chrome-devtools__navigate_page` to load the page route, then `mcp__chr
|
|
|
40
40
|
|
|
41
41
|
**Pre-flight (required before every Lighthouse run):**
|
|
42
42
|
1. Call `pulse_build` to produce a production build and start the production server on port 3001.
|
|
43
|
-
2. Call `
|
|
43
|
+
2. Call `pulse_check_bundles` — reads the actual generated bundle files, not just their sizes. Lighthouse scores can pass on a bundle that shouldn't exist at all (a page with no mutations/actions/persist that got a boot file anyway) or one containing leaked server-only code — neither shows up as a score failure. If it flags anything, fix it and restart from step 3.
|
|
44
|
+
3. Call `navigate_page` with `url: "http://localhost:3001/"` so the browser is on the production server.
|
|
44
45
|
|
|
45
46
|
Then run `mcp__chrome-devtools__lighthouse_audit` with `{ "device": "desktop" }`.
|
|
46
47
|
|
|
47
48
|
**Pass bar: Accessibility, Best Practices, and SEO must all be 100.** Performance is measured and reported but is not a hard requirement (it varies with machine load). Report the actual scores. If Accessibility, Best Practices, or SEO is below 100, identify the failing audit(s), fix the issue, and restart from step 3.
|
|
48
49
|
|
|
50
|
+
Keep the score numbers from this run — step 7a saves both desktop and mobile together.
|
|
51
|
+
|
|
49
52
|
### 7. Lighthouse — mobile *(skip in quick mode)*
|
|
50
53
|
|
|
51
54
|
The browser should still be on `http://localhost:3001/` from step 6. Run `mcp__chrome-devtools__lighthouse_audit` with `{ "device": "mobile" }`.
|
|
52
55
|
|
|
53
56
|
**Same pass bar: Accessibility, Best Practices, and SEO must all be 100.** Fix any failures and restart from step 3.
|
|
54
57
|
|
|
58
|
+
### 7a. Save the Lighthouse reports *(skip in quick mode — do not skip this if you ran steps 6–7)*
|
|
59
|
+
|
|
60
|
+
**This is a required step, not optional narrative — a Lighthouse run that passes step 6/7 and skips this one leaves no record anywhere except this conversation.** `pulse report-server` and any dashboard watching `.pulse/reports/` (including a companion app) read only what actually got saved to disk; reporting scores in chat is not the same as saving them, no matter how many times `/verify` passes.
|
|
61
|
+
|
|
62
|
+
Run one `Bash` call per device — **not an MCP tool call**, and run from the project root (the directory containing `pulse.config.js`):
|
|
63
|
+
|
|
64
|
+
```
|
|
65
|
+
pulse save-report --url "http://localhost:3001/<route>" --data '{"scores":{"performance":<N>,"accessibility":100,"bestPractices":100,"seo":100},"metrics":{"lcp":<N>,"cls":<N>,"fcp":<N>,"tbt":<N>}}'
|
|
66
|
+
```
|
|
67
|
+
|
|
68
|
+
Use the real numbers from step 6's result for one call and step 7's result for a second call. `<route>` is the page's route (`/` for the homepage). Confirm each call printed `✓ Report saved for ... (slug: ...)` — if it errored, fix the command and retry before moving on; do not silently continue to step 8 with an unsaved report.
|
|
69
|
+
|
|
55
70
|
**Stay on the production server (port 3001)** — steps 8 and 9 use it too. Do not call `pulse_restart_server` yet.
|
|
56
71
|
|
|
57
72
|
### 8. Mobile layout check *(skip in quick mode)*
|
|
@@ -88,9 +103,7 @@ Use `mcp__chrome-devtools__list_console_messages` — report any errors or unexp
|
|
|
88
103
|
|
|
89
104
|
### 11. Code review
|
|
90
105
|
|
|
91
|
-
Call `pulse_review` with the spec file path.
|
|
92
|
-
|
|
93
|
-
Work through every item the review returns. Fix anything that fails before proceeding.
|
|
106
|
+
Call `pulse_review` with the spec file path. Work through every item the review returns. Fix anything that fails before proceeding.
|
|
94
107
|
|
|
95
108
|
### 12. Close extra browser tabs
|
|
96
109
|
|
|
@@ -98,20 +111,29 @@ Use `mcp__chrome-devtools__list_pages` to get all open pages. Close every page *
|
|
|
98
111
|
|
|
99
112
|
If there is only one page open, skip this step — there is nothing to close.
|
|
100
113
|
|
|
101
|
-
### 13.
|
|
114
|
+
### 13. Check for unresolved errors on this route
|
|
115
|
+
|
|
116
|
+
Call `pulse_diagnose` with `{ route: "<this page's route>" }`. If it returns any unresolved entries, **do not proceed to step 14** — something threw during this session that hasn't been addressed. Fix the underlying issue and restart from step 3.
|
|
117
|
+
|
|
118
|
+
If it returns none, continue — `pulse_stamp` in the next step auto-resolves any journal entries for this route, since a clean pass this far means whatever was flagged either wasn't real or has already been fixed by an earlier step in this loop.
|
|
119
|
+
|
|
120
|
+
### 14. Write verification stamp
|
|
121
|
+
|
|
122
|
+
Call `pulse_stamp` with `{ route: "<this page's route>", mode: "full" }` in full mode, or just `{ route: "<this page's route>" }` in quick mode (quick never runs Lighthouse, so there's nothing to check). This writes `.pulse-verified` via the MCP server, which is more reliable than the `date +%s` shell command — the MCP write always lands after all spec edits, avoiding the mtime race condition that can cause the stop hook to block immediately after verification. It also marks any error-journal entries for this route as resolved.
|
|
102
123
|
|
|
103
|
-
|
|
124
|
+
**In full mode, `pulse_stamp` refuses to write the stamp unless it finds a Lighthouse report actually saved for this route in the last 10 minutes** (i.e. step 7a genuinely happened, not just step 6/7's scores existing in this conversation). If it refuses, go back and run the missing `pulse save-report` call(s) from step 7a, then call `pulse_stamp` again — do not work around the refusal any other way.
|
|
104
125
|
|
|
105
126
|
**This must be the last operation before stopping.** The stop hook compares each edited spec's mtime against this stamp — if any spec is newer than the stamp, the hook blocks. Do not edit any spec file after calling `pulse_stamp`.
|
|
106
127
|
|
|
107
|
-
###
|
|
128
|
+
### 15. Report
|
|
108
129
|
|
|
109
130
|
Summarise:
|
|
110
131
|
- Validation: pass or fail (with errors if any)
|
|
111
132
|
- Visual: desktop screenshot — any issues found and fixed
|
|
112
133
|
- Console: any errors
|
|
113
134
|
- Review: pass or issues found and fixed
|
|
114
|
-
-
|
|
135
|
+
- Error journal: clean, or resolved N entries
|
|
136
|
+
- **Full mode only:** bundle content check (clean, or issues found and fixed), Lighthouse desktop/mobile scores, whether both were saved via `pulse save-report` (step 7a), mobile layout check, LCP and CLS values
|
|
115
137
|
|
|
116
138
|
In quick mode, end with: "Quick check passed — run `/verify` when you're ready for the full Lighthouse audit."
|
|
117
139
|
|
|
@@ -157,7 +157,7 @@ To add a missing icon: copy a Lucide path (MIT) into `src/ui/icons.js` using `s(
|
|
|
157
157
|
| `stat` | `label`, `value`, `change`, `trend` (up/down/neutral), `size` (sm/md/lg — lg for hero KPIs), `center` |
|
|
158
158
|
| `avatar` | `src`, `alt`, `size` (sm/md/lg/xl), `initials` |
|
|
159
159
|
| `empty` | `title`, `description`, `action` ({label,href,variant}) |
|
|
160
|
-
| `table` | `headers`, `rows` (2D array of HTML strings), `caption` | ⚠ Each cell item must be **raw HTML content** — `table()` wraps each item in `<td>` itself. Do NOT pass `<td>...</td>` strings or you will get double-nested cells. |
|
|
160
|
+
| `table` | `headers`, `rows` (2D array of HTML strings), `caption` | ⚠ Each cell item must be **raw HTML content** — `table()` wraps each item in `<td>` itself. Do NOT pass `<td>...</td>` strings or you will get double-nested cells. Raw HTML you put in a cell (a `<a>` link, a `<button>`, anything) gets **no styling from the component** — `table()` only styles the table structure, not injected content. A bare `<a href="...">text</a>` in a cell falls back to the browser's default link blue, which fails contrast on a dark theme. Style it explicitly (`color: var(--ui-accent)`, `:hover { color: var(--ui-accent-hover) }`) the same as any raw HTML you'd write directly in a view. |
|
|
161
161
|
| `spinner` | `size` (sm/md/lg), `color` (accent/muted/white), `label` |
|
|
162
162
|
| `progress` | `value`, `max` (100), `label`, `showLabel`, `showValue`, `variant`, `size` |
|
|
163
163
|
| `breadcrumbs` | `items` ([{label,href}] — last item has no href), `separator` |
|
|
@@ -229,32 +229,14 @@ ${heading({ level: 1, text: 'In Search of Lost Code', class: 'display-heading' }
|
|
|
229
229
|
|
|
230
230
|
#### When components feel restrictive
|
|
231
231
|
|
|
232
|
-
|
|
232
|
+
Symptom: fighting a component's wrapper styles (padding, max-width, centering) in CSS overrides. Fix, in order of preference:
|
|
233
|
+
1. Editorial/typographic page → compose from `heading()`, `prose()`, raw HTML (creative override) instead of forcing `hero()`/`feature()`/`cta()` into that shape.
|
|
234
|
+
2. Structure diverges significantly (asymmetric grid, full-bleed, custom positioning) → write the outer HTML yourself; still use components for primitives (`button`, `card`, `input`, `badge`).
|
|
235
|
+
3. Only cosmetic → override with higher specificity in `app.css`: `.my-editorial-hero .ui-hero-inner { max-width: none; padding: 0; }` (component wrappers use single class names like `.ui-hero`).
|
|
233
236
|
|
|
234
|
-
|
|
235
|
-
1. **Use the right approach for your design.** If the page is editorial/typographic, compose from `heading()`, `prose()`, and raw HTML (creative override) instead of trying to force `hero()`, `feature()`, `cta()` into an editorial shape.
|
|
236
|
-
2. **Write custom markup for structural differences.** Components provide sensible defaults for common patterns. When your design structure diverges significantly (asymmetric grid, full-bleed sections, custom positioning), write the HTML directly — that's permitted for layouts. Use components for the UI primitives (`button`, `card`, `input`, `badge`), but write the outer structure yourself.
|
|
237
|
-
3. **Override with higher specificity.** Component wrappers use single class names (`.ui-hero`, `.ui-cta`). Override with a more specific selector in your `app.css`:
|
|
238
|
-
```css
|
|
239
|
-
.my-editorial-hero .ui-hero-inner {
|
|
240
|
-
max-width: none;
|
|
241
|
-
padding: 0;
|
|
242
|
-
}
|
|
243
|
-
```
|
|
237
|
+
#### Vibes are a paint job, not a structural transformation
|
|
244
238
|
|
|
245
|
-
|
|
246
|
-
|
|
247
|
-
#### Important: Vibes affect CSS tokens, not component HTML structure
|
|
248
|
-
|
|
249
|
-
The `meta.vibe` setting (`warm`, `editorial`, `playful`, `minimal`, `bold`, `brutalist`, `retro`, `corporate`, `neon`, `paper`) applies CSS variable overrides via `data-vibe` on the `<body>` element. This changes **visual styling** — border radius, font size, letter-spacing, heading weight — but does **not** change the **HTML structure** of components.
|
|
250
|
-
|
|
251
|
-
For example:
|
|
252
|
-
- `hero()` with `vibe: 'editorial'` still renders eyebrow + title + subtitle + actions (SaaS structure)
|
|
253
|
-
- The vibe adjusts the font sizes, tracking, and spacing, but the component HTML is unchanged
|
|
254
|
-
|
|
255
|
-
**If you need different HTML structure for an editorial design** — e.g., a large display title + asymmetric layout + drop cap instead of eyebrow/subtitle/actions — write custom markup styled with `heading()`/`prose()` plus utility classes or `app.css`. Do not expect `hero()` to become structurally editorial when `vibe: 'editorial'` is set.
|
|
256
|
-
|
|
257
|
-
This is by design: vibes are a paint job, not a structural transformation. Components stay predictable and testable. When the design structure itself differs from the SaaS patterns (centred hero, three-column features, pricing cards), reach for custom layout instead of trying to force `hero()` / `feature()` / `cta()` into shapes they weren't designed for.
|
|
239
|
+
`meta.vibe` (`warm`/`editorial`/`playful`/`minimal`/`bold`/`brutalist`/`retro`/`corporate`/`neon`/`paper`) sets `data-vibe` on `<body>` — changes radius/font-size/letter-spacing/weight tokens only. **Never changes component HTML structure.** `hero({ vibe: 'editorial' })` still renders eyebrow+title+subtitle+actions — vibe ≠ structural editorial layout. For a genuinely different structure (large display title + asymmetric layout + drop cap instead of eyebrow/subtitle/actions), write custom markup with `heading()`/`prose()` + utility classes — don't expect the vibe setting to produce it.
|
|
258
240
|
|
|
259
241
|
### Landing page components
|
|
260
242
|
|
|
@@ -89,18 +89,6 @@ Use the global store (`pulse.store.js`) for state that needs to be consistent ac
|
|
|
89
89
|
|
|
90
90
|
See `pulse://guide/server` for the full store API.
|
|
91
91
|
|
|
92
|
-
## Site navigation
|
|
93
|
-
|
|
94
|
-
Projects define navigation in src/components/layout.js via a NAV_LINKS array.
|
|
95
|
-
To add a new page to the nav: edit NAV_LINKS in src/components/layout.js only — do NOT add links in individual page files.
|
|
96
|
-
|
|
97
|
-
```js
|
|
98
|
-
const NAV_LINKS = [
|
|
99
|
-
{ label: 'Home', href: '/' },
|
|
100
|
-
{ label: 'About', href: '/about' }, // ← add new pages here
|
|
101
|
-
]
|
|
102
|
-
```
|
|
103
|
-
|
|
104
92
|
## Page discovery — no registration needed
|
|
105
93
|
|
|
106
94
|
Pulse automatically discovers every .js file under src/pages/ and registers it as a route. You NEVER need to edit a server.js or register specs manually. Just create the file and it is live.
|
|
@@ -80,6 +80,8 @@ All pages that declare `spec.store` with the affected keys re-render automatical
|
|
|
80
80
|
- Page-level `server` keys win over store keys if there is a name collision
|
|
81
81
|
- Pages with `spec.store` are never HTML-cached (same rule as pages with `spec.server`)
|
|
82
82
|
|
|
83
|
+
**Testing `pulse.store.js` — put the test file next to it, at the project root, not under `src/`.** `npm test`'s glob covers `src/**/*.test.js` plus a root-level `*.test.js` specifically so `pulse.store.test.js` is picked up — a store test placed anywhere under `src/pages/` instead (following the page-test convention out of habit) still runs, but `pulse.store.test.js` itself only runs from the root. Import the store the normal relative way: `import store from '../pulse.store.js'` from `src/pages/`, or `import store from './pulse.store.js'` from the root.
|
|
84
|
+
|
|
83
85
|
**Reactive updates — no refresh needed**
|
|
84
86
|
|
|
85
87
|
Return `_storeUpdate` from a page action's `onSuccess` to push a change into the global store. All mounted pages that subscribe to the affected keys re-render immediately:
|
|
@@ -177,6 +179,87 @@ export default {
|
|
|
177
179
|
- Escape user-submitted values before interpolating them into HTML (`escHtml`).
|
|
178
180
|
- **Progressive enhancement:** add `data-action="send"` to the same form — hydrated visitors get the async action with no page reload; no-JS visitors fall back to the POST.
|
|
179
181
|
- Multi-instance deployments: pass a stable `secret` to `createServer` (e.g. from an env var) so CSRF tokens issued by one instance validate on another.
|
|
182
|
+
- **On a longer page, a form mid-page needs to scroll the success/error state into view after the redirect.** The 303 lands the browser at the top of the page — a success message rendered next to the form (or anywhere below the fold) is invisible until the visitor scrolls down manually, which they have no reason to do after just submitting. Give the form section an `id`, add `${server.form?.sent ? '<script nonce=\"${server.nonce}\">document.getElementById(\"contact-form\").scrollIntoView()</script>' : ''}` right after it, or simplest: `<form id="contact-form" method="POST">`. Don't skip this on a short/single-purpose page (the form IS the page, nothing to scroll past) — it only matters once the form sits below other content.
|
|
183
|
+
|
|
184
|
+
## Server-side data persistence — outside the store and `spec.persist`
|
|
185
|
+
|
|
186
|
+
Neither of Pulse's two built-in state mechanisms is durable, shared server-side storage:
|
|
187
|
+
- `spec.persist` is **client-side** `localStorage`, scoped per route — gone if the visitor clears their browser, invisible to other visitors.
|
|
188
|
+
- The global store (`pulse.store.js`) is **in-memory**, shared across connected clients via SSE, but reset on every server restart and never written to disk.
|
|
189
|
+
|
|
190
|
+
For anything that needs to survive a restart and be shared across all requests — a URL shortener's code→URL map, a simple content store, any small dataset a form writes to — there's no built-in database, and the framework has no opinion on which one to use. **A JSON file on disk is a reasonable zero-dependency default for a small, low-concurrency dataset** (a few thousand records, not a high-write-throughput service); for anything bigger, bring your own database. Either way, write the persistence layer as its own module under `src/lib/`, imported from `guard`/`server`/`submit`/`actions.*.run` — never from the view.
|
|
191
|
+
|
|
192
|
+
```js
|
|
193
|
+
// src/lib/data-store.js — a minimal safe pattern for a small JSON-backed store
|
|
194
|
+
import fs from 'node:fs/promises'
|
|
195
|
+
import path from 'node:path'
|
|
196
|
+
|
|
197
|
+
const FILE = path.join(process.cwd(), 'data', 'store.json')
|
|
198
|
+
let cache = null // in-memory, refreshed on write — avoids a disk read per request
|
|
199
|
+
let writeQueue = Promise.resolve() // serializes writes so concurrent requests can't interleave
|
|
200
|
+
|
|
201
|
+
async function load() {
|
|
202
|
+
if (cache) return cache
|
|
203
|
+
try {
|
|
204
|
+
cache = JSON.parse(await fs.readFile(FILE, 'utf8'))
|
|
205
|
+
} catch {
|
|
206
|
+
cache = {} // file doesn't exist yet — start empty
|
|
207
|
+
}
|
|
208
|
+
return cache
|
|
209
|
+
}
|
|
210
|
+
|
|
211
|
+
async function persist(next) {
|
|
212
|
+
// Atomic write: write to a temp file, then rename — rename is atomic on POSIX,
|
|
213
|
+
// so a crash mid-write can never leave a corrupt/partial file on disk.
|
|
214
|
+
const tmp = FILE + '.tmp'
|
|
215
|
+
await fs.mkdir(path.dirname(FILE), { recursive: true })
|
|
216
|
+
await fs.writeFile(tmp, JSON.stringify(next, null, 2))
|
|
217
|
+
await fs.rename(tmp, FILE)
|
|
218
|
+
cache = next
|
|
219
|
+
}
|
|
220
|
+
|
|
221
|
+
// Serialize every write through one promise chain — without this, two
|
|
222
|
+
// concurrent requests can both read the same state, modify it separately,
|
|
223
|
+
// and the second write silently clobbers the first (a classic lost update).
|
|
224
|
+
export function mutate(fn) {
|
|
225
|
+
writeQueue = writeQueue.then(load).then(async (data) => {
|
|
226
|
+
const next = await fn(data)
|
|
227
|
+
await persist(next)
|
|
228
|
+
return next
|
|
229
|
+
})
|
|
230
|
+
return writeQueue
|
|
231
|
+
}
|
|
232
|
+
|
|
233
|
+
export const read = load
|
|
234
|
+
```
|
|
235
|
+
|
|
236
|
+
**Rules:**
|
|
237
|
+
- Load-on-first-read, cache in memory, refresh the cache on every write — the hot path (a read-heavy redirect lookup, say) shouldn't hit disk on every request.
|
|
238
|
+
- Serialize writes through a single promise queue, as above. This is single-process only — it does not protect against two separate server *instances* writing the same file; for multi-instance deployment, use a real database instead.
|
|
239
|
+
- Write via temp-file-then-rename, never `fs.writeFile` directly to the real path — a process killed mid-write leaves the rename-source temp file intact and the real file untouched, not a half-written JSON file that fails to parse on next boot.
|
|
240
|
+
- Add the data file to `.gitignore` — it's runtime state, not something to commit.
|
|
241
|
+
- **This local helper module must not be imported into a page's `view` or client-side `mutations`/`actions`** — only into `guard`, `server` fetchers, `submit`, or an action's `run`. A local module using `node:fs` (or any Node built-in) that ends up reachable from client code will break the production build — see the next point.
|
|
242
|
+
- **A page whose only server-side logic is `guard`/`submit`/`server` (no `mutations`, `actions`, or `persist`) gets zero client JS** — this is the normal, correct outcome for a redirect handler, a form-only page, or anything else that's purely server-rendered. If a build ever tries to bundle a local server-only module (e.g. you see esbuild choke on `node:fs` or similar), that's the build's hydration-need check misfiring, not something to work around in your spec — it should already match this rule.
|
|
243
|
+
|
|
244
|
+
## Third-party inline scripts — `server.nonce`
|
|
245
|
+
|
|
246
|
+
The framework auto-nonces its own inline `<script>` tags for CSP (`script-src` is nonce-restricted, not `'unsafe-inline'`). A hand-written `<script>` in a view — e.g. an ad network's or analytics tool's inline snippet that the framework doesn't generate itself — needs that same nonce or the browser blocks it as a CSP violation.
|
|
247
|
+
|
|
248
|
+
`server.nonce` is the per-request CSP nonce, available in every view with no extra setup:
|
|
249
|
+
|
|
250
|
+
```js
|
|
251
|
+
view: (state, server) => `
|
|
252
|
+
<main id="main-content">
|
|
253
|
+
<script nonce="${server.nonce}">
|
|
254
|
+
(adsbygoogle = window.adsbygoogle || []).push({});
|
|
255
|
+
</script>
|
|
256
|
+
</main>`,
|
|
257
|
+
```
|
|
258
|
+
|
|
259
|
+
**Rules:**
|
|
260
|
+
- Only covers **inline** `<script>` content. An external `<script src="...">` doesn't need a nonce at all — CSP's `script-src` origin allowlist (`csp: { 'script-src': ['https://example.com'] }` in `createServer`) gates those instead; add the third party's origin there.
|
|
261
|
+
- Never hardcode a nonce value — it's per-request and regenerated on every response. `server.nonce` always reflects the current request's real value.
|
|
262
|
+
- If the third party's own SDK tries to register a [Trusted Types](https://developer.mozilla.org/en-US/docs/Web/API/Trusted_Types_API) policy (some ad networks do), the framework's CSP restricts `trusted-types` to its own `pulse` policy — that's a separate, unrelated CSP dimension from nonces and isn't solved by `server.nonce`. It **is** extendable via `csp: { 'trusted-types': ['policy-name'] }` (merges with `pulse`, doesn't replace it) — see "Third-party scripts" in `pulse://guide/styles` for the full pattern and the explicit trade-off it represents.
|
|
180
263
|
|
|
181
264
|
## Server context — redirects, cookies, POST bodies
|
|
182
265
|
|
|
@@ -208,12 +291,14 @@ Body parsing is available in `guard`, `server.*` fetchers, and `render` (raw spe
|
|
|
208
291
|
```js
|
|
209
292
|
await ctx.json() // parse JSON body → object | null
|
|
210
293
|
await ctx.text() // raw string body → string
|
|
211
|
-
await ctx.formData() // URL-encoded body → plain object | null
|
|
294
|
+
await ctx.formData() // URL-encoded body → plain object | null — NOT a FormData, see below
|
|
212
295
|
await ctx.buffer() // raw Buffer
|
|
213
296
|
```
|
|
214
297
|
|
|
215
298
|
Bodies larger than `maxBody` (default 1 MB, configurable in `createServer`) are rejected with a 413 response before the handler runs.
|
|
216
299
|
|
|
300
|
+
**`ctx.formData()` returns a plain object, not a browser `FormData`.** Read fields directly — `data?.email`, not `data.get('email')` — the latter throws `TypeError: data.get is not a function`. This trips people up because client-side action code (`actions.*.run(state, serverState, formData)`) receives a real `FormData` object with a `.get()` method — same method name, different context, different shape. If you're writing `spec.submit` and reach for `.get()`, that's the tell you've copied the client-side pattern by mistake.
|
|
301
|
+
|
|
217
302
|
**Page specs only accept GET/HEAD by default** (POST → 405). To handle POST on a page spec, opt in with `spec.methods`:
|
|
218
303
|
|
|
219
304
|
```js
|
|
@@ -310,6 +395,22 @@ Key properties:
|
|
|
310
395
|
- `Cache-Control: no-store` — proxies never serve a stale health status
|
|
311
396
|
- Fires before route matching — a user spec at `/healthz` is shadowed when the built-in is enabled
|
|
312
397
|
|
|
398
|
+
## Error journal — mechanized, agent-facing error tracking
|
|
399
|
+
|
|
400
|
+
In dev mode (`dev: true`), every error the three error layers above catch — plus post-hydration client view/action failures — is also recorded to `.pulse/errors.json`. This is a machine-checkable diagnostic, not a console line you have to notice: read it with `pulse_diagnose`, clear entries with `pulse_resolve_error`.
|
|
401
|
+
|
|
402
|
+
```js
|
|
403
|
+
// .pulse/errors.json — written automatically, never edit directly
|
|
404
|
+
[{ id, ts, route, phase, message, stack, resolved }]
|
|
405
|
+
// phase: 'view' | 'action' | 'server' | 'guard'
|
|
406
|
+
```
|
|
407
|
+
|
|
408
|
+
**Call `pulse_diagnose` when something seems broken and the cause isn't obvious**, or proactively after building/testing a page to confirm nothing threw during the session. Pass `{ route }` to filter to one page. `/verify` already calls this automatically before writing the stamp — an unresolved error for the target route blocks the stamp; a clean pass auto-resolves the route's entries via `pulse_stamp`. You don't need to call `pulse_resolve_error` manually in the normal `/verify` flow — only if you fixed something outside that loop and want it cleared immediately.
|
|
409
|
+
|
|
410
|
+
- Ring-buffered at 50 entries — a diagnostic tool, not a production error tracker
|
|
411
|
+
- `dev`-only — nothing is written in production, and the client-reporting endpoint (`/__pulse/error`) doesn't exist outside dev mode
|
|
412
|
+
- Never write to this file directly — it's framework-managed
|
|
413
|
+
|
|
313
414
|
## Graceful shutdown
|
|
314
415
|
|
|
315
416
|
`createServer` registers `SIGTERM` and `SIGINT` handlers automatically. When either signal arrives:
|
package/src/agent/guide-spec.md
CHANGED
|
@@ -49,8 +49,15 @@ export default {
|
|
|
49
49
|
// Server-side form handling — POST works with JavaScript disabled.
|
|
50
50
|
// { redirect } → 303 PRG; anything else → re-render with the value as server.form.
|
|
51
51
|
// CSRF enforced automatically: the view MUST include ${server.csrf} inside the <form>.
|
|
52
|
+
//
|
|
53
|
+
// ⚠ ctx.formData() here is NOT the same type as the client-side FormData used
|
|
54
|
+
// in actions.run above. Server-side it's already parsed into a plain object —
|
|
55
|
+
// read fields directly (data?.email), not with .get('email'). Calling .get()
|
|
56
|
+
// on it throws "data.get is not a function". This is the single most common
|
|
57
|
+
// mistake writing spec.submit — the two formData()s look identical but return
|
|
58
|
+
// different shapes because one runs in the browser and one runs server-side.
|
|
52
59
|
submit: async (ctx) => {
|
|
53
|
-
const data = await ctx.formData()
|
|
60
|
+
const data = await ctx.formData() // plain object: { email: '...', ... } | null
|
|
54
61
|
if (!data?.email) return { errors: { email: 'Required' }, values: data ?? {} }
|
|
55
62
|
await save(data)
|
|
56
63
|
return { redirect: '/thanks' }
|
|
@@ -238,6 +238,29 @@ createServer(specs, {
|
|
|
238
238
|
|
|
239
239
|
Without this, external images will be blocked in production and Lighthouse will flag a Best Practices failure. Use the same pattern for other external resource types (`media-src` for video, `connect-src` for fetch/XHR to external APIs).
|
|
240
240
|
|
|
241
|
+
### Third-party scripts (ad networks, analytics SDKs)
|
|
242
|
+
|
|
243
|
+
**Every `csp` key merges the same way — including `trusted-types` and `require-trusted-types-for`.** This is a real, tested behavior, not a guess: `csp` passed to `createServer` (or `pulse.config.js`) is spread onto the framework's base CSP directive-by-directive, so `csp: { 'trusted-types': ['google'] }` produces `trusted-types pulse google` in the response header — it does not replace or drop the framework's own `pulse` policy.
|
|
244
|
+
|
|
245
|
+
A third-party script (Google AdSense, GTM, and similar SDKs) typically needs up to three separate CSP extensions — do not assume only one is needed:
|
|
246
|
+
|
|
247
|
+
```js
|
|
248
|
+
// pulse.config.js
|
|
249
|
+
export default {
|
|
250
|
+
csp: {
|
|
251
|
+
// The loader <script src="..."> itself — origin allowlist, no nonce needed
|
|
252
|
+
'script-src': ['https://pagead2.googlesyndication.com'],
|
|
253
|
+
// If the SDK creates its own Trusted Types policy (some ad SDKs do) —
|
|
254
|
+
// scoped to exactly the policy name it registers, not a broad relaxation
|
|
255
|
+
'trusted-types': ['google'],
|
|
256
|
+
},
|
|
257
|
+
}
|
|
258
|
+
```
|
|
259
|
+
|
|
260
|
+
For the **inline** per-slot script the SDK's own docs tell you to paste in (e.g. `(adsbygoogle = window.adsbygoogle || []).push({})`), use `server.nonce` — see "Third-party inline scripts" in `pulse://guide/server`. `csp.script-src` origin allowlisting only covers `<script src="...">`, not inline `<script>` content.
|
|
261
|
+
|
|
262
|
+
**Without the `trusted-types` extension, this is a hard failure, not a cosmetic one:** a script that calls `trustedTypes.createPolicy(...)` under a name other than `pulse` throws in the browser console, the SDK fails to initialize, AND `lighthouse_audit` fails Best Practices on the resulting console error/CSP violation — confirmed directly (an agent hit exactly this running a real audit against AdSense). This is not a "check the console if something seems off" edge case; if you're integrating a third-party SDK and Lighthouse Best Practices won't reach 100, check the browser console for a Trusted Types policy rejection before assuming the problem is elsewhere.
|
|
263
|
+
|
|
241
264
|
For multiple weights or italic variants, separate them with a semicolon in the URL:
|
|
242
265
|
```
|
|
243
266
|
?family=Inter:ital,wght@0,400;0,700;1,400&display=swap
|