@invisibleloop/pulse 0.1.21
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/.claude/commands/build-page.md +59 -0
- package/.claude/commands/new-doc-page.md +45 -0
- package/.claude/commands/verify.md +52 -0
- package/.claude/pulse-checklist.md +111 -0
- package/.claude/settings.local.json +102 -0
- package/.github/workflows/ci.yml +22 -0
- package/.github/workflows/publish.yml +41 -0
- package/.pulse/load-reports/home/1773432711417.json +22 -0
- package/CLAUDE.md +383 -0
- package/README.md +95 -0
- package/docs/.claude/pulse-checklist.md +111 -0
- package/docs/public/.pulse-ui-version +1 -0
- package/docs/public/dist/accessibility.boot-5DVTARJU.js +115 -0
- package/docs/public/dist/actions.boot-P66HKQEM.js +164 -0
- package/docs/public/dist/auth.boot-IMAJAUPH.js +140 -0
- package/docs/public/dist/caching.boot-DVR6KDE7.js +53 -0
- package/docs/public/dist/components--accordion.boot-3HVKMNWC.js +11 -0
- package/docs/public/dist/components--alert.boot-GCEXOZAC.js +6 -0
- package/docs/public/dist/components--app-badge.boot-DVT3GCHJ.js +6 -0
- package/docs/public/dist/components--avatar.boot-PSW24EVA.js +5 -0
- package/docs/public/dist/components--badge.boot-TYDY2RMK.js +7 -0
- package/docs/public/dist/components--banner.boot-EI5PZSZK.js +7 -0
- package/docs/public/dist/components--breadcrumbs.boot-SMA2E2GO.js +34 -0
- package/docs/public/dist/components--button.boot-J54BQM2E.js +23 -0
- package/docs/public/dist/components--card.boot-PZGNDIB6.js +138 -0
- package/docs/public/dist/components--carousel.boot-TP6LPFZZ.js +12 -0
- package/docs/public/dist/components--charts.boot-2EOYQWKL.js +108 -0
- package/docs/public/dist/components--checkbox.boot-DS5BSL6T.js +54 -0
- package/docs/public/dist/components--cluster.boot-HHVIBBJG.js +9 -0
- package/docs/public/dist/components--code-window.boot-2GR2DV33.js +20 -0
- package/docs/public/dist/components--container.boot-7LOOGK2K.js +5 -0
- package/docs/public/dist/components--cta.boot-FSNZ5YRT.js +11 -0
- package/docs/public/dist/components--divider.boot-3NI2C3QG.js +6 -0
- package/docs/public/dist/components--empty.boot-YX2UR3PV.js +7 -0
- package/docs/public/dist/components--feature.boot-MUD7NSUO.js +13 -0
- package/docs/public/dist/components--fieldset.boot-J7BYHMKF.js +19 -0
- package/docs/public/dist/components--fileupload.boot-NIKVTTPD.js +52 -0
- package/docs/public/dist/components--footer.boot-EYUK5FRG.js +14 -0
- package/docs/public/dist/components--grid.boot-URDQVDDR.js +59 -0
- package/docs/public/dist/components--heading.boot-BPQKU43E.js +44 -0
- package/docs/public/dist/components--hero.boot-4RAPRGAB.js +17 -0
- package/docs/public/dist/components--icons.boot-ZITNU5JP.js +68 -0
- package/docs/public/dist/components--image.boot-XEEGHQZF.js +19 -0
- package/docs/public/dist/components--input.boot-SGASZG5K.js +7 -0
- package/docs/public/dist/components--list.boot-W3XC5MHD.js +55 -0
- package/docs/public/dist/components--media.boot-5VFIETZO.js +13 -0
- package/docs/public/dist/components--modal.boot-RZUYXBN2.js +47 -0
- package/docs/public/dist/components--nav.boot-ODBOHU7O.js +33 -0
- package/docs/public/dist/components--pricing.boot-4AQ4ZVBY.js +21 -0
- package/docs/public/dist/components--progress.boot-GHAGYZOK.js +30 -0
- package/docs/public/dist/components--prose.boot-QANJL6JI.js +67 -0
- package/docs/public/dist/components--pullquote.boot-Q2WMNAZU.js +22 -0
- package/docs/public/dist/components--radio.boot-TJRDQ2OL.js +75 -0
- package/docs/public/dist/components--rating.boot-QBAN6DEL.js +38 -0
- package/docs/public/dist/components--search.boot-PXH5O5AG.js +17 -0
- package/docs/public/dist/components--section.boot-AQGIYHWW.js +12 -0
- package/docs/public/dist/components--segmented.boot-BEVTKEJO.js +33 -0
- package/docs/public/dist/components--select.boot-47X5RHOC.js +10 -0
- package/docs/public/dist/components--slider.boot-PSRRX7XL.js +47 -0
- package/docs/public/dist/components--spinner.boot-MZ5MO2OH.js +22 -0
- package/docs/public/dist/components--stack.boot-DI4NJXBF.js +9 -0
- package/docs/public/dist/components--stat.boot-QMFUWBQT.js +9 -0
- package/docs/public/dist/components--stepper.boot-34PP2NEV.js +22 -0
- package/docs/public/dist/components--table.boot-FCQGSFIQ.js +11 -0
- package/docs/public/dist/components--testimonial.boot-DWQPDKYG.js +11 -0
- package/docs/public/dist/components--textarea.boot-QVXLBOJ5.js +4 -0
- package/docs/public/dist/components--timeline.boot-26LN52P2.js +95 -0
- package/docs/public/dist/components--toggle.boot-IQQEI76S.js +29 -0
- package/docs/public/dist/components--tooltip.boot-LGHCO6NN.js +9 -0
- package/docs/public/dist/components.boot-SE6PQ4P7.js +103 -0
- package/docs/public/dist/config.boot-DTRRWUE6.js +126 -0
- package/docs/public/dist/constraints.boot-DUHDZBMC.js +71 -0
- package/docs/public/dist/deploy.boot-SLAD3NI2.js +163 -0
- package/docs/public/dist/docs-8e3d4b5c.css +1 -0
- package/docs/public/dist/extending.boot-UA3CN243.js +159 -0
- package/docs/public/dist/faq.boot-6EQAWLQR.js +43 -0
- package/docs/public/dist/getting-started.boot-TDKIFL5U.js +86 -0
- package/docs/public/dist/guard.boot-AUHAWTG4.js +80 -0
- package/docs/public/dist/home.boot-BVQXRH32.js +383 -0
- package/docs/public/dist/how-it-works.boot-LTWAKWKW.js +104 -0
- package/docs/public/dist/hydration.boot-JRM6IPJL.js +78 -0
- package/docs/public/dist/images.boot-M6ZVKTZS.js +80 -0
- package/docs/public/dist/manifest.json +94 -0
- package/docs/public/dist/meta.boot-7NXGPHR4.js +79 -0
- package/docs/public/dist/mutations.boot-F6F43UDX.js +79 -0
- package/docs/public/dist/navigation.boot-AOXWS3ZF.js +57 -0
- package/docs/public/dist/performance.boot-C3UPCOBK.js +98 -0
- package/docs/public/dist/persist.boot-WT32PQOQ.js +61 -0
- package/docs/public/dist/project-structure.boot-FB3LRVJ4.js +63 -0
- package/docs/public/dist/prompt-examples.boot-YKR4VDK4.js +31 -0
- package/docs/public/dist/pulse-ui-81a85c03.css +1 -0
- package/docs/public/dist/raw-responses.boot-M4KA5YXL.js +104 -0
- package/docs/public/dist/routing.boot-FNX5FDGH.js +70 -0
- package/docs/public/dist/runtime-B73WLANC.js +1 -0
- package/docs/public/dist/runtime-KO4BHUQ3.js +49 -0
- package/docs/public/dist/runtime-L2HNXIHW.js +59 -0
- package/docs/public/dist/runtime-QFURDKA2.js +5 -0
- package/docs/public/dist/runtime-UVPXO4IR.js +375 -0
- package/docs/public/dist/runtime-VMJA3Z4N.js +10 -0
- package/docs/public/dist/runtime-ZJ4FXT5O.js +11 -0
- package/docs/public/dist/server-api.boot-K7X3LCFB.js +219 -0
- package/docs/public/dist/server-data.boot-Y7HQYC4R.js +157 -0
- package/docs/public/dist/slash-commands.boot-V2UV7OW2.js +26 -0
- package/docs/public/dist/spec.boot-2WU7ZHCV.js +159 -0
- package/docs/public/dist/state.boot-B24GUE3R.js +73 -0
- package/docs/public/dist/store.boot-TLIB4XHH.js +150 -0
- package/docs/public/dist/streaming.boot-W2DZSMW4.js +80 -0
- package/docs/public/dist/stripe.boot-QN3C2GEL.js +164 -0
- package/docs/public/dist/supabase.boot-BG4XXLZE.js +303 -0
- package/docs/public/dist/testing.boot-6U4WKMTE.js +130 -0
- package/docs/public/dist/validation.boot-PQHYGW5B.js +100 -0
- package/docs/public/docs.css +2020 -0
- package/docs/public/menu.js +83 -0
- package/docs/public/pulse-ui.css +2739 -0
- package/docs/public/pulse-ui.js +236 -0
- package/docs/server.js +192 -0
- package/docs/src/lib/component-page.js +47 -0
- package/docs/src/lib/highlight.js +255 -0
- package/docs/src/lib/layout.js +131 -0
- package/docs/src/lib/metrics-store.js +6 -0
- package/docs/src/lib/nav.js +159 -0
- package/docs/src/lib/stats.js +81 -0
- package/docs/src/pages/accessibility.js +157 -0
- package/docs/src/pages/actions.js +191 -0
- package/docs/src/pages/auth.js +177 -0
- package/docs/src/pages/caching.js +95 -0
- package/docs/src/pages/components/accordion.js +48 -0
- package/docs/src/pages/components/alert.js +35 -0
- package/docs/src/pages/components/app-badge.js +41 -0
- package/docs/src/pages/components/avatar.js +35 -0
- package/docs/src/pages/components/badge.js +36 -0
- package/docs/src/pages/components/banner.js +45 -0
- package/docs/src/pages/components/breadcrumbs.js +94 -0
- package/docs/src/pages/components/button.js +84 -0
- package/docs/src/pages/components/card.js +225 -0
- package/docs/src/pages/components/carousel.js +72 -0
- package/docs/src/pages/components/charts.js +278 -0
- package/docs/src/pages/components/checkbox.js +129 -0
- package/docs/src/pages/components/cluster.js +47 -0
- package/docs/src/pages/components/code-window.js +57 -0
- package/docs/src/pages/components/container.js +40 -0
- package/docs/src/pages/components/cta.js +53 -0
- package/docs/src/pages/components/divider.js +37 -0
- package/docs/src/pages/components/empty.js +36 -0
- package/docs/src/pages/components/feature.js +60 -0
- package/docs/src/pages/components/fieldset.js +65 -0
- package/docs/src/pages/components/fileupload.js +127 -0
- package/docs/src/pages/components/footer.js +58 -0
- package/docs/src/pages/components/grid.js +165 -0
- package/docs/src/pages/components/heading.js +107 -0
- package/docs/src/pages/components/hero.js +65 -0
- package/docs/src/pages/components/icons.js +285 -0
- package/docs/src/pages/components/image.js +71 -0
- package/docs/src/pages/components/input.js +51 -0
- package/docs/src/pages/components/list.js +112 -0
- package/docs/src/pages/components/media.js +51 -0
- package/docs/src/pages/components/modal.js +111 -0
- package/docs/src/pages/components/nav.js +86 -0
- package/docs/src/pages/components/pricing.js +68 -0
- package/docs/src/pages/components/progress.js +102 -0
- package/docs/src/pages/components/prose.js +111 -0
- package/docs/src/pages/components/pullquote.js +71 -0
- package/docs/src/pages/components/radio.js +194 -0
- package/docs/src/pages/components/rating.js +106 -0
- package/docs/src/pages/components/search.js +61 -0
- package/docs/src/pages/components/section.js +59 -0
- package/docs/src/pages/components/segmented.js +121 -0
- package/docs/src/pages/components/select.js +45 -0
- package/docs/src/pages/components/slider.js +114 -0
- package/docs/src/pages/components/spinner.js +73 -0
- package/docs/src/pages/components/stack.js +48 -0
- package/docs/src/pages/components/stat.js +55 -0
- package/docs/src/pages/components/stepper.js +66 -0
- package/docs/src/pages/components/table.js +45 -0
- package/docs/src/pages/components/testimonial.js +49 -0
- package/docs/src/pages/components/textarea.js +31 -0
- package/docs/src/pages/components/timeline.js +227 -0
- package/docs/src/pages/components/toggle.js +84 -0
- package/docs/src/pages/components/tooltip.js +48 -0
- package/docs/src/pages/components.js +204 -0
- package/docs/src/pages/config.js +193 -0
- package/docs/src/pages/constraints.js +99 -0
- package/docs/src/pages/deploy.js +233 -0
- package/docs/src/pages/extending.js +198 -0
- package/docs/src/pages/faq.js +96 -0
- package/docs/src/pages/getting-started.js +106 -0
- package/docs/src/pages/guard.js +121 -0
- package/docs/src/pages/home.js +401 -0
- package/docs/src/pages/how-it-works.js +183 -0
- package/docs/src/pages/hydration.js +98 -0
- package/docs/src/pages/images.js +121 -0
- package/docs/src/pages/meta.js +120 -0
- package/docs/src/pages/mutations.js +106 -0
- package/docs/src/pages/navigation.js +85 -0
- package/docs/src/pages/performance.js +157 -0
- package/docs/src/pages/persist.js +88 -0
- package/docs/src/pages/project-structure.js +90 -0
- package/docs/src/pages/prompt-examples.js +186 -0
- package/docs/src/pages/raw-responses.js +124 -0
- package/docs/src/pages/routing.js +99 -0
- package/docs/src/pages/server-api.js +281 -0
- package/docs/src/pages/server-data.js +185 -0
- package/docs/src/pages/slash-commands.js +55 -0
- package/docs/src/pages/spec.js +207 -0
- package/docs/src/pages/state.js +101 -0
- package/docs/src/pages/store.js +181 -0
- package/docs/src/pages/streaming.js +108 -0
- package/docs/src/pages/stripe.js +193 -0
- package/docs/src/pages/supabase.js +323 -0
- package/docs/src/pages/testing.js +198 -0
- package/docs/src/pages/validation.js +138 -0
- package/examples/contact.js +166 -0
- package/examples/counter.js +94 -0
- package/examples/dev.server.js +91 -0
- package/examples/examples.test.js +394 -0
- package/examples/pricing.js +244 -0
- package/examples/products.js +191 -0
- package/examples/quiz.js +208 -0
- package/examples/shared.js +78 -0
- package/examples/todos.js +162 -0
- package/package.json +75 -0
- package/public/.pulse-ui-version +1 -0
- package/public/chippy-bird.css +246 -0
- package/public/examples/contact.css +119 -0
- package/public/examples/counter.css +79 -0
- package/public/examples/pricing.css +132 -0
- package/public/examples/products.css +100 -0
- package/public/examples/quiz.css +200 -0
- package/public/examples/todos.css +137 -0
- package/public/favicon.ico +0 -0
- package/public/log-dashboard.css +383 -0
- package/public/pulse-ui.css +2740 -0
- package/public/pulse-ui.js +236 -0
- package/public/pulse.css +149 -0
- package/scripts/build.js +411 -0
- package/src/agent/checklist.md +111 -0
- package/src/agent/coverage-check.js +66 -0
- package/src/agent/guide-components.md +274 -0
- package/src/agent/guide-examples.md +54 -0
- package/src/agent/guide-routing.md +36 -0
- package/src/agent/guide-server.md +258 -0
- package/src/agent/guide-spec.md +103 -0
- package/src/agent/guide-styles.md +191 -0
- package/src/agent/guide.md +979 -0
- package/src/agent/identity.md +106 -0
- package/src/agent/workflow.md +108 -0
- package/src/cli/cli.test.js +82 -0
- package/src/cli/dev.js +195 -0
- package/src/cli/discover.js +113 -0
- package/src/cli/index.js +361 -0
- package/src/cli/load-report.js +91 -0
- package/src/cli/load-runner.js +121 -0
- package/src/cli/report-server.js +723 -0
- package/src/cli/report.js +116 -0
- package/src/cli/scaffold.archive.js +1371 -0
- package/src/cli/scaffold.js +349 -0
- package/src/cli/start.js +74 -0
- package/src/html.js +19 -0
- package/src/mcp/server.js +884 -0
- package/src/mcp/validate-worker.js +110 -0
- package/src/runtime/image.js +74 -0
- package/src/runtime/image.test.js +111 -0
- package/src/runtime/index.js +621 -0
- package/src/runtime/navigate.js +146 -0
- package/src/runtime/runtime.test.js +773 -0
- package/src/runtime/ssr.js +464 -0
- package/src/runtime/ssr.test.js +421 -0
- package/src/runtime/store.js +92 -0
- package/src/runtime/toast.js +163 -0
- package/src/server/index.js +1386 -0
- package/src/server/server.test.js +1248 -0
- package/src/spec/schema.js +428 -0
- package/src/spec/schema.test.js +291 -0
- package/src/store/index.js +102 -0
- package/src/store/store.test.js +210 -0
- package/src/testing/html.js +283 -0
- package/src/testing/index.js +249 -0
- package/src/testing/testing.test.js +450 -0
- package/src/ui/accordion.js +28 -0
- package/src/ui/alert.js +43 -0
- package/src/ui/app-badge.js +48 -0
- package/src/ui/avatar.js +47 -0
- package/src/ui/badge.js +24 -0
- package/src/ui/banner.js +26 -0
- package/src/ui/breadcrumbs.js +38 -0
- package/src/ui/button.js +66 -0
- package/src/ui/card.js +34 -0
- package/src/ui/carousel.js +59 -0
- package/src/ui/charts.js +321 -0
- package/src/ui/checkbox.js +65 -0
- package/src/ui/cluster.js +44 -0
- package/src/ui/code-window.js +39 -0
- package/src/ui/container.js +24 -0
- package/src/ui/cta.js +37 -0
- package/src/ui/divider.js +29 -0
- package/src/ui/empty.js +33 -0
- package/src/ui/feature.js +33 -0
- package/src/ui/fieldset.js +37 -0
- package/src/ui/fileupload.js +89 -0
- package/src/ui/footer.js +38 -0
- package/src/ui/grid.js +36 -0
- package/src/ui/heading.js +45 -0
- package/src/ui/hero.js +37 -0
- package/src/ui/icons.js +161 -0
- package/src/ui/index.js +89 -0
- package/src/ui/input.js +74 -0
- package/src/ui/list.js +36 -0
- package/src/ui/media.js +44 -0
- package/src/ui/modal.js +80 -0
- package/src/ui/nav.js +61 -0
- package/src/ui/pricing.js +56 -0
- package/src/ui/progress.js +62 -0
- package/src/ui/prose.js +29 -0
- package/src/ui/pullquote.js +34 -0
- package/src/ui/radio.js +102 -0
- package/src/ui/rating.js +93 -0
- package/src/ui/search.js +77 -0
- package/src/ui/section.js +69 -0
- package/src/ui/segmented.js +50 -0
- package/src/ui/select.js +77 -0
- package/src/ui/slider.js +84 -0
- package/src/ui/spinner.js +34 -0
- package/src/ui/stack.js +36 -0
- package/src/ui/stat.js +52 -0
- package/src/ui/stepper.js +46 -0
- package/src/ui/switch.js +57 -0
- package/src/ui/table.js +45 -0
- package/src/ui/testimonial.js +48 -0
- package/src/ui/textarea.js +72 -0
- package/src/ui/timeline.js +72 -0
- package/src/ui/tooltip.js +28 -0
- package/src/ui/ui.test.js +1241 -0
- package/src/ui/uiimage.js +65 -0
- package/tsconfig.json +13 -0
- package/types/html.d.ts +17 -0
- package/types/image.d.ts +70 -0
- package/types/index.d.ts +7 -0
- package/types/navigate.d.ts +38 -0
- package/types/runtime.d.ts +63 -0
- package/types/schema.d.ts +243 -0
- package/types/server.d.ts +145 -0
- package/types/ssr.d.ts +110 -0
- package/types/testing.d.ts +154 -0
- package/types/ui.d.ts +704 -0
package/CLAUDE.md
ADDED
|
@@ -0,0 +1,383 @@
|
|
|
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:3001
|
|
9
|
+
npm test # run all 92 unit tests
|
|
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`, `state`, and `view`.
|
|
39
|
+
|
|
40
|
+
```js
|
|
41
|
+
export const mySpec = {
|
|
42
|
+
route: '/path', // URL pattern, supports :params
|
|
43
|
+
hydrate: '/path/to/spec.js',// browser-importable path → enables hydration
|
|
44
|
+
|
|
45
|
+
meta: {
|
|
46
|
+
title: 'Page Title',
|
|
47
|
+
description: 'Meta description',
|
|
48
|
+
styles: ['/pulse.css'],
|
|
49
|
+
ogTitle: '...',
|
|
50
|
+
ogImage: '...',
|
|
51
|
+
},
|
|
52
|
+
|
|
53
|
+
// Server data — resolved before render, passed to view as second arg
|
|
54
|
+
server: {
|
|
55
|
+
data: async (ctx) => ({ ... }) // ctx has params, query, headers, cookies
|
|
56
|
+
},
|
|
57
|
+
|
|
58
|
+
// Timeout for all server fetchers on this page (ms). Overrides createServer fetcherTimeout.
|
|
59
|
+
serverTimeout: 5000,
|
|
60
|
+
|
|
61
|
+
// Global store keys this page subscribes to — appears in view's server arg
|
|
62
|
+
// Store mutations update all subscribed pages without a server round-trip
|
|
63
|
+
store: ['user', 'cart'],
|
|
64
|
+
|
|
65
|
+
// Initial client state — deep cloned on mount, never mutated directly
|
|
66
|
+
state: { count: 0 },
|
|
67
|
+
|
|
68
|
+
// Declarative min/max bounds — always enforced after mutations
|
|
69
|
+
constraints: {
|
|
70
|
+
count: { min: 0, max: 10 }
|
|
71
|
+
},
|
|
72
|
+
|
|
73
|
+
// Validation rules — checked when action.validate === true
|
|
74
|
+
validation: {
|
|
75
|
+
'fields.email': { required: true, format: 'email' },
|
|
76
|
+
'fields.name': { required: true, minLength: 2, maxLength: 100 }
|
|
77
|
+
// formats: email | url | numeric
|
|
78
|
+
// rules: required | minLength | maxLength | min | max | pattern
|
|
79
|
+
},
|
|
80
|
+
|
|
81
|
+
// Pure function(s) — return HTML string, no side effects
|
|
82
|
+
// Can be a single function or a keyed object of segment functions
|
|
83
|
+
view: (state, server) => `<main>...</main>`,
|
|
84
|
+
|
|
85
|
+
// Optional — called client-side when view() throws. Return an HTML string.
|
|
86
|
+
// Without this, the runtime shows an inline error message and logs to console.
|
|
87
|
+
// On the server, a throwing view always propagates to the server error handler
|
|
88
|
+
// unless onViewError is defined, in which case it returns the fallback HTML with 200.
|
|
89
|
+
onViewError: (err, state, serverState) => `<p>Something went wrong</p>`,
|
|
90
|
+
|
|
91
|
+
// Synchronous state changes — return partial state to merge
|
|
92
|
+
mutations: {
|
|
93
|
+
increment: (state, event) => ({ count: state.count + 1 }),
|
|
94
|
+
},
|
|
95
|
+
|
|
96
|
+
// Async operations — lifecycle: onStart → validate → run → onSuccess/onError
|
|
97
|
+
actions: {
|
|
98
|
+
submit: {
|
|
99
|
+
onStart: (state, formData) => ({ status: 'loading', ... }),
|
|
100
|
+
validate: true, // runs validation before run()
|
|
101
|
+
run: async (state, serverState, formData) => { /* fetch, etc */ },
|
|
102
|
+
onSuccess: (state, payload) => ({
|
|
103
|
+
status: 'success',
|
|
104
|
+
_toast: { message: 'Saved!', variant: 'success' }, // show a toast
|
|
105
|
+
}),
|
|
106
|
+
onError: (state, err) => ({
|
|
107
|
+
status: 'error',
|
|
108
|
+
errors: err?.validation ?? [{ message: err.message }],
|
|
109
|
+
_toast: { message: 'Something went wrong', variant: 'error' },
|
|
110
|
+
}),
|
|
111
|
+
}
|
|
112
|
+
},
|
|
113
|
+
|
|
114
|
+
// Streaming SSR — split view into shell (instant) + deferred segments
|
|
115
|
+
stream: {
|
|
116
|
+
shell: ['header', 'nav'],
|
|
117
|
+
deferred: ['feed']
|
|
118
|
+
}
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
export default mySpec // required for hydration imports
|
|
122
|
+
```
|
|
123
|
+
|
|
124
|
+
## HTML Event Binding
|
|
125
|
+
|
|
126
|
+
Pulse binds to DOM attributes — no JSX, no templates.
|
|
127
|
+
|
|
128
|
+
```html
|
|
129
|
+
<button data-event="increment">+</button> <!-- click → mutation -->
|
|
130
|
+
<input data-event="change:setName"> <!-- change → mutation -->
|
|
131
|
+
<input data-event="input:setQuery"> <!-- input → mutation -->
|
|
132
|
+
<input data-event="input:search" data-debounce="300"> <!-- debounced input (300ms) -->
|
|
133
|
+
<input data-event="input:filter" data-throttle="100"> <!-- throttled input (100ms) -->
|
|
134
|
+
<form data-action="submit">...</form> <!-- submit → action, passes FormData -->
|
|
135
|
+
<button data-store-event="toggleTheme">Toggle</button> <!-- click → store mutation -->
|
|
136
|
+
<select data-store-event="change:setLang">...</select> <!-- change → store mutation -->
|
|
137
|
+
<button data-dialog-open="my-modal">Open modal</button> <!-- opens <dialog id="my-modal"> -->
|
|
138
|
+
<button data-dialog-close>Cancel</button> <!-- closes nearest ancestor <dialog> -->
|
|
139
|
+
```
|
|
140
|
+
|
|
141
|
+
**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.
|
|
142
|
+
|
|
143
|
+
**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.
|
|
144
|
+
|
|
145
|
+
## Dev vs Production Hydration
|
|
146
|
+
|
|
147
|
+
**Dev mode** — the HTML includes an inline bootstrap script importing source files directly:
|
|
148
|
+
```html
|
|
149
|
+
<script type="module">
|
|
150
|
+
import spec from '/examples/counter.js'
|
|
151
|
+
import { mount } from '/src/runtime/index.js'
|
|
152
|
+
import { initNavigation } from '/src/runtime/navigate.js'
|
|
153
|
+
mount(spec, root, window.__PULSE_SERVER__ || {}, { ssr: true })
|
|
154
|
+
initNavigation(root, mount)
|
|
155
|
+
</script>
|
|
156
|
+
```
|
|
157
|
+
|
|
158
|
+
**Production** — after `npm run build`, `spec.hydrate` is resolved via `manifest.json` to a content-hashed bundle. The HTML becomes a single external tag:
|
|
159
|
+
```html
|
|
160
|
+
<script type="module" src="/dist/counter.boot-HASH.js"></script>
|
|
161
|
+
```
|
|
162
|
+
|
|
163
|
+
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.
|
|
164
|
+
|
|
165
|
+
## Build Output
|
|
166
|
+
|
|
167
|
+
`npm run build` generates three things per app:
|
|
168
|
+
|
|
169
|
+
- `public/dist/runtime-[hash].js` — shared runtime (mount + navigate + schema, ~2.1 kB brotli)
|
|
170
|
+
- `public/dist/[name].boot-[hash].js` — per-page spec bundle (~0.5–0.9 kB brotli)
|
|
171
|
+
- `public/dist/manifest.json` — maps `/examples/foo.js` → `/dist/foo.boot-HASH.js`
|
|
172
|
+
|
|
173
|
+
To add a new page to the build, add its spec path to the `ENTRIES` array in `scripts/build.js`.
|
|
174
|
+
|
|
175
|
+
The server auto-detects the manifest from `staticDir/dist/manifest.json` when `staticDir` is set. No config needed.
|
|
176
|
+
|
|
177
|
+
## Server
|
|
178
|
+
|
|
179
|
+
```js
|
|
180
|
+
import { createServer } from './src/server/index.js'
|
|
181
|
+
|
|
182
|
+
createServer([specA, specB], {
|
|
183
|
+
port: 3000,
|
|
184
|
+
stream: true, // streaming SSR (default)
|
|
185
|
+
staticDir: 'public', // serve static files + auto-load manifest
|
|
186
|
+
manifest: null, // explicit manifest path or object (overrides auto-detect)
|
|
187
|
+
defaultCache: 3600, // default HTML cache TTL in seconds for all pages (prod only)
|
|
188
|
+
// also accepts true (3600s + swr 86400s) or { public, maxAge, staleWhileRevalidate }
|
|
189
|
+
// spec.cache overrides per-page; in-process + Cache-Control headers both set
|
|
190
|
+
fetcherTimeout: 5000, // ms before any server fetcher times out (null = no limit)
|
|
191
|
+
// spec.serverTimeout overrides per-page
|
|
192
|
+
shutdownTimeout: 30000, // ms to wait for in-flight requests before force-exit on SIGTERM/SIGINT
|
|
193
|
+
healthCheck: '/healthz', // built-in health endpoint path, or false to disable
|
|
194
|
+
csp: { // extra sources merged into the framework's default CSP
|
|
195
|
+
'style-src': ['https://fonts.googleapis.com'],
|
|
196
|
+
'font-src': ['https://fonts.gstatic.com'],
|
|
197
|
+
},
|
|
198
|
+
resolveBrand: async (host) => db.brands.findBySlug(host.split('.')[0]),
|
|
199
|
+
// multi-brand: result cached 60s, attached to ctx.brand
|
|
200
|
+
onRequest: (req, res) => { /* return false to short-circuit */ },
|
|
201
|
+
onError: (err, req, res) => { /* custom error handling */ }
|
|
202
|
+
})
|
|
203
|
+
```
|
|
204
|
+
|
|
205
|
+
All specs are validated at startup — bad specs throw before the server accepts connections.
|
|
206
|
+
|
|
207
|
+
## Multi-brand Sites
|
|
208
|
+
|
|
209
|
+
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.
|
|
210
|
+
|
|
211
|
+
Any `meta` field can be a function `(ctx) => value` — called per request, not at startup:
|
|
212
|
+
|
|
213
|
+
```js
|
|
214
|
+
export default {
|
|
215
|
+
meta: {
|
|
216
|
+
title: (ctx) => `${ctx.brand.name} — Home`,
|
|
217
|
+
styles: (ctx) => ['/pulse-ui.css', `/themes/${ctx.brand.slug}.css`],
|
|
218
|
+
},
|
|
219
|
+
server: {
|
|
220
|
+
brand: (ctx) => ctx.brand, // expose to view as serverState.brand
|
|
221
|
+
},
|
|
222
|
+
view: (state, { brand }) => `<h1>${brand.name}</h1>`,
|
|
223
|
+
guard: async (ctx) => {
|
|
224
|
+
if (!ctx.brand) return { redirect: '/not-found' }
|
|
225
|
+
},
|
|
226
|
+
}
|
|
227
|
+
```
|
|
228
|
+
|
|
229
|
+
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.
|
|
230
|
+
|
|
231
|
+
## Custom Fonts
|
|
232
|
+
|
|
233
|
+
`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.
|
|
234
|
+
|
|
235
|
+
```css
|
|
236
|
+
/* app.css */
|
|
237
|
+
:root {
|
|
238
|
+
--font: 'Inter', system-ui, sans-serif;
|
|
239
|
+
}
|
|
240
|
+
```
|
|
241
|
+
|
|
242
|
+
Load the font via `meta.styles` before `pulse-ui.css`, or self-host in `staticDir/fonts/` with `@font-face`:
|
|
243
|
+
|
|
244
|
+
```js
|
|
245
|
+
meta: {
|
|
246
|
+
styles: [
|
|
247
|
+
'https://fonts.googleapis.com/css2?family=Inter&display=swap',
|
|
248
|
+
'/pulse-ui.css',
|
|
249
|
+
'/app.css',
|
|
250
|
+
]
|
|
251
|
+
}
|
|
252
|
+
```
|
|
253
|
+
|
|
254
|
+
For multi-brand setups, each brand theme file just overrides `--font` in `:root`.
|
|
255
|
+
|
|
256
|
+
## HTTP Response Behaviour
|
|
257
|
+
|
|
258
|
+
- **Full page request** — SSR HTML with hydration bootstrap
|
|
259
|
+
- **`X-Pulse-Navigate: true`** — returns JSON `{ html, title, hydrate, serverState }` for client-side navigation
|
|
260
|
+
- **Security headers** — on every response: `X-Content-Type-Options`, `X-Frame-Options`, `Referrer-Policy`, `Permissions-Policy`, `Cross-Origin-Opener-Policy`, `Cross-Origin-Resource-Policy`
|
|
261
|
+
- **Compression** — brotli preferred, gzip fallback for all compressible types
|
|
262
|
+
- **Cache** — `/dist/*` bundles: `immutable, max-age=31536000`; static assets: `max-age=3600`; HTML: `no-store`
|
|
263
|
+
|
|
264
|
+
## Client Navigation
|
|
265
|
+
|
|
266
|
+
`initNavigation(root, mount)` intercepts same-origin `<a>` clicks:
|
|
267
|
+
1. Fetches the new page with `X-Pulse-Navigate: true`
|
|
268
|
+
2. Swaps `root.innerHTML` + updates `document.title`
|
|
269
|
+
3. Dynamically imports the new spec bundle (`import(hydrate)`)
|
|
270
|
+
4. Calls `mount(spec, root, serverState)` to re-attach interactivity
|
|
271
|
+
|
|
272
|
+
Falls back to `location.href` on any error.
|
|
273
|
+
|
|
274
|
+
## Testing
|
|
275
|
+
|
|
276
|
+
Tests use Node.js built-in `node:test` and `node:assert`. Each test file is run directly:
|
|
277
|
+
|
|
278
|
+
```bash
|
|
279
|
+
node src/spec/schema.test.js
|
|
280
|
+
node src/runtime/runtime.test.js
|
|
281
|
+
node src/runtime/ssr.test.js
|
|
282
|
+
node src/server/server.test.js
|
|
283
|
+
```
|
|
284
|
+
|
|
285
|
+
Server tests use an incrementing port counter (`withServer` helper) to avoid TCP TIME_WAIT conflicts between tests. Each test gets its own port.
|
|
286
|
+
|
|
287
|
+
### View testing — `@invisibleloop/pulse/testing`
|
|
288
|
+
|
|
289
|
+
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.
|
|
290
|
+
|
|
291
|
+
```js
|
|
292
|
+
import { renderSync, render } from '@invisibleloop/pulse/testing'
|
|
293
|
+
|
|
294
|
+
// Sync — calls view directly, pass mock state/server
|
|
295
|
+
const result = renderSync(mySpec, { state: { count: 5 }, server: { items: [] } })
|
|
296
|
+
result.has('button') // → true/false
|
|
297
|
+
result.get('#count').text // → '5' (throws if not found)
|
|
298
|
+
result.find('.title')?.text // → string | null
|
|
299
|
+
result.findAll('li') // → Element[]
|
|
300
|
+
result.count('li') // → number
|
|
301
|
+
result.attr('input[name="email"]', 'value') // → string | null
|
|
302
|
+
result.text() // → all text, tags stripped
|
|
303
|
+
|
|
304
|
+
// Async — runs real spec.server fetchers (pass server to mock them)
|
|
305
|
+
const result = await render(mySpec, { server: { product: mockProduct } })
|
|
306
|
+
const result = await render(mySpec, { ctx: { params: { id: '1' } } }) // real fetchers
|
|
307
|
+
```
|
|
308
|
+
|
|
309
|
+
**Supported selectors:** `button`, `.class`, `#id`, `[attr]`, `[attr="value"]`, and combinations: `button.primary[type="submit"]`.
|
|
310
|
+
`Element` also has `.find()`, `.findAll()`, `.has()`, `.attr()`, `.text`, `.tag`, `.attrs`.
|
|
311
|
+
|
|
312
|
+
## Key Decisions (Do Not Reverse)
|
|
313
|
+
|
|
314
|
+
| Decision | Reason |
|
|
315
|
+
|---|---|
|
|
316
|
+
| `{ ssr: true }` skips initial render in `mount()` | Preserves SSR-painted LCP — re-rendering would cause a flash and push LCP to ~500ms |
|
|
317
|
+
| `onStart` captures `FormData` before `validate` runs | Uncontrolled inputs — `innerHTML` replacement on keystroke destroys focus |
|
|
318
|
+
| Self-executing bundles with `export default spec` | Single HTTP request for JS; spec exported so client navigation can re-mount |
|
|
319
|
+
| `splitting: true` in esbuild | Shared runtime extracted once, cached across all page navigations |
|
|
320
|
+
| `window.__PULSE_SERVER__` injected by `renderToStream` | Streaming path must serialize server state into HTML — client hydration reads it without a second request |
|
|
321
|
+
| Bundle path detection via `/dist/` prefix | `wrapDocument` and streaming path emit `<script src>` for bundles, inline bootstrap for dev source files |
|
|
322
|
+
| Security headers on every response, including 404/405 | Defense in depth — error responses are also entry points |
|
|
323
|
+
|
|
324
|
+
## Build Workflow
|
|
325
|
+
|
|
326
|
+
Every build task follows this sequence. Each phase has a pass gate — do not advance until it clears.
|
|
327
|
+
|
|
328
|
+
**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.
|
|
329
|
+
|
|
330
|
+
| Phase | Action | Gate |
|
|
331
|
+
|---|---|---|
|
|
332
|
+
| 1. Understand | Read guides, call `pulse_list_structure` | — |
|
|
333
|
+
| 2. Plan | Present plan to user, wait for confirmation | User confirms |
|
|
334
|
+
| 3. Build | Write spec + related files | — |
|
|
335
|
+
| 4. Validate | `pulse_validate` — fix all errors + warnings | Clean output |
|
|
336
|
+
| 5. Browser | Screenshot + Lighthouse desktop + mobile | 100/100/100 (Accessibility, Best Practices, SEO) both strategies |
|
|
337
|
+
| 6. Tests | Write tests, run them, fix failures | All pass |
|
|
338
|
+
| 7. Review Agent | Invoke review — **only after phases 4–6 all pass** | — |
|
|
339
|
+
| 8. Fix | Fix every review issue, re-run affected gates | All gates still pass |
|
|
340
|
+
|
|
341
|
+
**Skip phase 2 confirmation only for trivially small, unambiguous tasks.** When in doubt, confirm.
|
|
342
|
+
|
|
343
|
+
**The Review Agent is always last.** Never invoke it before validate, Lighthouse, and tests all pass.
|
|
344
|
+
|
|
345
|
+
The `/verify` command runs the browser check loop (phases 4–5) automatically. Use it.
|
|
346
|
+
|
|
347
|
+
## Pulse vs React — Do Not Do These
|
|
348
|
+
|
|
349
|
+
Pulse views are plain JS template literals, not JSX. These React patterns are **wrong in Pulse**:
|
|
350
|
+
|
|
351
|
+
| Wrong (React) | Correct (Pulse) |
|
|
352
|
+
|---|---|
|
|
353
|
+
| `className="foo"` | `class="foo"` |
|
|
354
|
+
| `htmlFor="id"` | `for="id"` |
|
|
355
|
+
| `onClick={handler}` | `data-event="click:mutationName"` |
|
|
356
|
+
| `onChange={handler}` | `data-event="change:mutationName"` |
|
|
357
|
+
| `<input value={state.x}>` | Uncontrolled — read via `FormData` in `onStart` |
|
|
358
|
+
| `{expression}` in JSX | `${expression}` in template literal |
|
|
359
|
+
| `<>...</>` fragments | No fragments — use a real wrapper element |
|
|
360
|
+
| `import React from 'react'` | No import needed — plain JS |
|
|
361
|
+
| `useState`, `useEffect`, `useRef` | No hooks — use `state`, `mutations`, `actions` |
|
|
362
|
+
| `key={item.id}` on list items | No `key` props — no virtual DOM |
|
|
363
|
+
| Capitalized `<MyComponent />` | Call as a plain function: `${myComponent(props)}` |
|
|
364
|
+
| `setState(prev => ...)` | Mutations return partial state: `(state) => ({ x: state.x + 1 })` |
|
|
365
|
+
|
|
366
|
+
Also: mutations must be pure (no fetch, no DOM access). Only `actions` can be async.
|
|
367
|
+
|
|
368
|
+
## Check Components Before Building
|
|
369
|
+
|
|
370
|
+
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.
|
|
371
|
+
|
|
372
|
+
@src/agent/checklist.md
|
|
373
|
+
|
|
374
|
+
## Performance Baseline
|
|
375
|
+
|
|
376
|
+
| Page | CLS | JS (brotli) |
|
|
377
|
+
|---|---|---|
|
|
378
|
+
| /counter | 0.00 | 3.5 kB (first visit) / 0.35 kB (cached runtime) |
|
|
379
|
+
| /contact | 0.00 | 3.6 kB (first visit) / 0.47 kB (cached runtime) |
|
|
380
|
+
|
|
381
|
+
Lighthouse: 100/100/100 (Accessibility / Best Practices / SEO) on both pages.
|
|
382
|
+
|
|
383
|
+
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.
|
package/README.md
ADDED
|
@@ -0,0 +1,95 @@
|
|
|
1
|
+
# Pulse
|
|
2
|
+
|
|
3
|
+
**A spec-first, AI-native web framework.** Early access — v0.1.
|
|
4
|
+
|
|
5
|
+
Write a plain JavaScript object that describes what a page does. Pulse handles routing, SSR, hydration, client-side navigation, compression, security headers, and caching automatically.
|
|
6
|
+
|
|
7
|
+
```js
|
|
8
|
+
export default {
|
|
9
|
+
route: '/counter',
|
|
10
|
+
hydrate: '/src/pages/counter.js',
|
|
11
|
+
meta: {
|
|
12
|
+
title: 'Counter',
|
|
13
|
+
styles: ['/app.css'],
|
|
14
|
+
},
|
|
15
|
+
|
|
16
|
+
state: { count: 0 },
|
|
17
|
+
|
|
18
|
+
constraints: {
|
|
19
|
+
count: { min: 0, max: 10 },
|
|
20
|
+
},
|
|
21
|
+
|
|
22
|
+
view: (state) => `
|
|
23
|
+
<main id="main-content">
|
|
24
|
+
<p>${state.count}</p>
|
|
25
|
+
<button data-event="decrement">−</button>
|
|
26
|
+
<button data-event="increment">+</button>
|
|
27
|
+
</main>
|
|
28
|
+
`,
|
|
29
|
+
|
|
30
|
+
mutations: {
|
|
31
|
+
increment: (state) => ({ count: state.count + 1 }),
|
|
32
|
+
decrement: (state) => ({ count: state.count - 1 }),
|
|
33
|
+
},
|
|
34
|
+
}
|
|
35
|
+
```
|
|
36
|
+
|
|
37
|
+
## Requirements
|
|
38
|
+
|
|
39
|
+
Node.js 22 or later.
|
|
40
|
+
|
|
41
|
+
## Getting started
|
|
42
|
+
|
|
43
|
+
```bash
|
|
44
|
+
npm install -g @invisibleloop/pulse
|
|
45
|
+
mkdir my-project && cd my-project
|
|
46
|
+
pulse
|
|
47
|
+
```
|
|
48
|
+
|
|
49
|
+
Running `pulse` in an empty directory scaffolds a new project and starts an AI coding session. Your agent has MCP tools and slash commands available from the first prompt.
|
|
50
|
+
|
|
51
|
+
Full documentation: **[pulseframework.dev](https://pulseframework.dev)**
|
|
52
|
+
|
|
53
|
+
## Key ideas
|
|
54
|
+
|
|
55
|
+
**The spec is the source of truth.** Every page is a single JS object — data fetching, state, mutations, async actions, and view all in one place. No separate files for routes, controllers, or templates.
|
|
56
|
+
|
|
57
|
+
**Performance is built in.** Streaming SSR, immutable asset caching, and zero layout shift are automatic. Every scaffolded project targets Lighthouse 100 across all four categories.
|
|
58
|
+
|
|
59
|
+
**No client-side dependencies.** Pulse ships no runtime framework to the browser — only a small hydration bundle (~2 kB brotli) that binds your spec's mutations and actions to the DOM.
|
|
60
|
+
|
|
61
|
+
**AI-native.** The CLI starts an MCP server alongside the dev server, giving the coding agent tools to create pages, validate specs, and run Lighthouse audits — all without leaving the editor.
|
|
62
|
+
|
|
63
|
+
## CLI
|
|
64
|
+
|
|
65
|
+
```bash
|
|
66
|
+
pulse # scaffold a new project or start AI session
|
|
67
|
+
pulse dev # dev server (default port 3000)
|
|
68
|
+
pulse build # production build → public/dist/
|
|
69
|
+
pulse start # production server
|
|
70
|
+
```
|
|
71
|
+
|
|
72
|
+
## Docs
|
|
73
|
+
|
|
74
|
+
Full reference at [pulseframework.dev](https://pulseframework.dev):
|
|
75
|
+
|
|
76
|
+
- **Getting started** — install, scaffold, first page
|
|
77
|
+
- **The spec** — full reference for every property
|
|
78
|
+
- **State, mutations, actions** — client interactivity
|
|
79
|
+
- **Server data** — async data fetching before render
|
|
80
|
+
- **Routing** — dynamic routes and URL params
|
|
81
|
+
- **Streaming SSR** — shell + deferred segments
|
|
82
|
+
- **Caching** — per-page TTL and HTTP cache headers
|
|
83
|
+
- **Guard** — authentication and authorisation before data fetches
|
|
84
|
+
- **Validation & constraints** — declarative form validation and state bounds
|
|
85
|
+
- **Raw responses** — RSS feeds, sitemaps, JSON APIs
|
|
86
|
+
- **Performance** — how the framework hits Lighthouse 100
|
|
87
|
+
- **UI components** — 50+ built-in components
|
|
88
|
+
|
|
89
|
+
## Status
|
|
90
|
+
|
|
91
|
+
This is an early-access release. The core architecture is stable and production-quality, but the API may evolve before v1. Feedback and issues welcome on [GitHub](https://github.com/invisibleloop/pulse).
|
|
92
|
+
|
|
93
|
+
## License
|
|
94
|
+
|
|
95
|
+
MIT
|
|
@@ -0,0 +1,111 @@
|
|
|
1
|
+
## Spec review checklist
|
|
2
|
+
|
|
3
|
+
Before finishing any spec, verify every point below. Fix anything that fails.
|
|
4
|
+
|
|
5
|
+
### Critical
|
|
6
|
+
|
|
7
|
+
- **`hydrate` is set on every interactive page.** Without it, `data-event` / `data-action` bindings do nothing, `persist` never runs, and client-side navigation cannot re-mount the page. Every spec with `mutations`, `actions`, or `persist` must include:
|
|
8
|
+
```js
|
|
9
|
+
hydrate: '/src/pages/my-page.js', // browser-importable path to this file
|
|
10
|
+
```
|
|
11
|
+
Omit `hydrate` only for purely server-rendered pages with zero client interactivity.
|
|
12
|
+
|
|
13
|
+
### Components first
|
|
14
|
+
|
|
15
|
+
- **Before writing any HTML by hand, check `src/ui/index.js`.** There are 50+ components. Use `button`, `card`, `alert`, `input`, `spinner`, `badge`, `modal`, `nav`, `pagination`, `table`, etc. before writing equivalent HTML from scratch.
|
|
16
|
+
|
|
17
|
+
### Reuse (DRY)
|
|
18
|
+
|
|
19
|
+
- **Extract a view helper when the same HTML pattern appears 3 or more times in a single spec.** A plain JS function returning an HTML string is sufficient — no framework needed:
|
|
20
|
+
```js
|
|
21
|
+
const card = ({ title, body }) => `<div class="card"><h3>${title}</h3><p>${body}</p></div>`
|
|
22
|
+
```
|
|
23
|
+
- **Create a shared component in `src/ui/` when the same pattern is needed across 2 or more different specs.** Follow the existing pattern: a named export that returns an HTML string.
|
|
24
|
+
- **Do not abstract a pattern that appears only once.** Duplication is cheaper than the wrong abstraction. Wait until the third use before extracting.
|
|
25
|
+
|
|
26
|
+
### Correctness
|
|
27
|
+
|
|
28
|
+
- Mutations return plain partial-state objects and have no side effects (no fetch, no DOM access).
|
|
29
|
+
- `persist` contains only serialisable state that should survive a page reload — not ephemeral UI state like a loading flag or temporary selection.
|
|
30
|
+
- `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.
|
|
31
|
+
- State shape is consistent — avoid a single field that is sometimes `null`, sometimes a string, sometimes a boolean. Use a dedicated `status` field instead.
|
|
32
|
+
- **Never use `state.modalOpen` or conditional modal rendering.** 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:
|
|
33
|
+
```html
|
|
34
|
+
<!-- always in the view, never conditional -->
|
|
35
|
+
${modal({ id: 'confirm', title: 'Confirm', content: '...' })}
|
|
36
|
+
<!-- anywhere on the page — opens the dialog, no mutation needed -->
|
|
37
|
+
${modalTrigger({ target: 'confirm', label: 'Open' })}
|
|
38
|
+
<!-- or inline: -->
|
|
39
|
+
<button data-dialog-open="confirm">Open</button>
|
|
40
|
+
```
|
|
41
|
+
Close is handled natively by `<form method="dialog">` (inside the modal), ESC key, backdrop click, or `data-dialog-close` on any element.
|
|
42
|
+
|
|
43
|
+
### Defensive data handling
|
|
44
|
+
|
|
45
|
+
- **Always check `res.ok` before parsing fetch responses.** Never call `res.json()` on a response that may have failed. Never use `await res.text()` as an error message — on a 404 or 500, this returns raw HTML which surfaces directly in toasts and error alerts. The correct pattern:
|
|
46
|
+
```js
|
|
47
|
+
const res = await fetch('/api/...')
|
|
48
|
+
if (!res.ok) {
|
|
49
|
+
let message = `Request failed: ${res.status}`
|
|
50
|
+
try { const j = await res.json(); message = j.message || j.error || message } catch {}
|
|
51
|
+
throw new Error(message)
|
|
52
|
+
}
|
|
53
|
+
return await res.json()
|
|
54
|
+
```
|
|
55
|
+
- **Never assume the shape of data from external APIs or server fetchers.** Use optional chaining (`?.`) and nullish coalescing (`??`) at every access point. If a server fetcher can return `null`, the view must handle it — `server.user?.name ?? 'Guest'` not `server.user.name`.
|
|
56
|
+
- **Validate FormData fields before use.** `formData.get('email')` returns `null` if the field is missing. Check for null/empty before passing to an API or database.
|
|
57
|
+
- **Do not trust URL params.** `ctx.params.id` is a raw string from the URL. Validate it before use — check it exists, is the right type, and refers to a real resource. Return a 404 or redirect if it doesn't.
|
|
58
|
+
|
|
59
|
+
### Security
|
|
60
|
+
|
|
61
|
+
- Any value from user input (URL params, form fields, external APIs) interpolated into view HTML must be escaped.
|
|
62
|
+
|
|
63
|
+
### Tests
|
|
64
|
+
|
|
65
|
+
- Pure logic functions extracted from a spec (e.g. `checkResult`, `validate`, `formatPrice`) must have unit tests in a corresponding `.test.js` file.
|
|
66
|
+
- **When fixing a bug, write a failing test first.** The test must reproduce the bug before the fix is applied, then pass after. This pins the behaviour so the bug cannot silently return. A fix without a regression test is incomplete.
|
|
67
|
+
- **Use `renderSync` / `render` from `@invisibleloop/pulse/testing` to test view HTML output.** Do not test views with raw `html.includes()` — use the query helpers instead:
|
|
68
|
+
```js
|
|
69
|
+
import { renderSync, render } from '@invisibleloop/pulse/testing'
|
|
70
|
+
|
|
71
|
+
// Sync — call view directly with mock state/server
|
|
72
|
+
const result = renderSync(mySpec, { state: { count: 5 }, server: { items: [] } })
|
|
73
|
+
assert(result.has('button'))
|
|
74
|
+
assert.equal(result.get('#count').text, '5')
|
|
75
|
+
assert.equal(result.count('li'), 0)
|
|
76
|
+
|
|
77
|
+
// Async — run real server fetchers (integration), or pass server to skip them
|
|
78
|
+
const result = await render(mySpec, { server: { product: mockProduct } })
|
|
79
|
+
assert.equal(result.get('h1').text, mockProduct.name)
|
|
80
|
+
assert.equal(result.attr('img', 'src'), mockProduct.image)
|
|
81
|
+
```
|
|
82
|
+
Supported selectors: `tag`, `.class`, `#id`, `[attr]`, `[attr="value"]`, and combinations (`button.primary[disabled]`).
|
|
83
|
+
|
|
84
|
+
### View error handling
|
|
85
|
+
|
|
86
|
+
- **Define `onViewError` on any page where the view could throw due to bad or missing data.** Without it, a runtime view error returns a 500 on the server and shows a generic inline message on the client. With it, the server returns 200 with your fallback HTML instead of a 500, and the client renders your fallback:
|
|
87
|
+
```js
|
|
88
|
+
onViewError: (err, state, serverState) => `
|
|
89
|
+
<div class="u-p-4 u-text-center">
|
|
90
|
+
<p>Something went wrong. <a href="">Reload</a></p>
|
|
91
|
+
</div>
|
|
92
|
+
`
|
|
93
|
+
```
|
|
94
|
+
Use this on pages that render data from external APIs or user-supplied content — any path where the view can encounter `null`, `undefined`, or unexpected shapes that would cause a crash. It is not required on simple pages with predictable data.
|
|
95
|
+
|
|
96
|
+
### Store updates from actions
|
|
97
|
+
|
|
98
|
+
- **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:
|
|
99
|
+
```js
|
|
100
|
+
onSuccess: (state, theme) => ({
|
|
101
|
+
saved: true,
|
|
102
|
+
_storeUpdate: { settings: { theme } }, // ← merged into store state
|
|
103
|
+
}),
|
|
104
|
+
```
|
|
105
|
+
`_storeUpdate` only merges into the store — it does not appear in the page's own state. The rest of the return is merged into local state as normal. Use this instead of a full-page reload when a user action changes shared data (theme, cart count, user profile, etc.).
|
|
106
|
+
|
|
107
|
+
### Accessibility
|
|
108
|
+
|
|
109
|
+
- Interactive elements without visible text have an `aria-label`.
|
|
110
|
+
- Disabled state is reflected with the `disabled` attribute, not just CSS.
|
|
111
|
+
- The page has a `<main id="main-content">` landmark.
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
0.1.20
|