@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,979 @@
|
|
|
1
|
+
Pulse is a spec-first frontend framework. Pages are JS files that export a default spec object.
|
|
2
|
+
|
|
3
|
+
## Spec structure
|
|
4
|
+
|
|
5
|
+
```js
|
|
6
|
+
export default {
|
|
7
|
+
meta: { title, description, styles: ['/app.css'] },
|
|
8
|
+
state: { /* initial state */ },
|
|
9
|
+
mutations: {
|
|
10
|
+
// Synchronous. Return partial state. First arg is state, second is the DOM event.
|
|
11
|
+
setName: (state, e) => ({ name: e.target.value }),
|
|
12
|
+
},
|
|
13
|
+
actions: {
|
|
14
|
+
// Async lifecycle. <form data-action="actionName"> passes FormData as payload.
|
|
15
|
+
submit: {
|
|
16
|
+
onStart: (state, formData) => ({ status: 'loading' }), // optional — runs before run()
|
|
17
|
+
run: async (state, serverState, formData) => { // required — return value passed to onSuccess
|
|
18
|
+
const name = formData.get('name')
|
|
19
|
+
return await fetch('/api', { method: 'POST', body: formData }).then(r => r.json())
|
|
20
|
+
},
|
|
21
|
+
onSuccess: (state, result) => ({ status: 'success' }), // required — result = return value of run()
|
|
22
|
+
onError: (state, error) => ({ status: 'error' }), // required — called if run() throws
|
|
23
|
+
},
|
|
24
|
+
},
|
|
25
|
+
server: {
|
|
26
|
+
// Resolved server-side before render. Passed to view() as second arg.
|
|
27
|
+
posts: async () => fetchPostsFromDb(),
|
|
28
|
+
},
|
|
29
|
+
view: (state, serverState) => `<h1>${state.title}</h1>`,
|
|
30
|
+
}
|
|
31
|
+
```
|
|
32
|
+
|
|
33
|
+
## Streaming SSR — shell + deferred segments
|
|
34
|
+
|
|
35
|
+
Use streaming when a page has slow data and you want the shell to paint instantly while slower content streams in.
|
|
36
|
+
|
|
37
|
+
To use streaming, the `view` must be an **object of named segment functions** (not a single function), and the spec must declare a `stream` field:
|
|
38
|
+
|
|
39
|
+
```js
|
|
40
|
+
export default {
|
|
41
|
+
route: '/',
|
|
42
|
+
state: {},
|
|
43
|
+
server: {
|
|
44
|
+
user: async (ctx) => getUser(ctx.cookies.session), // fast
|
|
45
|
+
items: async () => {
|
|
46
|
+
await new Promise(r => setTimeout(r, 1000)) // slow
|
|
47
|
+
return fetchItems()
|
|
48
|
+
},
|
|
49
|
+
},
|
|
50
|
+
stream: {
|
|
51
|
+
shell: ['shell'], // rendered and sent immediately
|
|
52
|
+
deferred: ['content'], // streamed in when server data resolves
|
|
53
|
+
},
|
|
54
|
+
view: {
|
|
55
|
+
shell: (state, server) => `<nav>My App</nav><h1>Welcome ${server.user?.name}</h1>`,
|
|
56
|
+
content: (state, server) => `<ul>${server.items.map(i => `<li>${i.title}</li>`).join('')}</ul>`,
|
|
57
|
+
},
|
|
58
|
+
}
|
|
59
|
+
```
|
|
60
|
+
|
|
61
|
+
**Key rules:**
|
|
62
|
+
- `view` must be an object of named functions — not a single function — when using `stream`
|
|
63
|
+
- Streaming splits the **view**, not data fetching. All `server.*` fetchers resolve before any segment renders. Use `Promise.all` inside a fetcher to parallelise slow calls
|
|
64
|
+
- Deferred segments render a `<pulse-deferred>` placeholder while loading — no client JS required
|
|
65
|
+
- Only specs with a `stream` field use chunked responses; all others use standard buffered SSR
|
|
66
|
+
|
|
67
|
+
## Cross-page state and persistence
|
|
68
|
+
|
|
69
|
+
### Per-page persistence — `spec.persist`
|
|
70
|
+
|
|
71
|
+
`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).
|
|
72
|
+
|
|
73
|
+
```js
|
|
74
|
+
export default {
|
|
75
|
+
route: '/cart',
|
|
76
|
+
state: { items: [], count: 0 },
|
|
77
|
+
persist: ['items', 'count'], // survive page refresh
|
|
78
|
+
}
|
|
79
|
+
```
|
|
80
|
+
|
|
81
|
+
Only declared keys are saved. Restored values that differ from the spec default trigger a re-render on mount (even after SSR).
|
|
82
|
+
|
|
83
|
+
### Global store — `pulse.store.js`
|
|
84
|
+
|
|
85
|
+
The global store is a single shared server-side data layer. Declare fetchers once — user profiles, settings, feature flags — and any page can access them by name. No repeated fetchers, no prop drilling.
|
|
86
|
+
|
|
87
|
+
**Define the store** in `pulse.store.js`:
|
|
88
|
+
```js
|
|
89
|
+
// pulse.store.js
|
|
90
|
+
export default {
|
|
91
|
+
state: { // default/fallback values
|
|
92
|
+
user: null,
|
|
93
|
+
settings: { theme: 'dark', lang: 'en' },
|
|
94
|
+
},
|
|
95
|
+
server: { // async fetchers — run per request
|
|
96
|
+
user: async (ctx) => db.users.findByCookie(ctx.cookies.session),
|
|
97
|
+
settings: async (ctx) => db.settings.forUser(ctx.cookies.userId),
|
|
98
|
+
},
|
|
99
|
+
}
|
|
100
|
+
```
|
|
101
|
+
|
|
102
|
+
**Register the store** in your server file:
|
|
103
|
+
```js
|
|
104
|
+
import store from './pulse.store.js'
|
|
105
|
+
createServer([...specs], { store })
|
|
106
|
+
```
|
|
107
|
+
|
|
108
|
+
**Use store data in a page** — declare `spec.store` with the keys needed. They appear in the view's `server` argument:
|
|
109
|
+
```js
|
|
110
|
+
export default {
|
|
111
|
+
route: '/dashboard',
|
|
112
|
+
store: ['user', 'settings'], // declare which store keys this page uses
|
|
113
|
+
server: {
|
|
114
|
+
stats: async (ctx) => db.stats.forUser(ctx.store.user?.id), // ctx.store available here
|
|
115
|
+
},
|
|
116
|
+
state: {},
|
|
117
|
+
view: (state, server) => `
|
|
118
|
+
<h1>Hello, ${server.user?.name}</h1>
|
|
119
|
+
<p>Theme: ${server.settings.theme}</p>
|
|
120
|
+
<p>Requests: ${server.stats.total}</p>
|
|
121
|
+
`,
|
|
122
|
+
}
|
|
123
|
+
```
|
|
124
|
+
|
|
125
|
+
- Store fetchers run **before** page server fetchers and guards — `ctx.store` is available in all of them
|
|
126
|
+
- Only keys listed in `spec.store` are passed to the view — nothing leaks to pages that don't declare it
|
|
127
|
+
- Page-level `server` keys win over store keys if there is a name collision
|
|
128
|
+
- Pages with `spec.store` are never HTML-cached (same rule as pages with `spec.server`)
|
|
129
|
+
|
|
130
|
+
**Reactive updates — no refresh needed**
|
|
131
|
+
|
|
132
|
+
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:
|
|
133
|
+
```js
|
|
134
|
+
actions: {
|
|
135
|
+
saveTheme: {
|
|
136
|
+
run: async (state, server, payload) => {
|
|
137
|
+
await fetch('/api/settings', { method: 'PATCH', body: payload })
|
|
138
|
+
return payload.get('theme')
|
|
139
|
+
},
|
|
140
|
+
onSuccess: (state, theme) => ({
|
|
141
|
+
saved: true,
|
|
142
|
+
_storeUpdate: { settings: { theme } }, // ← broadcast to all subscribed pages
|
|
143
|
+
}),
|
|
144
|
+
onError: (state, err) => ({ error: err.message }),
|
|
145
|
+
},
|
|
146
|
+
},
|
|
147
|
+
```
|
|
148
|
+
`_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.
|
|
149
|
+
|
|
150
|
+
## Server context — redirects, cookies, POST bodies
|
|
151
|
+
|
|
152
|
+
The `ctx` object is available in `guard`, `server.*` fetchers, and `meta` functions.
|
|
153
|
+
|
|
154
|
+
### Reading cookies
|
|
155
|
+
`ctx.cookies` — plain object parsed from the request `Cookie` header:
|
|
156
|
+
```js
|
|
157
|
+
server: { user: async (ctx) => getUserByToken(ctx.cookies.session) }
|
|
158
|
+
```
|
|
159
|
+
|
|
160
|
+
### Redirects — use `guard`
|
|
161
|
+
Return `{ redirect: '/path' }` from `guard` to redirect (302) before any data fetching.
|
|
162
|
+
There is **no redirect mechanism from `server.*` fetchers** — use `guard` for that.
|
|
163
|
+
```js
|
|
164
|
+
guard: async (ctx) => {
|
|
165
|
+
if (!ctx.cookies.session) return { redirect: '/login' }
|
|
166
|
+
}
|
|
167
|
+
```
|
|
168
|
+
|
|
169
|
+
### Setting cookies and headers
|
|
170
|
+
`ctx.setCookie(name, value, opts)` — queues a `Set-Cookie` header. Options: `httpOnly`, `secure`, `path`, `maxAge`, `sameSite`, `domain`. Defaults: `Path=/`, `SameSite=Lax`.
|
|
171
|
+
`ctx.setHeader(name, value)` — queues any arbitrary response header.
|
|
172
|
+
|
|
173
|
+
### Reading the request body
|
|
174
|
+
|
|
175
|
+
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.
|
|
176
|
+
|
|
177
|
+
```js
|
|
178
|
+
await ctx.json() // parse JSON body → object | null
|
|
179
|
+
await ctx.text() // raw string body → string
|
|
180
|
+
await ctx.formData() // URL-encoded body → plain object | null
|
|
181
|
+
await ctx.buffer() // raw Buffer
|
|
182
|
+
```
|
|
183
|
+
|
|
184
|
+
Bodies larger than `maxBody` (default 1 MB, configurable in `createServer`) are rejected with a 413 response before the handler runs.
|
|
185
|
+
|
|
186
|
+
**Page specs only accept GET/HEAD by default** (POST → 405). To handle POST on a page spec, opt in with `spec.methods`:
|
|
187
|
+
|
|
188
|
+
```js
|
|
189
|
+
export default {
|
|
190
|
+
route: '/contact',
|
|
191
|
+
methods: ['GET', 'POST'],
|
|
192
|
+
|
|
193
|
+
guard: async (ctx) => {
|
|
194
|
+
if (ctx.method === 'POST') {
|
|
195
|
+
const data = await ctx.formData()
|
|
196
|
+
if (!data.email) return { status: 422, json: { error: 'Email required' } }
|
|
197
|
+
await db.leads.create(data)
|
|
198
|
+
return { redirect: '/contact?sent=1' }
|
|
199
|
+
}
|
|
200
|
+
},
|
|
201
|
+
|
|
202
|
+
state: {},
|
|
203
|
+
view: (state) => `<form method="POST">...</form>`,
|
|
204
|
+
}
|
|
205
|
+
```
|
|
206
|
+
|
|
207
|
+
**Guard can return a custom HTTP response** (instead of a redirect) by returning `{ status, json?, body?, headers? }`:
|
|
208
|
+
|
|
209
|
+
```js
|
|
210
|
+
guard: async (ctx) => {
|
|
211
|
+
const token = ctx.headers.authorization
|
|
212
|
+
if (!token) return { status: 401, json: { error: 'Unauthorized' } }
|
|
213
|
+
// returning nothing lets the request proceed
|
|
214
|
+
}
|
|
215
|
+
```
|
|
216
|
+
|
|
217
|
+
**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:
|
|
218
|
+
|
|
219
|
+
```js
|
|
220
|
+
export default {
|
|
221
|
+
route: '/api/hook',
|
|
222
|
+
contentType: 'application/json',
|
|
223
|
+
render: async (ctx) => {
|
|
224
|
+
const payload = await ctx.json()
|
|
225
|
+
await processWebhook(payload)
|
|
226
|
+
return JSON.stringify({ ok: true })
|
|
227
|
+
},
|
|
228
|
+
}
|
|
229
|
+
```
|
|
230
|
+
|
|
231
|
+
## Key rules
|
|
232
|
+
|
|
233
|
+
- view() returns an HTML string using template literals
|
|
234
|
+
- data-event="mutationName" on buttons/elements — passes the DOM event to the mutation
|
|
235
|
+
- data-event="change:mutationName" to fire on input change
|
|
236
|
+
- data-action="actionName" on <form> elements only — submits pass FormData to the action
|
|
237
|
+
- Do NOT use data-event on text inputs to mirror value into state — innerHTML re-renders destroy focus. Capture values from FormData in onStart or run instead.
|
|
238
|
+
- onSuccess AND onError are both required in every action (missing either will cause a runtime error)
|
|
239
|
+
- Always export default spec
|
|
240
|
+
|
|
241
|
+
## Form layout pattern
|
|
242
|
+
|
|
243
|
+
Use a `<form data-action="...">` element with class `u-flex u-flex-col u-gap-4` for vertical field stacking. For side-by-side fields (e.g. name + email), use `grid({ cols: 2, gap: 'md' })` inside the form — it collapses to one column on mobile automatically. Never use raw `<div class="...">` grids or custom CSS when `grid()` covers it.
|
|
244
|
+
|
|
245
|
+
```js
|
|
246
|
+
card({ content: `
|
|
247
|
+
<form data-action="submit" class="u-flex u-flex-col u-gap-4">
|
|
248
|
+
${fieldset({ legend: 'Your details', content: `
|
|
249
|
+
${grid({ cols: 2, gap: 'md', content: `
|
|
250
|
+
${input({ name: 'firstName', label: 'First name', required: true })}
|
|
251
|
+
${input({ name: 'lastName', label: 'Last name', required: true })}
|
|
252
|
+
` })}
|
|
253
|
+
${input({ name: 'email', label: 'Email', type: 'email', required: true })}
|
|
254
|
+
` })}
|
|
255
|
+
${fieldset({ legend: 'Message', content: `
|
|
256
|
+
${textarea({ name: 'message', label: 'Tell us about your project', rows: 5, required: true })}
|
|
257
|
+
` })}
|
|
258
|
+
${button({ label: 'Send', type: 'submit', variant: 'primary', fullWidth: true })}
|
|
259
|
+
</form>
|
|
260
|
+
` })
|
|
261
|
+
```
|
|
262
|
+
|
|
263
|
+
For simple forms without distinct groups, omit `fieldset` and use `u-flex u-flex-col u-gap-4` on the `<form>` directly.
|
|
264
|
+
|
|
265
|
+
## meta.styles and meta.scripts
|
|
266
|
+
|
|
267
|
+
- meta.styles — array of CSS paths loaded as <link rel="stylesheet">. Always include '/app.css'.
|
|
268
|
+
- meta.scripts — array of JS paths loaded as <script defer>. Required for interactive UI components.
|
|
269
|
+
|
|
270
|
+
Interactive Pulse UI components (carousel, modal, accordion, tooltip) require BOTH:
|
|
271
|
+
```js
|
|
272
|
+
meta: {
|
|
273
|
+
styles: ['/app.css', '/pulse-ui.css'],
|
|
274
|
+
scripts: ['/pulse-ui.js'],
|
|
275
|
+
}
|
|
276
|
+
```
|
|
277
|
+
Non-interactive components (nav, hero, button, card, etc.) only need '/pulse-ui.css' in styles.
|
|
278
|
+
|
|
279
|
+
## Theming — always use CSS custom properties
|
|
280
|
+
|
|
281
|
+
pulse-ui.css exposes CSS custom properties for every token. app.css MUST use these tokens — never hardcode colour hex values.
|
|
282
|
+
|
|
283
|
+
Override tokens in :root inside app.css to retheme all components at once:
|
|
284
|
+
```css
|
|
285
|
+
:root {
|
|
286
|
+
--bg: #0d0d10; /* page background */
|
|
287
|
+
--surface: #111116; /* card / panel background */
|
|
288
|
+
--surface-2: #18181f; /* inset / code background */
|
|
289
|
+
--border: #38383f;
|
|
290
|
+
--text: #e2e2ea;
|
|
291
|
+
--muted: #9090a0;
|
|
292
|
+
--accent: #9b8dff;
|
|
293
|
+
--accent-hover: #b5aaff;
|
|
294
|
+
--radius: 8px;
|
|
295
|
+
}
|
|
296
|
+
```
|
|
297
|
+
|
|
298
|
+
Then use the computed --ui-* tokens everywhere in app.css:
|
|
299
|
+
```css
|
|
300
|
+
body { background-color: var(--ui-bg); color: var(--ui-text); font-family: var(--ui-font); }
|
|
301
|
+
h1 { color: var(--ui-text); }
|
|
302
|
+
p { color: var(--ui-muted); }
|
|
303
|
+
a { color: var(--ui-accent); }
|
|
304
|
+
code { background: var(--ui-surface-2); color: var(--ui-accent); border: 1px solid var(--ui-border); }
|
|
305
|
+
```
|
|
306
|
+
|
|
307
|
+
The library is dark by default. To apply a **light theme**, set `meta.theme: 'light'` in the spec — this adds `data-theme="light"` to the `<body>` and activates the built-in light token set (accessible contrast for badges, alerts, and all semantic colours). Do NOT manually copy token values into `:root`.
|
|
308
|
+
|
|
309
|
+
```js
|
|
310
|
+
meta: {
|
|
311
|
+
theme: 'light',
|
|
312
|
+
styles: ['/pulse-ui.css', '/app.css'],
|
|
313
|
+
}
|
|
314
|
+
```
|
|
315
|
+
|
|
316
|
+
Token list: --ui-bg, --ui-surface, --ui-surface-2, --ui-border, --ui-text, --ui-muted, --ui-accent, --ui-accent-hover, --ui-green, --ui-red, --ui-yellow, --ui-blue, --ui-radius, --ui-radius-sm, --ui-font, --ui-mono. Never hardcode hex values — override the tokens.
|
|
317
|
+
|
|
318
|
+
## Custom fonts
|
|
319
|
+
|
|
320
|
+
All components use `--ui-font` (body) and `--ui-mono` (code). These resolve from `--font` and `--mono` respectively, so overriding those two variables in `:root` is all that is ever needed — no other CSS changes required.
|
|
321
|
+
|
|
322
|
+
**Two rules that must never be broken:**
|
|
323
|
+
- **Never `@import url(...)` a font in CSS** — use `meta.styles` instead. CSS `@import` is render-blocking; a `<link>` tag is not.
|
|
324
|
+
- **Never set `font-family` directly on `body` or any element** — this bypasses `--ui-font` and breaks component inheritance. Always set `--font` in `:root`.
|
|
325
|
+
|
|
326
|
+
```css
|
|
327
|
+
/* app.css */
|
|
328
|
+
:root {
|
|
329
|
+
--font: 'Inter', system-ui, sans-serif;
|
|
330
|
+
--mono: 'JetBrains Mono', monospace;
|
|
331
|
+
}
|
|
332
|
+
```
|
|
333
|
+
|
|
334
|
+
### Google Fonts
|
|
335
|
+
|
|
336
|
+
Add the Google Fonts stylesheet URL **before** `pulse-ui.css` in `meta.styles`. Use `family=Name:wght@weights` and always include `&display=swap`.
|
|
337
|
+
|
|
338
|
+
**Never use `@import url(...)` in app.css** — CSS `@import` is render-blocking and much slower than a `<link>` tag. Always use `meta.styles`.
|
|
339
|
+
|
|
340
|
+
**Never set `font-family` directly on `body`** — this bypasses `--ui-font` so components won't inherit the font. Always set `--font` in `:root` instead.
|
|
341
|
+
|
|
342
|
+
```js
|
|
343
|
+
meta: {
|
|
344
|
+
styles: [
|
|
345
|
+
'https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600;700&display=swap',
|
|
346
|
+
'/pulse-ui.css',
|
|
347
|
+
'/app.css',
|
|
348
|
+
],
|
|
349
|
+
}
|
|
350
|
+
```
|
|
351
|
+
|
|
352
|
+
Then in app.css:
|
|
353
|
+
```css
|
|
354
|
+
:root { --font: 'Inter', system-ui, sans-serif; }
|
|
355
|
+
```
|
|
356
|
+
|
|
357
|
+
For multiple weights or italic variants, separate them with a semicolon in the URL:
|
|
358
|
+
```
|
|
359
|
+
?family=Inter:ital,wght@0,400;0,700;1,400&display=swap
|
|
360
|
+
```
|
|
361
|
+
|
|
362
|
+
### Adobe Fonts (Typekit)
|
|
363
|
+
|
|
364
|
+
Adobe Fonts provides a per-project CSS URL from your kit settings. Add it before `pulse-ui.css` in `meta.styles` — the font-family name comes from your Adobe Fonts kit.
|
|
365
|
+
|
|
366
|
+
```js
|
|
367
|
+
meta: {
|
|
368
|
+
styles: [
|
|
369
|
+
'https://use.typekit.net/YOURPROJECTID.css',
|
|
370
|
+
'/pulse-ui.css',
|
|
371
|
+
'/app.css',
|
|
372
|
+
],
|
|
373
|
+
}
|
|
374
|
+
```
|
|
375
|
+
|
|
376
|
+
Then in app.css, use the exact font name shown in your Adobe Fonts kit:
|
|
377
|
+
```css
|
|
378
|
+
:root { --font: 'proxima-nova', system-ui, sans-serif; }
|
|
379
|
+
```
|
|
380
|
+
|
|
381
|
+
Note: Adobe Fonts kit URLs require the project to be published and the domain to be authorised in your Adobe Fonts account.
|
|
382
|
+
|
|
383
|
+
### Self-hosted fonts
|
|
384
|
+
|
|
385
|
+
Place font files in `public/fonts/` and declare them with `@font-face` in `app.css`. Always use `woff2` format and `font-display: swap`.
|
|
386
|
+
|
|
387
|
+
```css
|
|
388
|
+
/* app.css */
|
|
389
|
+
@font-face {
|
|
390
|
+
font-family: 'MyFont';
|
|
391
|
+
src: url('/fonts/myfont-regular.woff2') format('woff2');
|
|
392
|
+
font-weight: 400;
|
|
393
|
+
font-style: normal;
|
|
394
|
+
font-display: swap;
|
|
395
|
+
}
|
|
396
|
+
|
|
397
|
+
@font-face {
|
|
398
|
+
font-family: 'MyFont';
|
|
399
|
+
src: url('/fonts/myfont-bold.woff2') format('woff2');
|
|
400
|
+
font-weight: 700;
|
|
401
|
+
font-style: normal;
|
|
402
|
+
font-display: swap;
|
|
403
|
+
}
|
|
404
|
+
|
|
405
|
+
:root { --font: 'MyFont', system-ui, sans-serif; }
|
|
406
|
+
```
|
|
407
|
+
|
|
408
|
+
No changes to `meta.styles` needed — the `@font-face` declarations live in `app.css` which is already loaded.
|
|
409
|
+
|
|
410
|
+
### Multi-brand fonts
|
|
411
|
+
|
|
412
|
+
For multi-brand sites, keep `@font-face` declarations (or the font service URL) in the per-brand theme file and override `--font` there:
|
|
413
|
+
|
|
414
|
+
```css
|
|
415
|
+
/* themes/acme.css */
|
|
416
|
+
:root { --font: 'proxima-nova', system-ui, sans-serif; }
|
|
417
|
+
```
|
|
418
|
+
|
|
419
|
+
## CSS rules — where to put styles and when to use utilities
|
|
420
|
+
|
|
421
|
+
RULE: Never use inline style attributes (style="...") in HTML. Always use classes.
|
|
422
|
+
|
|
423
|
+
RULE: For spacing, typography, layout, and colour, always prefer pulse-ui utility classes first. Only add to app.css if you need something the utilities cannot provide (e.g. a unique component style, a keyframe animation, a custom grid).
|
|
424
|
+
|
|
425
|
+
pulse-ui.css includes a full utility layer (u- prefix). Use these directly in HTML:
|
|
426
|
+
|
|
427
|
+
Spacing (scale: 1=4px 2=8px 3=12px 4=16px 5=20px 6=24px 8=32px 10=40px 12=48px 16=64px):
|
|
428
|
+
u-mt-{0-16} u-mb-{0-16} u-mx-auto u-ml-auto u-mr-auto
|
|
429
|
+
u-p-{0-8} u-px-{0-8} u-py-{0-8}
|
|
430
|
+
|
|
431
|
+
Typography:
|
|
432
|
+
u-text-{xs,sm,base,lg,xl,2xl,3xl,4xl}
|
|
433
|
+
u-font-{normal,medium,semibold,bold}
|
|
434
|
+
u-text-{left,center,right}
|
|
435
|
+
u-text-{default,muted,accent,green,red,yellow,blue}
|
|
436
|
+
u-leading-{tight,snug,normal,relaxed,loose}
|
|
437
|
+
|
|
438
|
+
Layout:
|
|
439
|
+
u-flex u-flex-col u-flex-wrap u-flex-1 u-shrink-0
|
|
440
|
+
u-items-{start,center,end,stretch}
|
|
441
|
+
u-justify-{start,center,end,between}
|
|
442
|
+
u-gap-{1-8}
|
|
443
|
+
u-w-full u-max-w-{xs,sm,md,lg,xl,prose}
|
|
444
|
+
u-block u-inline u-inline-block u-hidden
|
|
445
|
+
|
|
446
|
+
Visual:
|
|
447
|
+
u-rounded u-rounded-md u-rounded-lg u-rounded-xl u-rounded-full
|
|
448
|
+
u-border u-border-t u-border-b
|
|
449
|
+
u-bg-surface u-bg-surface2 u-bg-accent
|
|
450
|
+
u-overflow-hidden u-overflow-auto
|
|
451
|
+
u-relative u-absolute u-opacity-50 u-opacity-75
|
|
452
|
+
|
|
453
|
+
Example — a centred hero block using only utilities, no custom CSS:
|
|
454
|
+
```html
|
|
455
|
+
<div class="u-flex u-flex-col u-items-center u-text-center u-py-16 u-gap-4">
|
|
456
|
+
<h1 class="u-text-4xl u-font-bold">Hello</h1>
|
|
457
|
+
<p class="u-text-lg u-text-muted u-max-w-prose">Subtitle goes here.</p>
|
|
458
|
+
</div>
|
|
459
|
+
```
|
|
460
|
+
|
|
461
|
+
When you DO need to write CSS, add it to public/app.css — never inline.
|
|
462
|
+
|
|
463
|
+
## Site navigation
|
|
464
|
+
|
|
465
|
+
Projects define navigation in src/components/layout.js via a NAV_LINKS array.
|
|
466
|
+
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.
|
|
467
|
+
|
|
468
|
+
```js
|
|
469
|
+
const NAV_LINKS = [
|
|
470
|
+
{ label: 'Home', href: '/' },
|
|
471
|
+
{ label: 'About', href: '/about' }, // ← add new pages here
|
|
472
|
+
]
|
|
473
|
+
```
|
|
474
|
+
|
|
475
|
+
## Page discovery — no registration needed
|
|
476
|
+
|
|
477
|
+
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.
|
|
478
|
+
|
|
479
|
+
File → derived route (used only when the spec has no route property):
|
|
480
|
+
src/pages/about.js → /about
|
|
481
|
+
src/pages/blog.js → /blog
|
|
482
|
+
src/pages/blog/index.js → /blog
|
|
483
|
+
src/pages/home.js or index.js → /
|
|
484
|
+
|
|
485
|
+
## Dynamic routes
|
|
486
|
+
|
|
487
|
+
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.
|
|
488
|
+
|
|
489
|
+
route: '/blog/:slug' ← param captured as ctx.params.slug
|
|
490
|
+
|
|
491
|
+
The param is available in server fetchers and meta functions:
|
|
492
|
+
server: { post: async (ctx) => posts.find(p => p.slug === ctx.params.slug) ?? null }
|
|
493
|
+
meta: { title: (ctx) => posts[ctx.params.slug]?.title ?? 'Not Found' }
|
|
494
|
+
|
|
495
|
+
Convention: name dynamic-route files [param].js inside a subfolder:
|
|
496
|
+
src/pages/blog/[slug].js with route: '/blog/:slug'
|
|
497
|
+
|
|
498
|
+
This is purely a human readability convention. Pulse does not process [ ] in filenames.
|
|
499
|
+
|
|
500
|
+
## UI components — MANDATORY
|
|
501
|
+
|
|
502
|
+
**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).**
|
|
503
|
+
|
|
504
|
+
**RULE: For every feature, page, or UI element you build — regardless of how the request is phrased, whether it came from text or an image — your first step is always to check whether a Pulse UI component exists for it. If it does, you MUST use it. There are no exceptions.**
|
|
505
|
+
|
|
506
|
+
Before writing a single line of HTML, mentally scan the task and list which components apply. Only after exhausting the component list should you write raw HTML — and only for structural wrappers (div, main, aside, footer) with no component equivalent.
|
|
507
|
+
|
|
508
|
+
**RULE: All components accept a `class` prop — never `className` (that is React). Use `class` to add utility classes or custom identifiers to any component.**
|
|
509
|
+
|
|
510
|
+
Import only what you use. Icons are included — no third-party library needed. Always include `/pulse-ui.css` in `meta.styles`:
|
|
511
|
+
```js
|
|
512
|
+
import { nav, hero, feature, button, card, stat, grid, section, container, iconZap, iconShield, iconCheck } from '@invisibleloop/pulse/ui'
|
|
513
|
+
meta: { styles: ['/pulse-ui.css', '/app.css'] }
|
|
514
|
+
```
|
|
515
|
+
|
|
516
|
+
Interactive components (modal, carousel, tooltip, accordion) also need `/pulse-ui.js` in `meta.scripts`.
|
|
517
|
+
|
|
518
|
+
REQUIRED in meta.styles: always include '/pulse-ui.css' whenever you use any UI component.
|
|
519
|
+
|
|
520
|
+
Setup: pulse-ui.css must exist at public/pulse-ui.css — if missing, copy from node_modules/@invisibleloop/pulse/public/pulse-ui.css. Interactive components (modal, carousel) also need public/pulse-ui.js.
|
|
521
|
+
|
|
522
|
+
### Charts
|
|
523
|
+
|
|
524
|
+
Server-rendered SVG charts — no JS, no dependencies. Drop into any card, section, or grid.
|
|
525
|
+
|
|
526
|
+
```js
|
|
527
|
+
import { barChart, lineChart, donutChart, sparkline } from '@invisibleloop/pulse/ui'
|
|
528
|
+
|
|
529
|
+
// Bar chart
|
|
530
|
+
barChart({
|
|
531
|
+
data: [{ label: 'Jan', value: 42 }, { label: 'Feb', value: 78 }],
|
|
532
|
+
color: 'accent', // accent · success · warning · error · blue · muted
|
|
533
|
+
showValues: true, // value labels above bars
|
|
534
|
+
showGrid: true, // horizontal grid lines (default: true)
|
|
535
|
+
gap: 0.25, // gap between bars 0–0.9
|
|
536
|
+
height: 220, // SVG height in px
|
|
537
|
+
})
|
|
538
|
+
|
|
539
|
+
// Line chart
|
|
540
|
+
lineChart({
|
|
541
|
+
data: [{ label: 'Jan', value: 42 }, ...],
|
|
542
|
+
color: 'accent',
|
|
543
|
+
area: true, // fill area under the line
|
|
544
|
+
showDots: true, // dots at each point (default: true)
|
|
545
|
+
height: 220,
|
|
546
|
+
})
|
|
547
|
+
|
|
548
|
+
// Donut chart — each segment can override its colour
|
|
549
|
+
donutChart({
|
|
550
|
+
data: [
|
|
551
|
+
{ label: 'Satisfied', value: 73, color: 'success' },
|
|
552
|
+
{ label: 'Neutral', value: 18, color: 'muted' },
|
|
553
|
+
{ label: 'Unsatisfied', value: 9, color: 'error' },
|
|
554
|
+
],
|
|
555
|
+
size: 200, // diameter in px
|
|
556
|
+
thickness: 40, // ring thickness
|
|
557
|
+
label: '73%', // large centre text
|
|
558
|
+
sublabel: 'satisfied',
|
|
559
|
+
})
|
|
560
|
+
|
|
561
|
+
// Sparkline — inline trend line, plain number array
|
|
562
|
+
sparkline({ data: [12,18,14,22,19,28], color: 'success', area: true })
|
|
563
|
+
sparkline({ data: [12,18,14,22,19,28], width: 80, height: 32 })
|
|
564
|
+
```
|
|
565
|
+
|
|
566
|
+
Charts compose naturally with other components:
|
|
567
|
+
```js
|
|
568
|
+
// Chart inside a card
|
|
569
|
+
card({ title: 'Monthly signups', content: barChart({ data, height: 180 }) })
|
|
570
|
+
|
|
571
|
+
// Grid of chart cards
|
|
572
|
+
grid({ cols: 2, content:
|
|
573
|
+
card({ title: 'Revenue', content: barChart({ data: monthly, color: 'accent' }) }) +
|
|
574
|
+
card({ title: 'Traffic', content: lineChart({ data: daily, color: 'blue', area: true }) }),
|
|
575
|
+
})
|
|
576
|
+
|
|
577
|
+
// Sparkline below a stat tile
|
|
578
|
+
card({ content:
|
|
579
|
+
stat({ label: 'Revenue', value: '$18k', change: '+12%', trend: 'up' }) +
|
|
580
|
+
sparkline({ data: trend, color: 'success', area: true }),
|
|
581
|
+
})
|
|
582
|
+
```
|
|
583
|
+
|
|
584
|
+
### Icons
|
|
585
|
+
|
|
586
|
+
55 built-in icons — no third-party library needed. All are pure functions: `iconName({ size, class, bg, bgColor }) => svgString`.
|
|
587
|
+
|
|
588
|
+
Props:
|
|
589
|
+
- `size` — width/height in px (default: 16)
|
|
590
|
+
- `class` — extra CSS classes (on wrapper when `bg` is set, otherwise on the SVG)
|
|
591
|
+
- `bg` — `'circle'` or `'square'` — wraps icon in a tinted background shape
|
|
592
|
+
- `bgColor` — `'accent'` · `'success'` · `'warning'` · `'error'` · `'muted'` (default: `'accent'`)
|
|
593
|
+
|
|
594
|
+
```js
|
|
595
|
+
import { iconCheck, iconArrowRight, iconZap, iconShield } from '@invisibleloop/pulse/ui'
|
|
596
|
+
|
|
597
|
+
// Plain icon — inherits color from parent
|
|
598
|
+
feature({ icon: iconZap({ size: 20 }) })
|
|
599
|
+
|
|
600
|
+
// Icon in button — use the button icon prop
|
|
601
|
+
button({ label: 'Download', variant: 'primary', icon: iconDownload({ size: 14 }) })
|
|
602
|
+
button({ label: 'Delete', variant: 'danger', icon: iconTrash({ size: 14 }) })
|
|
603
|
+
button({ label: 'Search', variant: 'ghost', icon: iconSearch({ size: 14 }) })
|
|
604
|
+
|
|
605
|
+
// Background circle — great for feature() icon slots or stat/timeline dots
|
|
606
|
+
iconZap({ size: 20, bg: 'circle', bgColor: 'accent' })
|
|
607
|
+
iconCheck({ size: 20, bg: 'circle', bgColor: 'success' })
|
|
608
|
+
iconAlertTriangle({ size: 20, bg: 'circle', bgColor: 'warning' })
|
|
609
|
+
|
|
610
|
+
// Background square (rounded corners)
|
|
611
|
+
iconShield({ size: 22, bg: 'square', bgColor: 'success' })
|
|
612
|
+
iconCode({ size: 22, bg: 'square', bgColor: 'muted' })
|
|
613
|
+
|
|
614
|
+
// Tint colour via utility class (no bg)
|
|
615
|
+
`<span class="u-text-accent">${iconStar({ size: 20 })}</span>`
|
|
616
|
+
```
|
|
617
|
+
|
|
618
|
+
Available icons (import by name):
|
|
619
|
+
- **Navigation:** iconArrowLeft/Right/Up/Down, iconChevronLeft/Right/Up/Down, iconExternalLink, iconMenu, iconX, iconMoreHorizontal, iconMoreVertical
|
|
620
|
+
- **Hand Pointers:** iconHandPointUp, iconHandPointDown, iconHandPointLeft, iconHandPointRight
|
|
621
|
+
- **Status:** iconCheck, iconCheckCircle, iconXCircle, iconAlertCircle, iconAlertTriangle, iconInfo
|
|
622
|
+
- **Actions:** iconPlus, iconMinus, iconEdit, iconTrash, iconCopy, iconSearch, iconFilter, iconDownload, iconUpload, iconRefresh, iconSend
|
|
623
|
+
- **UI Controls:** iconEye, iconEyeOff, iconLock, iconUnlock, iconSettings, iconBell
|
|
624
|
+
- **People:** iconUser, iconUsers, iconMail, iconMessageSquare
|
|
625
|
+
- **Pages:** iconHome, iconLogOut, iconLogIn
|
|
626
|
+
- **Content:** iconFile, iconImage, iconLink, iconCode, iconCalendar, iconClock, iconBookmark, iconTag
|
|
627
|
+
- **Media:** iconPlay, iconPause, iconVolume, iconStar, iconHeart
|
|
628
|
+
- **Devices:** iconPhone, iconGamepad
|
|
629
|
+
- **Misc:** iconGlobe, iconShield, iconZap, iconTrendingUp, iconTrendingDown, iconLoader, iconGrid
|
|
630
|
+
|
|
631
|
+
**Never use emoji in UI output.** Use icons instead. Emoji are not accessible, not theme-aware, and render inconsistently across platforms.
|
|
632
|
+
|
|
633
|
+
#### Adding a new icon
|
|
634
|
+
|
|
635
|
+
If the icon you need does not exist, add it to `src/ui/icons.js`. All icons follow the same pattern:
|
|
636
|
+
|
|
637
|
+
```js
|
|
638
|
+
// Stroke-based (most icons) — inherits color from CSS via stroke="currentColor"
|
|
639
|
+
export const iconRocket = (o) => s('<path d="M4.5 16.5c-1.5 1.5-1.5 4 0 5.5s4 1.5 5.5 0"/><path d="M12 2C6.5 7 5 13 7 18l5 5c5-2 11-3.5 16-9a15 15 0 00-16-12z"/><circle cx="14" cy="10" r="2"/>', opts(o))
|
|
640
|
+
|
|
641
|
+
// Fill-based (solid shapes only — use sparingly)
|
|
642
|
+
export const iconDiamond = (o) => f('<polygon points="12 2 22 12 12 22 2 12"/>', opts(o))
|
|
643
|
+
```
|
|
644
|
+
|
|
645
|
+
Rules for new icons:
|
|
646
|
+
- 24×24 viewBox, Lucide-compatible paths (lucide.dev is MIT licensed — copy paths directly)
|
|
647
|
+
- Stroke icons use `s(paths, opts(o))`. Fill icons use `f(paths, opts(o))`
|
|
648
|
+
- Name as `iconCamelCase` — exported as a named const
|
|
649
|
+
- Add it to the correct section in the file (Navigation, Status, Actions, etc.) or add a new section
|
|
650
|
+
- Export it from `src/ui/index.js` alongside the existing icon exports
|
|
651
|
+
|
|
652
|
+
After adding, import and use it exactly like any built-in icon:
|
|
653
|
+
```js
|
|
654
|
+
import { iconRocket } from '@invisibleloop/pulse/ui'
|
|
655
|
+
iconRocket({ size: 20, bg: 'circle', bgColor: 'accent' })
|
|
656
|
+
```
|
|
657
|
+
|
|
658
|
+
### UI components
|
|
659
|
+
|
|
660
|
+
| Component | Key props |
|
|
661
|
+
|-----------|-----------|
|
|
662
|
+
| `button` | `label`, `variant` (primary/secondary/ghost/danger), `size` (sm/md/lg), `href` (renders `<a>`), `type` (button/submit/reset), `disabled`, `fullWidth`, `icon`, `iconAfter`, `attrs` |
|
|
663
|
+
| `input` | `name`, `label`, `type`, `placeholder`, `value`, `error`, `hint`, `required`, `disabled`, `attrs` |
|
|
664
|
+
| `select` | `name`, `label`, `options` (strings or `{value,label}`), `value`, `error`, `required`, `event` (data-event binding, e.g. `change:setVal`) |
|
|
665
|
+
| `textarea` | `name`, `label`, `placeholder`, `value`, `rows`, `error`, `hint`, `required`, `disabled`, `attrs` |
|
|
666
|
+
| `fieldset` | `legend` (group label), `content` (HTML slot), `gap` (xs/sm/md/lg) — semantic `<fieldset>` + `<legend>` for grouping related fields |
|
|
667
|
+
| `toggle` | `name`, `label`, `checked`, `disabled`, `hint`, `id`, `event` (data-event binding, e.g. `change:setEnabled`) — iOS-style switch; reads as `'on'` in FormData when checked |
|
|
668
|
+
| `fileUpload` | `name`, `label`, `hint`, `error`, `accept` (MIME types/extensions), `multiple`, `required`, `disabled`, `event` (data-event on the input, e.g. `change:fileSelected`) — drag-and-drop upload zone; file submitted in FormData under `name` |
|
|
669
|
+
| `rating` | `value` (0–max, supports 0.5 steps for display), `max` (default 5), `name` (enables interactive radio mode), `label`, `size` (sm/md/lg), `disabled` — omit `name` for read-only display |
|
|
670
|
+
| `spinner` | `size` (sm/md/lg), `color` (accent/muted/white), `label` (aria-label, default 'Loading…') — CSS-only rotating ring |
|
|
671
|
+
| `progress` | `value` (0–max, omit for indeterminate), `max` (default 100), `label`, `showLabel`, `showValue`, `variant` (accent/success/warning/error), `size` (sm/md/lg) |
|
|
672
|
+
| `slider` | `name`, `label`, `min` (0), `max` (100), `step` (1), `value` (50), `disabled`, `hint`, `event` (data-event binding, use `change:mutationName` not `input`) — styled range input; fill gradient updates live during drag automatically. Use `data-event="change:mutationName"` (not `input`) to capture value in state — `change` fires on release, avoiding mid-drag re-render. Submits numeric value in FormData |
|
|
673
|
+
| `segmented` | `name`, `options` ([{value,label}]), `value` (selected), `size` (sm/md/lg), `disabled`, `event` (data-event binding, e.g. `change:setTab`) — iOS-style segmented control using radio inputs |
|
|
674
|
+
| `breadcrumbs` | `items` ([{label,href}] — last item has no href), `separator` (default '/') — accessible nav with aria-current on the current page |
|
|
675
|
+
| `stepper` | `steps` (array of label strings), `current` (0-based active index) — horizontal step progress indicator |
|
|
676
|
+
| `uiImage` | `src`, `alt`, `caption`, `ratio` (CSS aspect-ratio e.g. '16/9'), `rounded` (larger corner radius ~1rem — for photos and book covers), `pill` (999px stadium radius — for avatars), `width`, `height`, `maxWidth` (number in px or CSS string — constrains the figure and centres it; use for portrait/book-cover images inside a `media()` column so they don't stretch to 50% container width) |
|
|
677
|
+
| `pullquote` | `quote`, `cite`, `size` (md/lg) — styled blockquote with accent left border |
|
|
678
|
+
| `heading` | `level` (1–6), `text`, `size` (xs/sm/base/lg/xl/2xl/3xl/4xl — overrides level default), `color` (default/muted/accent), `balance` (true — adds `text-wrap: balance` to prevent orphaned words on the last line) — semantic heading tag with correct visual styling; no margin added, use `u-mb-*` for spacing |
|
|
679
|
+
| `list` | `items` (array of HTML strings), `ordered` (false=ul, true=ol), `gap` (xs/sm/md) — styled list with tokens; items can contain any HTML including other components |
|
|
680
|
+
| `prose` | `content` (raw HTML string — NOT escaped), `size` (sm/base/lg) — typography wrapper for CMS output, markdown-rendered HTML, or any HTML you don't control; styles all descendant elements automatically |
|
|
681
|
+
| `radio` | `name`, `value`, `label`, `checked`, `disabled`, `id`, `event` (data-event binding, e.g. `change:setChoice`) — single radio button with custom styled dot |
|
|
682
|
+
| `radioGroup` | `name`, `legend`, `options` ([{value,label,hint?,disabled?}]), `value` (selected), `hint`, `error`, `gap`, `event` (data-event propagated to each radio input) (sm/md/lg) — semantic `<fieldset>` of radio options |
|
|
683
|
+
| `card` | `title`, `content` (HTML slot), `footer` (HTML slot), `flush` (removes body padding — use for full-bleed images or tables) |
|
|
684
|
+
| `alert` | `variant` (info/success/warning/error), `title`, `content` |
|
|
685
|
+
| `badge` | `label`, `variant` (default/success/warning/error/info) |
|
|
686
|
+
| `stat` | `label`, `value`, `change`, `trend` (up/down/neutral), `center` |
|
|
687
|
+
| `avatar` | `src`, `alt`, `size` (sm/md/lg/xl), `initials` |
|
|
688
|
+
| `empty` | `title`, `description`, `action` ({label,href,variant}) |
|
|
689
|
+
| `table` | `headers`, `rows` (2D array of HTML strings), `caption` |
|
|
690
|
+
| `timeline` | `direction` (vertical/horizontal), `items` ([{dot,dotColor,label,content}]), `content` (raw HTML via `timelineItem()`) — ordered events connected by a line |
|
|
691
|
+
| `timelineItem` | `content` (HTML slot — any component), `label` (timestamp/step label, escaped), `dot` (HTML slot — SVG or emoji, grows dot to 2rem), `dotColor` (accent/success/warning/error/muted) |
|
|
692
|
+
|
|
693
|
+
### Landing page components
|
|
694
|
+
|
|
695
|
+
| Component | Key props |
|
|
696
|
+
|-----------|-----------|
|
|
697
|
+
| `nav` | `logo` (HTML slot), `logoHref`, `links` ([{label,href}]), `action` (HTML slot), `sticky` |
|
|
698
|
+
| `hero` | `eyebrow`, `title`, `subtitle`, `actions` (HTML slot — button() calls), `align` (center/left), `size` (md/sm — sm reduces top padding and removes bottom padding, good for inner-page headers) |
|
|
699
|
+
| `feature` | `icon` (HTML slot — SVG, icon component, or emoji; rendered as-is with no wrapper styling — the icon itself controls its own shape and background), `title`, `description`, `center` (boolean — centres icon, title, and description on all screen sizes) |
|
|
700
|
+
| `testimonial` | `quote`, `name`, `role`, `src` (avatar image URL), `rating` (1–5) |
|
|
701
|
+
| `pricing` | `name`, `price`, `period`, `features` ([strings]), `action` (HTML slot), `highlighted` |
|
|
702
|
+
| `accordion` | `items` ([{title,content}]) — no JS, native `<details>` |
|
|
703
|
+
| `appBadge` | `store` (apple/google), `href` |
|
|
704
|
+
|
|
705
|
+
### Layout components
|
|
706
|
+
|
|
707
|
+
| Component | Key props |
|
|
708
|
+
|-----------|-----------|
|
|
709
|
+
| `container` | `content` (HTML slot), `size` (sm/md/lg/xl) — max-width wrapper |
|
|
710
|
+
| `section` | `content` (HTML slot), `variant` (default/alt/dark), `padding` (sm/md/lg), `id`, `eyebrow`, `title`, `subtitle`, `align` (left/center), `class` — heading props render above content |
|
|
711
|
+
| `grid` | `content` (HTML slot), `cols` (1–4), `gap` (sm/md/lg) — responsive CSS grid |
|
|
712
|
+
| `stack` | `content` (HTML slot), `gap` (xs/sm/md/lg/xl), `align` (stretch/start/center/end) — flex column |
|
|
713
|
+
| `cluster` | `content` (HTML slot), `gap` (xs/sm/md/lg), `justify` (start/center/end/between), `align` — flex row with wrap |
|
|
714
|
+
| `divider` | `label` — `<hr>` with optional centred label |
|
|
715
|
+
| `banner` | `content` (HTML slot), `variant` — full-width announcement bar |
|
|
716
|
+
| `media` | `image` (HTML slot), `content` (HTML slot), `reverse` (boolean — puts text left, image right) — two-column image + text, stacks on mobile |
|
|
717
|
+
| `cta` | `eyebrow`, `title`, `subtitle`, `actions` (HTML slot), `align` (center/left) — call-to-action block, sits inside section + container |
|
|
718
|
+
| `codeWindow` | `content` (raw HTML slot — highlighted code), `filename`, `lang` — macOS-style window chrome around a code block |
|
|
719
|
+
| `footer` | `logo` (HTML slot), `logoHref`, `links` ([{label,href}]), `legal` — accessible site footer, stacks on mobile |
|
|
720
|
+
|
|
721
|
+
### Component composition
|
|
722
|
+
|
|
723
|
+
All HTML slot props (`content`, `footer`, `actions`, etc.) accept any string — including the output of other components. Nest freely:
|
|
724
|
+
|
|
725
|
+
```js
|
|
726
|
+
// grid() wrapping multiple cards — the standard pattern for card grids
|
|
727
|
+
grid({
|
|
728
|
+
cols: 3, gap: 'md',
|
|
729
|
+
content: items.map(item => card({ title: item.name, content: `...` })).join(''),
|
|
730
|
+
})
|
|
731
|
+
|
|
732
|
+
// grid() inside a card's content slot — for structured layouts within a surface
|
|
733
|
+
card({
|
|
734
|
+
title: 'Plan comparison',
|
|
735
|
+
content: grid({
|
|
736
|
+
cols: 3, gap: 'sm',
|
|
737
|
+
content: plans.map(p => `<div class="u-text-center u-p-2">...</div>`).join(''),
|
|
738
|
+
}),
|
|
739
|
+
footer: button({ label: 'View pricing', href: '/pricing', variant: 'ghost', size: 'sm' }),
|
|
740
|
+
})
|
|
741
|
+
|
|
742
|
+
// badge() + button() inside card content and footer
|
|
743
|
+
card({
|
|
744
|
+
title: 'API rate limits',
|
|
745
|
+
content: `
|
|
746
|
+
<div class="u-flex u-gap-2 u-mb-4">
|
|
747
|
+
${badge({ label: 'Production', variant: 'success' })}
|
|
748
|
+
${badge({ label: 'v2.1', variant: 'info' })}
|
|
749
|
+
</div>
|
|
750
|
+
<p class="u-text-muted u-text-sm">Requests are capped at 1,000/min.</p>
|
|
751
|
+
`,
|
|
752
|
+
footer: `
|
|
753
|
+
${button({ label: 'View docs', href: '/docs', variant: 'ghost', size: 'sm' })}
|
|
754
|
+
${button({ label: 'Request increase', href: '/contact', variant: 'secondary', size: 'sm' })}
|
|
755
|
+
`,
|
|
756
|
+
})
|
|
757
|
+
|
|
758
|
+
// flush card with image — remove body padding for full-bleed image, restore below
|
|
759
|
+
card({
|
|
760
|
+
flush: true,
|
|
761
|
+
content: `
|
|
762
|
+
<div class="u-overflow-hidden u-rounded-md" style="height:180px">
|
|
763
|
+
<img src="/img/photo.jpg" alt="..." style="width:100%;height:100%;object-fit:cover">
|
|
764
|
+
</div>
|
|
765
|
+
<div class="u-p-5">
|
|
766
|
+
<p class="u-font-semibold u-mb-2">Card title</p>
|
|
767
|
+
<p class="u-text-muted u-text-sm">Supporting description.</p>
|
|
768
|
+
</div>
|
|
769
|
+
`,
|
|
770
|
+
footer: button({ label: 'Read more', href: '#', variant: 'ghost', size: 'sm' }),
|
|
771
|
+
})
|
|
772
|
+
```
|
|
773
|
+
|
|
774
|
+
### Timeline patterns
|
|
775
|
+
|
|
776
|
+
Vertical (default) — events flow downward, connector line links each dot:
|
|
777
|
+
```js
|
|
778
|
+
timeline({
|
|
779
|
+
items: [
|
|
780
|
+
{ label: 'Jan 2024', dotColor: 'success', content: '<strong>Launched</strong><p>v1.0 shipped to GA.</p>' },
|
|
781
|
+
{ label: 'Mar 2024', dotColor: 'accent', content: '<strong>1k users</strong><p>Organic growth milestone.</p>' },
|
|
782
|
+
{ label: 'Q3 2024', dotColor: 'muted', content: '<strong>Mobile (planned)</strong>' },
|
|
783
|
+
],
|
|
784
|
+
})
|
|
785
|
+
```
|
|
786
|
+
|
|
787
|
+
Horizontal — steps flow left to right (good for onboarding flows):
|
|
788
|
+
```js
|
|
789
|
+
timeline({
|
|
790
|
+
direction: 'horizontal',
|
|
791
|
+
items: [
|
|
792
|
+
{ label: 'Step 1', content: '<p>Sign up</p>' },
|
|
793
|
+
{ label: 'Step 2', content: '<p>Connect data</p>' },
|
|
794
|
+
{ label: 'Step 3', content: '<p>Go live</p>' },
|
|
795
|
+
],
|
|
796
|
+
})
|
|
797
|
+
```
|
|
798
|
+
|
|
799
|
+
Icon dots — pass any SVG as `dot`; dotColor tints both dot background and icon:
|
|
800
|
+
```js
|
|
801
|
+
timeline({
|
|
802
|
+
items: [
|
|
803
|
+
{ dot: checkSvg, dotColor: 'success', label: 'Done', content: card({ title: 'Onboarding complete' }) },
|
|
804
|
+
{ dot: alertSvg, dotColor: 'warning', label: 'Pending', content: '<p>Awaiting approval.</p>' },
|
|
805
|
+
],
|
|
806
|
+
})
|
|
807
|
+
```
|
|
808
|
+
|
|
809
|
+
The `content` slot accepts any component — card(), stat(), badge(), button(), etc. Use `timelineItem()` directly for conditional or dynamic lists:
|
|
810
|
+
```js
|
|
811
|
+
timeline({
|
|
812
|
+
content: steps.map(s =>
|
|
813
|
+
timelineItem({ dotColor: s.done ? 'success' : 'muted', label: s.date, content: s.html })
|
|
814
|
+
).join(''),
|
|
815
|
+
})
|
|
816
|
+
```
|
|
817
|
+
|
|
818
|
+
### Typography components — headings, lists, and prose
|
|
819
|
+
|
|
820
|
+
**Never write raw `<h1>`–`<h6>` or `<ul>`/`<ol>` without styling.** Use these components instead:
|
|
821
|
+
|
|
822
|
+
#### `heading({ level, text, size?, color?, balance?, class? })`
|
|
823
|
+
|
|
824
|
+
Renders the correct semantic tag with consistent visual styling. Use for any standalone heading in a UI page — above a form, inside a card, in a section.
|
|
825
|
+
|
|
826
|
+
```js
|
|
827
|
+
import { heading } from '@invisibleloop/pulse/ui'
|
|
828
|
+
|
|
829
|
+
heading({ level: 1, text: 'Dashboard' })
|
|
830
|
+
// → <h1 class="u-text-4xl u-font-bold u-leading-tight">Dashboard</h1>
|
|
831
|
+
|
|
832
|
+
heading({ level: 2, text: 'Recent orders' })
|
|
833
|
+
heading({ level: 3, text: 'Billing address', class: 'u-mb-4' })
|
|
834
|
+
heading({ level: 2, text: 'No results', color: 'muted' })
|
|
835
|
+
|
|
836
|
+
// Override visual size independently of semantic level (SEO/accessibility need h2 but want h4 size visually)
|
|
837
|
+
heading({ level: 2, text: 'Related articles', size: 'lg' })
|
|
838
|
+
|
|
839
|
+
// Prevent orphaned words — adds text-wrap: balance
|
|
840
|
+
heading({ level: 1, text: 'The quick brown fox jumps over', balance: true })
|
|
841
|
+
```
|
|
842
|
+
|
|
843
|
+
Default size per level: h1=4xl, h2=3xl, h3=2xl, h4=xl, h5=base, h6=sm. Does not add margin — use `u-mb-*` / `u-mt-*` utility classes for spacing.
|
|
844
|
+
|
|
845
|
+
**`balance: true`** adds `text-wrap: balance`, which distributes text evenly across lines so no single word is stranded on the last line. Use it when a heading wraps and the visual result looks uneven. The verification workflow includes an orphan check — apply `balance: true` to any heading it flags.
|
|
846
|
+
|
|
847
|
+
#### `list({ items, ordered?, gap?, class? })`
|
|
848
|
+
|
|
849
|
+
Styled unordered or ordered list. Items are HTML strings — other components can be passed as items.
|
|
850
|
+
|
|
851
|
+
```js
|
|
852
|
+
import { list } from '@invisibleloop/pulse/ui'
|
|
853
|
+
|
|
854
|
+
// Simple text list
|
|
855
|
+
list({ items: ['Fast', 'Accessible', 'Zero dependencies'] })
|
|
856
|
+
|
|
857
|
+
// Ordered steps
|
|
858
|
+
list({ items: ['Create account', 'Verify email', 'Set up profile'], ordered: true })
|
|
859
|
+
|
|
860
|
+
// Items with markup — include links, badges, etc.
|
|
861
|
+
list({ items: items.map(i => `<strong>${e(i.name)}</strong> — ${e(i.desc)}`) })
|
|
862
|
+
|
|
863
|
+
// Spacing: 'xs' | 'sm' (default) | 'md'
|
|
864
|
+
list({ items: features, gap: 'md' })
|
|
865
|
+
```
|
|
866
|
+
|
|
867
|
+
#### `prose({ content, size?, class? })`
|
|
868
|
+
|
|
869
|
+
Typography wrapper for **any HTML you don't control**: CMS rich text, markdown output, database content, API responses. Styles all descendant elements (h1–h6, p, ul, ol, li, a, blockquote, code, pre, table, img) using `--ui-*` tokens. No classes needed on individual elements.
|
|
870
|
+
|
|
871
|
+
```js
|
|
872
|
+
import { prose } from '@invisibleloop/pulse/ui'
|
|
873
|
+
|
|
874
|
+
// CMS rich text field — output directly, fully styled
|
|
875
|
+
prose({ content: server.article.bodyHtml })
|
|
876
|
+
|
|
877
|
+
// Markdown rendered to HTML
|
|
878
|
+
prose({ content: renderMarkdown(server.post.body) })
|
|
879
|
+
|
|
880
|
+
// Larger text (e.g. hero intro)
|
|
881
|
+
prose({ content: server.page.intro, size: 'lg' })
|
|
882
|
+
```
|
|
883
|
+
|
|
884
|
+
**When to use `prose()` vs `heading()` / `list()`:**
|
|
885
|
+
- **`prose()`** — when the HTML comes from outside your spec (CMS, database, markdown). You don't control the tags.
|
|
886
|
+
- **`heading()` / `list()`** — when you're writing the content yourself inside the view template.
|
|
887
|
+
|
|
888
|
+
### Attaching Pulse events to components
|
|
889
|
+
|
|
890
|
+
Pass events via `attrs` — must be an object, not a string:
|
|
891
|
+
```js
|
|
892
|
+
button({ label: 'Like', attrs: { 'data-event': 'like' } })
|
|
893
|
+
button({ label: 'Delete', attrs: { 'data-event': 'remove', 'data-id': item.id } })
|
|
894
|
+
```
|
|
895
|
+
|
|
896
|
+
### Typical landing page structure
|
|
897
|
+
|
|
898
|
+
```js
|
|
899
|
+
import { nav, hero, section, container, grid, feature, button } from '@invisibleloop/pulse/ui'
|
|
900
|
+
|
|
901
|
+
view: () => `
|
|
902
|
+
${nav({
|
|
903
|
+
logo: 'MyApp',
|
|
904
|
+
links: [{ label: 'Docs', href: '/docs' }, { label: 'Pricing', href: '/pricing' }],
|
|
905
|
+
action: button({ label: 'Sign up', href: '/signup', variant: 'primary', size: 'sm' }),
|
|
906
|
+
})}
|
|
907
|
+
<main id="main-content">
|
|
908
|
+
${hero({
|
|
909
|
+
eyebrow: 'Now in beta',
|
|
910
|
+
title: 'Build fast. Ship faster.',
|
|
911
|
+
subtitle: 'The framework that gets out of your way.',
|
|
912
|
+
actions: button({ label: 'Get started →', href: '/docs', variant: 'primary', size: 'lg' }),
|
|
913
|
+
})}
|
|
914
|
+
${section({ content: container({ content: grid({
|
|
915
|
+
cols: 3,
|
|
916
|
+
content: [
|
|
917
|
+
feature({ icon: '⚡', title: 'Fast', description: 'SSR always on.' }),
|
|
918
|
+
feature({ icon: '🛡️', title: 'Safe', description: 'Constraints enforced.' }),
|
|
919
|
+
feature({ icon: '🎯', title: 'Correct', description: '100 Lighthouse.' }),
|
|
920
|
+
].join(''),
|
|
921
|
+
}) }) })}
|
|
922
|
+
</main>
|
|
923
|
+
`
|
|
924
|
+
```
|
|
925
|
+
|
|
926
|
+
## Example page — contact form
|
|
927
|
+
|
|
928
|
+
```js
|
|
929
|
+
import { nav, section, input, textarea, button, alert, container } from '@invisibleloop/pulse/ui'
|
|
930
|
+
import { layout } from '../components/layout.js'
|
|
931
|
+
|
|
932
|
+
export default {
|
|
933
|
+
meta: {
|
|
934
|
+
title: 'Contact',
|
|
935
|
+
styles: ['/app.css', '/pulse-ui.css'],
|
|
936
|
+
},
|
|
937
|
+
state: {
|
|
938
|
+
status: 'idle',
|
|
939
|
+
errors: [],
|
|
940
|
+
},
|
|
941
|
+
actions: {
|
|
942
|
+
send: {
|
|
943
|
+
onStart: (state, formData) => ({
|
|
944
|
+
status: 'loading',
|
|
945
|
+
name: formData.get('name'),
|
|
946
|
+
email: formData.get('email'),
|
|
947
|
+
message: formData.get('message'),
|
|
948
|
+
}),
|
|
949
|
+
run: async (state, serverState, formData) => {
|
|
950
|
+
const res = await fetch('/api/contact', { method: 'POST', body: formData })
|
|
951
|
+
if (!res.ok) throw new Error(await res.text())
|
|
952
|
+
return await res.json()
|
|
953
|
+
},
|
|
954
|
+
onSuccess: (state, result) => ({ status: 'success' }),
|
|
955
|
+
onError: (state, err) => ({
|
|
956
|
+
status: 'error',
|
|
957
|
+
errors: err?.validation ?? [{ message: err.message }],
|
|
958
|
+
}),
|
|
959
|
+
},
|
|
960
|
+
},
|
|
961
|
+
view: (state) => layout(`
|
|
962
|
+
${container({ content: `
|
|
963
|
+
${section({ title: 'Contact Us', subtitle: 'We will get back to you within 24 hours.' })}
|
|
964
|
+
${state.status === 'success'
|
|
965
|
+
? alert({ type: 'success', message: 'Message sent!' })
|
|
966
|
+
: `<form data-action="send" class="u-flex u-flex-col u-gap-4">
|
|
967
|
+
${grid({ cols: 2, gap: 'md', content: `
|
|
968
|
+
${input({ name: 'name', label: 'Name', placeholder: 'Your name', required: true })}
|
|
969
|
+
${input({ name: 'email', label: 'Email', type: 'email', placeholder: 'you@example.com', required: true })}
|
|
970
|
+
` })}
|
|
971
|
+
${textarea({ name: 'message', label: 'Message', rows: 5, required: true })}
|
|
972
|
+
${state.errors.length ? alert({ type: 'error', message: state.errors[0].message }) : ''}
|
|
973
|
+
${button({ label: state.status === 'loading' ? 'Sending…' : 'Send', type: 'submit', variant: 'primary', fullWidth: true })}
|
|
974
|
+
</form>`
|
|
975
|
+
}
|
|
976
|
+
` })}
|
|
977
|
+
`),
|
|
978
|
+
}
|
|
979
|
+
```
|