@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
|
@@ -0,0 +1,274 @@
|
|
|
1
|
+
## UI components — MANDATORY
|
|
2
|
+
|
|
3
|
+
**RULE: Before writing any HTML, check whether a Pulse UI component exists for the purpose. If it does, you MUST use it. Raw HTML is only permitted for structural tags with no component equivalent (div, main, aside, footer, header).**
|
|
4
|
+
|
|
5
|
+
**RULE: All components accept a `class` prop — never `className` (that is React). Use `class` to add utility classes or custom identifiers to any component.**
|
|
6
|
+
|
|
7
|
+
Import only what you use. Icons are included — no third-party library needed. Always include `/pulse-ui.css` in `meta.styles`:
|
|
8
|
+
```js
|
|
9
|
+
import { nav, hero, feature, button, card, stat, grid, section, container, iconZap, iconShield, iconCheck } from '@invisibleloop/pulse/ui'
|
|
10
|
+
meta: { styles: ['/pulse-ui.css', '/app.css'] }
|
|
11
|
+
```
|
|
12
|
+
|
|
13
|
+
Interactive components (carousel, tooltip) also need `/pulse-ui.js` in `meta.scripts`. Modal is handled natively by the Pulse runtime — no extra script needed.
|
|
14
|
+
|
|
15
|
+
### Charts
|
|
16
|
+
|
|
17
|
+
Server-rendered SVG charts — no JS, no dependencies. Drop into any card, section, or grid.
|
|
18
|
+
|
|
19
|
+
```js
|
|
20
|
+
import { barChart, lineChart, donutChart, sparkline } from '@invisibleloop/pulse/ui'
|
|
21
|
+
|
|
22
|
+
barChart({
|
|
23
|
+
data: [{ label: 'Jan', value: 42 }, { label: 'Feb', value: 78 }],
|
|
24
|
+
color: 'accent', // accent · success · warning · error · blue · muted
|
|
25
|
+
showValues: true,
|
|
26
|
+
showGrid: true,
|
|
27
|
+
gap: 0.25,
|
|
28
|
+
height: 220,
|
|
29
|
+
})
|
|
30
|
+
|
|
31
|
+
lineChart({
|
|
32
|
+
data: [{ label: 'Jan', value: 42 }, ...],
|
|
33
|
+
color: 'accent',
|
|
34
|
+
area: true,
|
|
35
|
+
showDots: true,
|
|
36
|
+
height: 220,
|
|
37
|
+
})
|
|
38
|
+
|
|
39
|
+
donutChart({
|
|
40
|
+
data: [
|
|
41
|
+
{ label: 'Satisfied', value: 73, color: 'success' },
|
|
42
|
+
{ label: 'Neutral', value: 18, color: 'muted' },
|
|
43
|
+
{ label: 'Unsatisfied', value: 9, color: 'error' },
|
|
44
|
+
],
|
|
45
|
+
size: 200, thickness: 40, label: '73%', sublabel: 'satisfied',
|
|
46
|
+
})
|
|
47
|
+
|
|
48
|
+
sparkline({ data: [12,18,14,22,19,28], color: 'success', area: true })
|
|
49
|
+
```
|
|
50
|
+
|
|
51
|
+
Charts compose naturally:
|
|
52
|
+
```js
|
|
53
|
+
card({ title: 'Monthly signups', content: barChart({ data, height: 180 }) })
|
|
54
|
+
grid({ cols: 2, content:
|
|
55
|
+
card({ title: 'Revenue', content: barChart({ data: monthly, color: 'accent' }) }) +
|
|
56
|
+
card({ title: 'Traffic', content: lineChart({ data: daily, color: 'blue', area: true }) }),
|
|
57
|
+
})
|
|
58
|
+
```
|
|
59
|
+
|
|
60
|
+
### Icons
|
|
61
|
+
|
|
62
|
+
55 built-in icons — no third-party library needed. All are pure functions: `iconName({ size, class, bg, bgColor }) => svgString`.
|
|
63
|
+
|
|
64
|
+
Props: `size` (px, default 16), `class`, `bg` ('circle' or 'square'), `bgColor` (accent/success/warning/error/muted).
|
|
65
|
+
|
|
66
|
+
```js
|
|
67
|
+
import { iconCheck, iconZap, iconShield, iconTrash } from '@invisibleloop/pulse/ui'
|
|
68
|
+
|
|
69
|
+
feature({ icon: iconZap({ size: 20 }) })
|
|
70
|
+
button({ label: 'Delete', variant: 'danger', icon: iconTrash({ size: 14 }) })
|
|
71
|
+
iconZap({ size: 20, bg: 'circle', bgColor: 'accent' })
|
|
72
|
+
iconCheck({ size: 20, bg: 'circle', bgColor: 'success' })
|
|
73
|
+
```
|
|
74
|
+
|
|
75
|
+
Available icons:
|
|
76
|
+
- **Navigation:** iconArrowLeft/Right/Up/Down, iconChevronLeft/Right/Up/Down, iconExternalLink, iconMenu, iconX, iconMoreHorizontal, iconMoreVertical
|
|
77
|
+
- **Hand Pointers:** iconHandPointUp/Down/Left/Right
|
|
78
|
+
- **Status:** iconCheck, iconCheckCircle, iconXCircle, iconAlertCircle, iconAlertTriangle, iconInfo
|
|
79
|
+
- **Actions:** iconPlus, iconMinus, iconEdit, iconTrash, iconCopy, iconSearch, iconFilter, iconDownload, iconUpload, iconRefresh, iconSend
|
|
80
|
+
- **UI Controls:** iconEye, iconEyeOff, iconLock, iconUnlock, iconSettings, iconBell
|
|
81
|
+
- **People:** iconUser, iconUsers, iconMail, iconMessageSquare
|
|
82
|
+
- **Pages:** iconHome, iconLogOut, iconLogIn
|
|
83
|
+
- **Content:** iconFile, iconImage, iconLink, iconCode, iconCalendar, iconClock, iconBookmark, iconTag
|
|
84
|
+
- **Media:** iconPlay, iconPause, iconVolume, iconStar, iconHeart
|
|
85
|
+
- **Misc:** iconGlobe, iconShield, iconZap, iconTrendingUp, iconTrendingDown, iconLoader, iconGrid, iconPhone, iconGamepad, iconBug
|
|
86
|
+
|
|
87
|
+
**Never use emoji in UI output.** Use icons instead.
|
|
88
|
+
|
|
89
|
+
To add a missing icon: copy a Lucide path (MIT) into `src/ui/icons.js` using `s(paths, opts(o))` for stroke or `f(paths, opts(o))` for fill, export from `src/ui/index.js`.
|
|
90
|
+
|
|
91
|
+
### Form components
|
|
92
|
+
|
|
93
|
+
| Component | Key props |
|
|
94
|
+
|-----------|-----------|
|
|
95
|
+
| `button` | `label`, `variant` (primary/secondary/ghost/danger), `size` (sm/md/lg), `href`, `type`, `disabled`, `fullWidth`, `icon`, `iconAfter`, `attrs` |
|
|
96
|
+
| `input` | `name`, `label`, `type`, `placeholder`, `value`, `error`, `hint`, `required`, `disabled`, `attrs` |
|
|
97
|
+
| `search` | `name`, `label`, `labelHidden`, `placeholder`, `value`, `event` (e.g. `'input:setSearch'`), `debounce` (ms, default 200), `clearEvent` (shown when value non-empty), `disabled`, `attrs` — search input with icon + clear button. **Use this instead of `input({ type: 'search' })`.** |
|
|
98
|
+
| `select` | `name`, `label`, `options` (strings or `{value,label}`), `value`, `error`, `required`, `event` |
|
|
99
|
+
| `textarea` | `name`, `label`, `placeholder`, `value`, `rows`, `error`, `hint`, `required`, `disabled`, `attrs` |
|
|
100
|
+
| `fieldset` | `legend`, `content` (HTML slot), `gap` (xs/sm/md/lg) |
|
|
101
|
+
| `toggle` | `name`, `label`, `checked`, `disabled`, `hint`, `id`, `event` — iOS-style switch; reads as `'on'` in FormData |
|
|
102
|
+
| `fileUpload` | `name`, `label`, `hint`, `error`, `accept`, `multiple`, `required`, `disabled`, `event` — drag-and-drop zone. **Never use `input({ type: 'file' })` for file uploads — always use `fileUpload()`.** |
|
|
103
|
+
| `slider` | `name`, `label`, `min`, `max`, `step`, `value`, `disabled`, `hint`, `event` — use `change:mutationName` not `input` |
|
|
104
|
+
| `segmented` | `name`, `options` ([{value,label}]), `value`, `size`, `disabled`, `event` — iOS-style segmented control. `value` must match an `options[].value` entry exactly — ensure `state` is initialised with a default that matches one of the option values. |
|
|
105
|
+
| `radio` | `name`, `value`, `label`, `checked`, `disabled`, `id`, `event` |
|
|
106
|
+
| `radioGroup` | `name`, `legend`, `options` ([{value,label,hint?,disabled?}]), `value`, `hint`, `error`, `gap`, `event` |
|
|
107
|
+
| `rating` | `value` (0–max, 0.5 steps), `max` (5), `name` (enables interactive mode), `label`, `size`, `disabled` |
|
|
108
|
+
|
|
109
|
+
### Display components
|
|
110
|
+
|
|
111
|
+
| Component | Key props |
|
|
112
|
+
|-----------|-----------|
|
|
113
|
+
| `card` | `title`, `level` (1–6, default 3 — heading tag for title; visual style unchanged), `content` (HTML slot), `footer` (HTML slot), `flush` (full-bleed — removes body padding) |
|
|
114
|
+
| `alert` | `variant` (info/success/warning/error), `title`, `content` |
|
|
115
|
+
| `badge` | `label`, `variant` (default/success/warning/error/info) |
|
|
116
|
+
| `stat` | `label`, `value`, `change`, `trend` (up/down/neutral), `center` |
|
|
117
|
+
| `avatar` | `src`, `alt`, `size` (sm/md/lg/xl), `initials` |
|
|
118
|
+
| `empty` | `title`, `description`, `action` ({label,href,variant}) |
|
|
119
|
+
| `table` | `headers`, `rows` (2D array of HTML strings), `caption` |
|
|
120
|
+
| `spinner` | `size` (sm/md/lg), `color` (accent/muted/white), `label` |
|
|
121
|
+
| `progress` | `value`, `max` (100), `label`, `showLabel`, `showValue`, `variant`, `size` |
|
|
122
|
+
| `breadcrumbs` | `items` ([{label,href}] — last item has no href), `separator` |
|
|
123
|
+
| `stepper` | `steps` (array of label strings), `current` (0-based) |
|
|
124
|
+
| `uiImage` | `src`, `alt`, `caption`, `ratio`, `rounded`, `pill`, `width`, `height`, `maxWidth` |
|
|
125
|
+
| `pullquote` | `quote`, `cite`, `size` (md/lg) |
|
|
126
|
+
| `timeline` | `direction` (vertical/horizontal), `items` ([{dot,dotColor,label,content}]) |
|
|
127
|
+
| `timelineItem` | `content`, `label`, `dot`, `dotColor` (accent/success/warning/error/muted) |
|
|
128
|
+
|
|
129
|
+
### Typography components
|
|
130
|
+
|
|
131
|
+
| Component | Key props |
|
|
132
|
+
|-----------|-----------|
|
|
133
|
+
| `heading` | `level` (1–6), `text`, `size` (xs–4xl, overrides level default), `color` (default/muted/accent), `balance` (true — prevents orphaned words) |
|
|
134
|
+
| `list` | `items` (array of HTML strings), `ordered`, `gap` (xs/sm/md) |
|
|
135
|
+
| `prose` | `content` (raw HTML — not escaped), `size` (sm/base/lg) — for CMS/markdown content you don't control |
|
|
136
|
+
|
|
137
|
+
**Never write raw `<h1>`–`<h6>` or `<ul>`/`<ol>` without styling.** Use `heading()` and `list()` instead.
|
|
138
|
+
|
|
139
|
+
**Heading order across components.** `card`, `feature`, `section`, `pricing`, `cta`, and `modal` all accept a `level` prop. Use it to keep the document outline correct without changing visual style. For example, if a page has an h2 section title and each card inside it has a title, pass `level: 3` on the cards (the default) — but if the cards are the first heading on the page, pass `level: 2`. The CSS class stays the same (`ui-card-title` etc.) so appearance is unaffected.
|
|
140
|
+
|
|
141
|
+
**`balance: true`** on `heading()` prevents orphaned words. Apply to any heading the verification orphan check flags.
|
|
142
|
+
|
|
143
|
+
Use `prose()` for HTML from external sources (CMS, markdown). Use `heading()`/`list()` when writing content yourself.
|
|
144
|
+
|
|
145
|
+
### Landing page components
|
|
146
|
+
|
|
147
|
+
| Component | Key props |
|
|
148
|
+
|-----------|-----------|
|
|
149
|
+
| `nav` | `logo` (HTML slot), `logoHref`, `links` ([{label,href}]), `action` (HTML slot), `sticky` |
|
|
150
|
+
| `hero` | `eyebrow`, `title`, `subtitle`, `actions` (HTML slot), `align` (center/left), `size` (md/sm) |
|
|
151
|
+
| `feature` | `icon` (HTML slot), `title`, `level` (1–6, default 3), `description`, `center` |
|
|
152
|
+
| `testimonial` | `quote`, `name`, `role`, `src`, `rating` (1–5) |
|
|
153
|
+
| `pricing` | `name`, `level` (1–6, default 3), `price`, `period`, `features` ([strings]), `action` (HTML slot), `highlighted` |
|
|
154
|
+
| `accordion` | `items` ([{title,content}]) — native `<details>`, no JS |
|
|
155
|
+
| `appBadge` | `store` (apple/google), `href` |
|
|
156
|
+
| `cta` | `eyebrow`, `title`, `level` (1–6, default 2), `subtitle`, `actions` (HTML slot), `align` |
|
|
157
|
+
|
|
158
|
+
### Layout components
|
|
159
|
+
|
|
160
|
+
| Component | Key props |
|
|
161
|
+
|-----------|-----------|
|
|
162
|
+
| `container` | `content`, `size` (sm/md/lg/xl) |
|
|
163
|
+
| `section` | `content`, `variant` (default/alt/dark), `padding` (sm/md/lg), `id`, `eyebrow`, `title`, `level` (1–6, default 2), `subtitle`, `align` |
|
|
164
|
+
| `grid` | `content`, `cols` (1–4), `gap` (sm/md/lg) — responsive, collapses on mobile |
|
|
165
|
+
| `stack` | `content`, `gap` (xs–xl), `align` — flex column |
|
|
166
|
+
| `cluster` | `content`, `gap`, `justify`, `align` — flex row with wrap |
|
|
167
|
+
| `divider` | `label` |
|
|
168
|
+
| `banner` | `content`, `variant` |
|
|
169
|
+
| `media` | `image` (HTML slot), `content` (HTML slot), `reverse` — two-column image + text |
|
|
170
|
+
| `codeWindow` | `content` (highlighted code HTML), `filename`, `lang` |
|
|
171
|
+
| `footer` | `logo`, `logoHref`, `links`, `legal` |
|
|
172
|
+
|
|
173
|
+
### Modal / dialog
|
|
174
|
+
|
|
175
|
+
Use `modal()` + `modalTrigger()` (or `data-dialog-open`). **Never use `state.modalOpen` or conditional rendering** — this destroys the dialog element on every render, breaking focus, animation, and native ESC.
|
|
176
|
+
|
|
177
|
+
```js
|
|
178
|
+
import { modal, modalTrigger, button } from '@invisibleloop/pulse/ui'
|
|
179
|
+
|
|
180
|
+
view: (state) => `
|
|
181
|
+
<main id="main-content">
|
|
182
|
+
<!-- Always in the DOM — never conditional -->
|
|
183
|
+
${modal({
|
|
184
|
+
id: 'confirm',
|
|
185
|
+
title: 'Delete item?',
|
|
186
|
+
content: '<p>This cannot be undone.</p>',
|
|
187
|
+
footer: button({ label: 'Delete', variant: 'danger', attrs: { 'data-dialog-close': '' } }),
|
|
188
|
+
})}
|
|
189
|
+
|
|
190
|
+
<!-- Opens the dialog — no spec state, no mutation needed -->
|
|
191
|
+
${modalTrigger({ target: 'confirm', label: 'Delete', variant: 'ghost' })}
|
|
192
|
+
|
|
193
|
+
<!-- Or inline with any element -->
|
|
194
|
+
<button data-dialog-open="confirm">Delete</button>
|
|
195
|
+
</main>
|
|
196
|
+
`
|
|
197
|
+
```
|
|
198
|
+
|
|
199
|
+
**How close works (all native — no JS needed):**
|
|
200
|
+
- `<form method="dialog">` submit inside the modal (the X button uses this)
|
|
201
|
+
- ESC key
|
|
202
|
+
- Clicking the backdrop (outside the dialog content)
|
|
203
|
+
- `data-dialog-close` attribute on any element inside or outside the dialog
|
|
204
|
+
|
|
205
|
+
**`modal()` props:** `id` (required), `title`, `content`, `footer`, `size` (sm/md/lg/xl), `class`
|
|
206
|
+
**`modalTrigger()` props:** `target` (dialog id), `label`, `variant`, `size`, `class`
|
|
207
|
+
|
|
208
|
+
### Forms inside a modal
|
|
209
|
+
|
|
210
|
+
**`modal()` wraps all content in `<form method="dialog">`. You cannot nest a `<form data-action="...">` inside it — browsers silently ignore nested forms, so the action will never fire.**
|
|
211
|
+
|
|
212
|
+
When the modal action requires a Pulse action (e.g. confirm delete, submit settings), use the `form` HTML attribute to associate a button with an external form:
|
|
213
|
+
|
|
214
|
+
```js
|
|
215
|
+
view: (state) => `
|
|
216
|
+
<main id="main-content">
|
|
217
|
+
<!-- The action form lives OUTSIDE the modal -->
|
|
218
|
+
<form id="delete-form" data-action="deleteAccount" style="display:none"></form>
|
|
219
|
+
|
|
220
|
+
${modal({
|
|
221
|
+
id: 'confirm-delete',
|
|
222
|
+
title: 'Delete account?',
|
|
223
|
+
content: '<p>This cannot be undone.</p>',
|
|
224
|
+
footer:
|
|
225
|
+
button({ label: 'Cancel', variant: 'secondary', type: 'submit' }) +
|
|
226
|
+
button({ label: 'Confirm delete', variant: 'danger', attrs: { form: 'delete-form', type: 'submit' } }),
|
|
227
|
+
})}
|
|
228
|
+
|
|
229
|
+
${modalTrigger({ target: 'confirm-delete', label: 'Delete account', variant: 'danger' })}
|
|
230
|
+
</main>
|
|
231
|
+
`
|
|
232
|
+
```
|
|
233
|
+
|
|
234
|
+
- **Cancel** — `type="submit"` with no `form` attribute submits the modal's own `<form method="dialog">`, closing it natively.
|
|
235
|
+
- **Confirm** — `form="delete-form"` associates the button with the external form, triggering the Pulse action.
|
|
236
|
+
- The hidden form needs no visible fields; `onStart` can read `state` directly for any data needed by the action.
|
|
237
|
+
|
|
238
|
+
### Attaching Pulse events to components
|
|
239
|
+
|
|
240
|
+
Pass events via `attrs` — must be an object:
|
|
241
|
+
```js
|
|
242
|
+
button({ label: 'Like', attrs: { 'data-event': 'like' } })
|
|
243
|
+
button({ label: 'Delete', attrs: { 'data-event': 'remove', 'data-id': item.id } })
|
|
244
|
+
```
|
|
245
|
+
|
|
246
|
+
### Typical landing page structure
|
|
247
|
+
|
|
248
|
+
```js
|
|
249
|
+
import { nav, hero, section, container, grid, feature, button } from '@invisibleloop/pulse/ui'
|
|
250
|
+
|
|
251
|
+
view: () => `
|
|
252
|
+
${nav({
|
|
253
|
+
logo: 'MyApp',
|
|
254
|
+
links: [{ label: 'Docs', href: '/docs' }, { label: 'Pricing', href: '/pricing' }],
|
|
255
|
+
action: button({ label: 'Sign up', href: '/signup', variant: 'primary', size: 'sm' }),
|
|
256
|
+
})}
|
|
257
|
+
<main id="main-content">
|
|
258
|
+
${hero({
|
|
259
|
+
eyebrow: 'Now in beta',
|
|
260
|
+
title: 'Build fast. Ship faster.',
|
|
261
|
+
subtitle: 'The framework that gets out of your way.',
|
|
262
|
+
actions: button({ label: 'Get started →', href: '/docs', variant: 'primary', size: 'lg' }),
|
|
263
|
+
})}
|
|
264
|
+
${section({ content: container({ content: grid({
|
|
265
|
+
cols: 3,
|
|
266
|
+
content: [
|
|
267
|
+
feature({ icon: iconZap({ size: 20, bg: 'circle' }), title: 'Fast', description: 'SSR always on.' }),
|
|
268
|
+
feature({ icon: iconShield({ size: 20, bg: 'circle' }), title: 'Safe', description: 'Constraints enforced.' }),
|
|
269
|
+
feature({ icon: iconCheck({ size: 20, bg: 'circle' }), title: 'Correct', description: '100 Lighthouse.' }),
|
|
270
|
+
].join(''),
|
|
271
|
+
}) }) })}
|
|
272
|
+
</main>
|
|
273
|
+
`
|
|
274
|
+
```
|
|
@@ -0,0 +1,54 @@
|
|
|
1
|
+
## Example page — contact form
|
|
2
|
+
|
|
3
|
+
```js
|
|
4
|
+
import { nav, section, input, textarea, button, alert, container } from '@invisibleloop/pulse/ui'
|
|
5
|
+
import { layout } from '../components/layout.js'
|
|
6
|
+
|
|
7
|
+
export default {
|
|
8
|
+
meta: {
|
|
9
|
+
title: 'Contact',
|
|
10
|
+
styles: ['/app.css', '/pulse-ui.css'],
|
|
11
|
+
},
|
|
12
|
+
state: {
|
|
13
|
+
status: 'idle',
|
|
14
|
+
errors: [],
|
|
15
|
+
},
|
|
16
|
+
actions: {
|
|
17
|
+
send: {
|
|
18
|
+
onStart: (state, formData) => ({
|
|
19
|
+
status: 'loading',
|
|
20
|
+
name: formData.get('name'),
|
|
21
|
+
email: formData.get('email'),
|
|
22
|
+
message: formData.get('message'),
|
|
23
|
+
}),
|
|
24
|
+
run: async (state, serverState, formData) => {
|
|
25
|
+
const res = await fetch('/api/contact', { method: 'POST', body: formData })
|
|
26
|
+
if (!res.ok) throw new Error(await res.text())
|
|
27
|
+
return await res.json()
|
|
28
|
+
},
|
|
29
|
+
onSuccess: (state, result) => ({ status: 'success' }),
|
|
30
|
+
onError: (state, err) => ({
|
|
31
|
+
status: 'error',
|
|
32
|
+
errors: err?.validation ?? [{ message: err.message }],
|
|
33
|
+
}),
|
|
34
|
+
},
|
|
35
|
+
},
|
|
36
|
+
view: (state) => layout(`
|
|
37
|
+
${container({ content: `
|
|
38
|
+
${section({ title: 'Contact Us', subtitle: 'We will get back to you within 24 hours.' })}
|
|
39
|
+
${state.status === 'success'
|
|
40
|
+
? alert({ type: 'success', message: 'Message sent!' })
|
|
41
|
+
: `<form data-action="send" class="u-flex u-flex-col u-gap-4">
|
|
42
|
+
${grid({ cols: 2, gap: 'md', content: `
|
|
43
|
+
${input({ name: 'name', label: 'Name', placeholder: 'Your name', required: true })}
|
|
44
|
+
${input({ name: 'email', label: 'Email', type: 'email', placeholder: 'you@example.com', required: true })}
|
|
45
|
+
` })}
|
|
46
|
+
${textarea({ name: 'message', label: 'Message', rows: 5, required: true })}
|
|
47
|
+
${state.errors.length ? alert({ type: 'error', message: state.errors[0].message }) : ''}
|
|
48
|
+
${button({ label: state.status === 'loading' ? 'Sending…' : 'Send', type: 'submit', variant: 'primary', fullWidth: true })}
|
|
49
|
+
</form>`
|
|
50
|
+
}
|
|
51
|
+
` })}
|
|
52
|
+
`),
|
|
53
|
+
}
|
|
54
|
+
```
|
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
## Site navigation
|
|
2
|
+
|
|
3
|
+
Projects define navigation in src/components/layout.js via a NAV_LINKS array.
|
|
4
|
+
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.
|
|
5
|
+
|
|
6
|
+
```js
|
|
7
|
+
const NAV_LINKS = [
|
|
8
|
+
{ label: 'Home', href: '/' },
|
|
9
|
+
{ label: 'About', href: '/about' }, // ← add new pages here
|
|
10
|
+
]
|
|
11
|
+
```
|
|
12
|
+
|
|
13
|
+
## Page discovery — no registration needed
|
|
14
|
+
|
|
15
|
+
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.
|
|
16
|
+
|
|
17
|
+
File → derived route (used only when the spec has no route property):
|
|
18
|
+
src/pages/about.js → /about
|
|
19
|
+
src/pages/blog.js → /blog
|
|
20
|
+
src/pages/blog/index.js → /blog
|
|
21
|
+
src/pages/home.js or index.js → /
|
|
22
|
+
|
|
23
|
+
## Dynamic routes
|
|
24
|
+
|
|
25
|
+
Use :param syntax in the spec's route property. The filename doesn't matter — name it [slug].js or post.js, it makes no difference. The route property controls matching.
|
|
26
|
+
|
|
27
|
+
route: '/blog/:slug' ← param captured as ctx.params.slug
|
|
28
|
+
|
|
29
|
+
The param is available in server fetchers and meta functions:
|
|
30
|
+
server: { post: async (ctx) => posts.find(p => p.slug === ctx.params.slug) ?? null }
|
|
31
|
+
meta: { title: (ctx) => posts[ctx.params.slug]?.title ?? 'Not Found' }
|
|
32
|
+
|
|
33
|
+
Convention: name dynamic-route files [param].js inside a subfolder:
|
|
34
|
+
src/pages/blog/[slug].js with route: '/blog/:slug'
|
|
35
|
+
|
|
36
|
+
This is purely a human readability convention. Pulse does not process [ ] in filenames.
|
|
@@ -0,0 +1,258 @@
|
|
|
1
|
+
## Cross-page state and persistence
|
|
2
|
+
|
|
3
|
+
### Per-page persistence — `spec.persist`
|
|
4
|
+
|
|
5
|
+
`spec.persist` is an array of state key names automatically saved to `localStorage` after every mutation/action, and restored on mount. Storage key is `pulse:/route-path` (scoped per route).
|
|
6
|
+
|
|
7
|
+
```js
|
|
8
|
+
export default {
|
|
9
|
+
route: '/cart',
|
|
10
|
+
state: { items: [], count: 0 },
|
|
11
|
+
persist: ['items', 'count'], // survive page refresh
|
|
12
|
+
}
|
|
13
|
+
```
|
|
14
|
+
|
|
15
|
+
Only declared keys are saved. Restored values that differ from the spec default trigger a re-render on mount (even after SSR).
|
|
16
|
+
|
|
17
|
+
### Global store — `pulse.store.js`
|
|
18
|
+
|
|
19
|
+
The global store is a shared data layer. Server fetchers run per request; mutations run on the client and broadcast to all subscribed pages without a server round-trip.
|
|
20
|
+
|
|
21
|
+
**Define the store** in `pulse.store.js`:
|
|
22
|
+
```js
|
|
23
|
+
// pulse.store.js
|
|
24
|
+
export default {
|
|
25
|
+
hydrate: '/pulse.store.js', // browser-importable path — required for mutations
|
|
26
|
+
|
|
27
|
+
state: { // default/fallback values
|
|
28
|
+
user: null,
|
|
29
|
+
settings: { theme: 'dark', lang: 'en' },
|
|
30
|
+
cart: { count: 0 },
|
|
31
|
+
},
|
|
32
|
+
server: { // async fetchers — run per request, server-side only
|
|
33
|
+
user: async (ctx) => db.users.findByCookie(ctx.cookies.session),
|
|
34
|
+
settings: async (ctx) => db.settings.forUser(ctx.cookies.userId),
|
|
35
|
+
},
|
|
36
|
+
mutations: { // synchronous client-side updates — broadcast to all subscribers
|
|
37
|
+
// Same contract as spec.mutations: (storeState, payload?) => Partial<storeState>
|
|
38
|
+
toggleTheme: (store) => ({
|
|
39
|
+
settings: { ...store.settings, theme: store.settings.theme === 'dark' ? 'light' : 'dark' },
|
|
40
|
+
}),
|
|
41
|
+
addToCart: (store, e) => ({
|
|
42
|
+
cart: { count: store.cart.count + 1 },
|
|
43
|
+
}),
|
|
44
|
+
},
|
|
45
|
+
}
|
|
46
|
+
```
|
|
47
|
+
|
|
48
|
+
**Register the store** in your server file:
|
|
49
|
+
```js
|
|
50
|
+
import store from './pulse.store.js'
|
|
51
|
+
createServer([...specs], { store })
|
|
52
|
+
```
|
|
53
|
+
|
|
54
|
+
**Use store data in a page** — declare `spec.store` with the keys needed. They appear in the view's `server` argument:
|
|
55
|
+
```js
|
|
56
|
+
export default {
|
|
57
|
+
route: '/dashboard',
|
|
58
|
+
store: ['user', 'settings'], // declare which store keys this page uses
|
|
59
|
+
server: {
|
|
60
|
+
stats: async (ctx) => db.stats.forUser(ctx.store.user?.id), // ctx.store available here
|
|
61
|
+
},
|
|
62
|
+
state: {},
|
|
63
|
+
view: (state, server) => `
|
|
64
|
+
<h1>Hello, ${server.user?.name}</h1>
|
|
65
|
+
<p>Theme: ${server.settings.theme}</p>
|
|
66
|
+
<p>Requests: ${server.stats.total}</p>
|
|
67
|
+
`,
|
|
68
|
+
}
|
|
69
|
+
```
|
|
70
|
+
|
|
71
|
+
**Dispatch store mutations from any page** using `data-store-event` (same format as `data-event`):
|
|
72
|
+
```html
|
|
73
|
+
<button data-store-event="toggleTheme">Toggle theme</button>
|
|
74
|
+
<button data-store-event="addToCart">Add to cart</button>
|
|
75
|
+
<select data-store-event="change:setLang">...</select>
|
|
76
|
+
```
|
|
77
|
+
|
|
78
|
+
All pages that declare `spec.store` with the affected keys re-render automatically — no server round-trip, no page reload.
|
|
79
|
+
|
|
80
|
+
- Store fetchers run **before** page server fetchers and guards — `ctx.store` is available in all of them
|
|
81
|
+
- Only keys listed in `spec.store` are passed to the view — nothing leaks to pages that don't declare it
|
|
82
|
+
- Page-level `server` keys win over store keys if there is a name collision
|
|
83
|
+
- Pages with `spec.store` are never HTML-cached (same rule as pages with `spec.server`)
|
|
84
|
+
|
|
85
|
+
**Reactive updates — no refresh needed**
|
|
86
|
+
|
|
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:
|
|
88
|
+
```js
|
|
89
|
+
actions: {
|
|
90
|
+
saveTheme: {
|
|
91
|
+
run: async (state, server, payload) => {
|
|
92
|
+
await fetch('/api/settings', { method: 'PATCH', body: payload })
|
|
93
|
+
return payload.get('theme')
|
|
94
|
+
},
|
|
95
|
+
onSuccess: (state, theme) => ({
|
|
96
|
+
saved: true,
|
|
97
|
+
_storeUpdate: { settings: { theme } }, // ← broadcast to all subscribed pages
|
|
98
|
+
}),
|
|
99
|
+
onError: (state, err) => ({ error: err.message }),
|
|
100
|
+
},
|
|
101
|
+
},
|
|
102
|
+
```
|
|
103
|
+
`_storeUpdate` is stripped from local page state — it is only forwarded to the store. The rest of `onSuccess` merges into the page's own state as normal.
|
|
104
|
+
|
|
105
|
+
## Server context — redirects, cookies, POST bodies
|
|
106
|
+
|
|
107
|
+
The `ctx` object is available in `guard`, `server.*` fetchers, and `meta` functions.
|
|
108
|
+
|
|
109
|
+
### Reading cookies
|
|
110
|
+
`ctx.cookies` — plain object parsed from the request `Cookie` header:
|
|
111
|
+
```js
|
|
112
|
+
server: { user: async (ctx) => getUserByToken(ctx.cookies.session) }
|
|
113
|
+
```
|
|
114
|
+
|
|
115
|
+
### Redirects — use `guard`
|
|
116
|
+
Return `{ redirect: '/path' }` from `guard` to redirect (302) before any data fetching.
|
|
117
|
+
There is **no redirect mechanism from `server.*` fetchers** — use `guard` for that.
|
|
118
|
+
```js
|
|
119
|
+
guard: async (ctx) => {
|
|
120
|
+
if (!ctx.cookies.session) return { redirect: '/login' }
|
|
121
|
+
}
|
|
122
|
+
```
|
|
123
|
+
|
|
124
|
+
### Setting cookies and headers
|
|
125
|
+
`ctx.setCookie(name, value, opts)` — queues a `Set-Cookie` header. Options: `httpOnly`, `secure`, `path`, `maxAge`, `sameSite`, `domain`. Defaults: `Path=/`, `SameSite=Lax`.
|
|
126
|
+
`ctx.setHeader(name, value)` — queues any arbitrary response header.
|
|
127
|
+
|
|
128
|
+
### Reading the request body
|
|
129
|
+
|
|
130
|
+
Body parsing is available in `guard`, `server.*` fetchers, and `render` (raw specs). All methods are lazy — the body stream is only consumed once and the result is memoised for the lifetime of the request.
|
|
131
|
+
|
|
132
|
+
```js
|
|
133
|
+
await ctx.json() // parse JSON body → object | null
|
|
134
|
+
await ctx.text() // raw string body → string
|
|
135
|
+
await ctx.formData() // URL-encoded body → plain object | null
|
|
136
|
+
await ctx.buffer() // raw Buffer
|
|
137
|
+
```
|
|
138
|
+
|
|
139
|
+
Bodies larger than `maxBody` (default 1 MB, configurable in `createServer`) are rejected with a 413 response before the handler runs.
|
|
140
|
+
|
|
141
|
+
**Page specs only accept GET/HEAD by default** (POST → 405). To handle POST on a page spec, opt in with `spec.methods`:
|
|
142
|
+
|
|
143
|
+
```js
|
|
144
|
+
export default {
|
|
145
|
+
route: '/contact',
|
|
146
|
+
methods: ['GET', 'POST'],
|
|
147
|
+
|
|
148
|
+
guard: async (ctx) => {
|
|
149
|
+
if (ctx.method === 'POST') {
|
|
150
|
+
const data = await ctx.formData()
|
|
151
|
+
if (!data.email) return { redirect: '/contact?error=required' }
|
|
152
|
+
await db.leads.create(data)
|
|
153
|
+
// Flash cookie — consumed once, clears on next GET (see server fetcher below)
|
|
154
|
+
ctx.setCookie('flash_sent', '1', { maxAge: 30 })
|
|
155
|
+
return { redirect: '/contact' }
|
|
156
|
+
}
|
|
157
|
+
},
|
|
158
|
+
|
|
159
|
+
server: {
|
|
160
|
+
status: async (ctx) => {
|
|
161
|
+
const sent = ctx.cookies.flash_sent === '1'
|
|
162
|
+
if (sent) ctx.setCookie('flash_sent', '', { maxAge: 0 }) // clear immediately
|
|
163
|
+
return { sent, error: ctx.query?.error ?? null }
|
|
164
|
+
},
|
|
165
|
+
},
|
|
166
|
+
|
|
167
|
+
state: {},
|
|
168
|
+
view: (_state, server) => `
|
|
169
|
+
${server.status.sent
|
|
170
|
+
? '<p>Message sent!</p>'
|
|
171
|
+
: '<form method="POST">...</form>'
|
|
172
|
+
}
|
|
173
|
+
`,
|
|
174
|
+
}
|
|
175
|
+
```
|
|
176
|
+
|
|
177
|
+
### Post-Redirect-Get (PRG) and success messages
|
|
178
|
+
|
|
179
|
+
**Never use `?success=1` query params for success state.** They persist in the URL — refreshing re-shows the message, sharing the URL gives a false success to someone else, and it looks wrong.
|
|
180
|
+
|
|
181
|
+
**Use a flash cookie instead:**
|
|
182
|
+
|
|
183
|
+
1. After the POST succeeds: `ctx.setCookie('flash_sent', '1', { maxAge: 30 })` then redirect to the clean URL.
|
|
184
|
+
2. In the `server.*` fetcher on the subsequent GET: read the cookie, then clear it immediately with `maxAge: 0`.
|
|
185
|
+
3. Pass the flag to the view. On refresh the cookie is gone — the form renders normally.
|
|
186
|
+
|
|
187
|
+
Error states (validation failures) are fine as query params (`?error=required`) because they are expected to persist until the user corrects the form.
|
|
188
|
+
|
|
189
|
+
**Guard can return a custom HTTP response** (instead of a redirect) by returning `{ status, json?, body?, headers? }`:
|
|
190
|
+
|
|
191
|
+
```js
|
|
192
|
+
guard: async (ctx) => {
|
|
193
|
+
const token = ctx.headers.authorization
|
|
194
|
+
if (!token) return { status: 401, json: { error: 'Unauthorized' } }
|
|
195
|
+
// returning nothing lets the request proceed
|
|
196
|
+
}
|
|
197
|
+
```
|
|
198
|
+
|
|
199
|
+
**Raw response specs** (`contentType` set) accept any HTTP method by default — use `ctx.method` and `await ctx.json()` / `ctx.text()` to build webhooks or JSON APIs:
|
|
200
|
+
|
|
201
|
+
```js
|
|
202
|
+
export default {
|
|
203
|
+
route: '/api/hook',
|
|
204
|
+
contentType: 'application/json',
|
|
205
|
+
render: async (ctx) => {
|
|
206
|
+
const payload = await ctx.json()
|
|
207
|
+
await processWebhook(payload)
|
|
208
|
+
return JSON.stringify({ ok: true })
|
|
209
|
+
},
|
|
210
|
+
}
|
|
211
|
+
```
|
|
212
|
+
|
|
213
|
+
## Health check endpoint
|
|
214
|
+
|
|
215
|
+
Pulse exposes a built-in health check at `/healthz` by default. It responds **before** `onRequest`, static files, and route matching — so load balancers always get a response.
|
|
216
|
+
|
|
217
|
+
```
|
|
218
|
+
GET /healthz → 200 OK
|
|
219
|
+
{ "status": "ok", "uptime": 42.3 }
|
|
220
|
+
```
|
|
221
|
+
|
|
222
|
+
Configure the path or disable it:
|
|
223
|
+
|
|
224
|
+
```js
|
|
225
|
+
createServer(specs, {
|
|
226
|
+
healthCheck: '/ping', // custom path
|
|
227
|
+
// healthCheck: false, // disable
|
|
228
|
+
})
|
|
229
|
+
```
|
|
230
|
+
|
|
231
|
+
Key properties:
|
|
232
|
+
- Bypasses `onRequest` — a faulty hook can't accidentally block health checks
|
|
233
|
+
- `HEAD /healthz` is supported (no body)
|
|
234
|
+
- `Cache-Control: no-store` — proxies never serve a stale health status
|
|
235
|
+
- Fires before route matching — a user spec at `/healthz` is shadowed when the built-in is enabled
|
|
236
|
+
|
|
237
|
+
## Graceful shutdown
|
|
238
|
+
|
|
239
|
+
`createServer` registers `SIGTERM` and `SIGINT` handlers automatically. When either signal arrives:
|
|
240
|
+
|
|
241
|
+
1. `server.close()` stops accepting new connections
|
|
242
|
+
2. Idle keep-alive sockets are destroyed immediately
|
|
243
|
+
3. In-flight requests are allowed to finish naturally
|
|
244
|
+
4. A force-exit fires after `shutdownTimeout` ms (default 30 000 ms) to prevent a stuck request from blocking a deploy
|
|
245
|
+
|
|
246
|
+
The `shutdown()` function is also returned from `createServer` so you can trigger it programmatically (useful in tests or custom process managers):
|
|
247
|
+
|
|
248
|
+
```js
|
|
249
|
+
const { server, shutdown } = createServer(specs, {
|
|
250
|
+
port: 3000,
|
|
251
|
+
shutdownTimeout: 10000, // override default 30 s grace period
|
|
252
|
+
})
|
|
253
|
+
|
|
254
|
+
// Call manually when needed (SIGTERM is already wired up automatically)
|
|
255
|
+
shutdown()
|
|
256
|
+
```
|
|
257
|
+
|
|
258
|
+
`shutdown()` is idempotent — calling it multiple times is safe.
|