@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,621 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Pulse 2 — Client Runtime
|
|
3
|
+
*
|
|
4
|
+
* Takes a spec, mounts it to a DOM element.
|
|
5
|
+
* Handles events, applies mutations, enforces constraints, re-renders.
|
|
6
|
+
*
|
|
7
|
+
* No framework. No virtual DOM. No dependencies.
|
|
8
|
+
*/
|
|
9
|
+
|
|
10
|
+
import { initClientStore, getStoreState, subscribe, updateStore, registerStoreMutations, dispatchStoreMutation } from './store.js'
|
|
11
|
+
|
|
12
|
+
// Toast is lazy-loaded on first use — pages that never use _toast pay zero bytes
|
|
13
|
+
const showToast = (opts) => import('./toast.js').then(m => m.showToast(opts))
|
|
14
|
+
|
|
15
|
+
// ---------------------------------------------------------------------------
|
|
16
|
+
// Mount
|
|
17
|
+
// ---------------------------------------------------------------------------
|
|
18
|
+
|
|
19
|
+
/**
|
|
20
|
+
* Mount a spec to a DOM element.
|
|
21
|
+
*
|
|
22
|
+
* @param {import('../spec/schema.js').PulseSpec} spec
|
|
23
|
+
* @param {HTMLElement} el
|
|
24
|
+
* @param {Object} [serverState] - Serialised server state from SSR
|
|
25
|
+
*/
|
|
26
|
+
export function mount(spec, el, serverState = {}, options = {}) {
|
|
27
|
+
// Spec is validated server-side at startup — no need to re-validate in the browser
|
|
28
|
+
// Initialise the client store from SSR data (no-op after the first page mount).
|
|
29
|
+
// window.__PULSE_STORE__ is serialised by the server when a store is registered.
|
|
30
|
+
if (typeof window !== 'undefined') {
|
|
31
|
+
initClientStore(window.__PULSE_STORE__ || {})
|
|
32
|
+
// Register store mutations so data-store-event bindings can dispatch them.
|
|
33
|
+
// No-op on subsequent mounts — mutations persist across client navigations.
|
|
34
|
+
if (options.store?.mutations) {
|
|
35
|
+
registerStoreMutations(options.store.mutations)
|
|
36
|
+
}
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
// Separate page-level server state from store keys so they can be tracked
|
|
40
|
+
// independently — store values come from the live singleton, not the snapshot.
|
|
41
|
+
const _storeKeys = new Set(spec.store || [])
|
|
42
|
+
const _pageServerState = {}
|
|
43
|
+
for (const [k, v] of Object.entries(serverState)) {
|
|
44
|
+
if (!_storeKeys.has(k)) _pageServerState[k] = v
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
// Build the server state the view sees: fresh store slice + page-level data.
|
|
48
|
+
// Page-level keys always win over store keys with the same name.
|
|
49
|
+
function getEffectiveServerState() {
|
|
50
|
+
if (!_storeKeys.size) return _pageServerState
|
|
51
|
+
const storeState = getStoreState()
|
|
52
|
+
const slice = {}
|
|
53
|
+
for (const key of _storeKeys) {
|
|
54
|
+
if (storeState[key] !== undefined) slice[key] = storeState[key]
|
|
55
|
+
}
|
|
56
|
+
return { ...slice, ..._pageServerState }
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
// Deep clone initial state — never mutate the spec itself
|
|
60
|
+
let state = deepClone(spec.state)
|
|
61
|
+
|
|
62
|
+
// Restore persisted keys from localStorage
|
|
63
|
+
const persistKey = spec.persist?.length ? `pulse:${spec.route || location.pathname}` : null
|
|
64
|
+
let restoredFromPersist = false
|
|
65
|
+
if (persistKey) {
|
|
66
|
+
try {
|
|
67
|
+
const saved = JSON.parse(localStorage.getItem(persistKey) || '{}')
|
|
68
|
+
spec.persist.forEach(k => {
|
|
69
|
+
if (saved[k] !== undefined && saved[k] !== spec.state[k]) {
|
|
70
|
+
state[k] = saved[k]
|
|
71
|
+
restoredFromPersist = true
|
|
72
|
+
}
|
|
73
|
+
})
|
|
74
|
+
} catch { /* ignore parse errors */ }
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
// ---------------------------------------------------------------------------
|
|
78
|
+
// Persist
|
|
79
|
+
// ---------------------------------------------------------------------------
|
|
80
|
+
|
|
81
|
+
function persist() {
|
|
82
|
+
if (!persistKey) return
|
|
83
|
+
try {
|
|
84
|
+
const snapshot = {}
|
|
85
|
+
spec.persist.forEach(k => { snapshot[k] = state[k] })
|
|
86
|
+
localStorage.setItem(persistKey, JSON.stringify(snapshot))
|
|
87
|
+
} catch { /* ignore quota errors */ }
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
// ---------------------------------------------------------------------------
|
|
91
|
+
// Render
|
|
92
|
+
// ---------------------------------------------------------------------------
|
|
93
|
+
|
|
94
|
+
let lastHtml = null
|
|
95
|
+
|
|
96
|
+
function render() {
|
|
97
|
+
let html
|
|
98
|
+
try {
|
|
99
|
+
html = resolveView(spec, state, getEffectiveServerState())
|
|
100
|
+
} catch (err) {
|
|
101
|
+
console.error('[Pulse] View error:', err)
|
|
102
|
+
const serverState = getEffectiveServerState()
|
|
103
|
+
html = spec.onViewError
|
|
104
|
+
? spec.onViewError(err, state, serverState)
|
|
105
|
+
: viewErrorFallback(err)
|
|
106
|
+
}
|
|
107
|
+
if (html === lastHtml) return // nothing changed
|
|
108
|
+
lastHtml = html
|
|
109
|
+
morph(el, html)
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
// ---------------------------------------------------------------------------
|
|
113
|
+
// Dispatch — the single entry point for all state changes
|
|
114
|
+
// ---------------------------------------------------------------------------
|
|
115
|
+
|
|
116
|
+
function dispatch(type, payload) {
|
|
117
|
+
// Mutation
|
|
118
|
+
if (spec.mutations?.[type]) {
|
|
119
|
+
const raw = spec.mutations[type](state, payload)
|
|
120
|
+
if (raw?._toast) showToast(raw._toast)
|
|
121
|
+
const { _toast, ...partial } = raw ?? {}
|
|
122
|
+
state = applyConstraints({ ...state, ...partial }, spec.constraints)
|
|
123
|
+
persist()
|
|
124
|
+
render()
|
|
125
|
+
return
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
// Action
|
|
129
|
+
if (spec.actions?.[type]) {
|
|
130
|
+
runAction(type, spec.actions[type], state, payload)
|
|
131
|
+
return
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
console.warn(`[Pulse] No mutation or action found for "${type}"`)
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
// ---------------------------------------------------------------------------
|
|
138
|
+
// Actions — async, cross the server/client boundary
|
|
139
|
+
// ---------------------------------------------------------------------------
|
|
140
|
+
|
|
141
|
+
async function runAction(name, action, currentState, payload) {
|
|
142
|
+
// Optimistic update before async work
|
|
143
|
+
if (action.onStart) {
|
|
144
|
+
const raw = action.onStart(currentState, payload)
|
|
145
|
+
if (raw?._toast) showToast(raw._toast)
|
|
146
|
+
const { _toast, ...partial } = raw ?? {}
|
|
147
|
+
state = applyConstraints({ ...state, ...partial }, spec.constraints)
|
|
148
|
+
render()
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
// Validate before running if requested
|
|
152
|
+
if (action.validate) {
|
|
153
|
+
const errors = validateFields(state, spec.validation)
|
|
154
|
+
if (errors.length > 0) {
|
|
155
|
+
console.warn(`[Pulse] Validation failed for action "${name}":`, errors)
|
|
156
|
+
const raw = action.onError?.(state, { validation: errors }) ?? {}
|
|
157
|
+
if (raw._toast) showToast(raw._toast)
|
|
158
|
+
const { _toast, ...partial } = raw
|
|
159
|
+
state = applyConstraints({ ...state, ...partial }, spec.constraints)
|
|
160
|
+
render()
|
|
161
|
+
return
|
|
162
|
+
}
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
try {
|
|
166
|
+
// Pass fresh effective server state (includes live store values)
|
|
167
|
+
const result = await action.run(state, getEffectiveServerState(), payload)
|
|
168
|
+
const raw = action.onSuccess(state, result) ?? {}
|
|
169
|
+
|
|
170
|
+
// _storeUpdate — push changes to the global store and notify all subscribers
|
|
171
|
+
if (raw._storeUpdate) updateStore(raw._storeUpdate)
|
|
172
|
+
if (raw._toast) showToast(raw._toast)
|
|
173
|
+
const { _storeUpdate: _su, _toast: _t, ...partial } = raw
|
|
174
|
+
state = applyConstraints({ ...state, ...partial }, spec.constraints)
|
|
175
|
+
} catch (error) {
|
|
176
|
+
console.error(`[Pulse] Action "${name}" failed:`, error)
|
|
177
|
+
const raw = action.onError(state, error) ?? {}
|
|
178
|
+
if (raw._toast) showToast(raw._toast)
|
|
179
|
+
const { _toast, ...partial } = raw
|
|
180
|
+
state = applyConstraints({ ...state, ...partial }, spec.constraints)
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
persist()
|
|
184
|
+
render()
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
// ---------------------------------------------------------------------------
|
|
188
|
+
// Initial render
|
|
189
|
+
// ---------------------------------------------------------------------------
|
|
190
|
+
|
|
191
|
+
// Event delegation — one set of listeners on the root, survives morphing.
|
|
192
|
+
// AbortController lets destroy() remove all listeners at once.
|
|
193
|
+
const _eventAbort = new AbortController()
|
|
194
|
+
bindEvents(el, dispatch, _eventAbort.signal)
|
|
195
|
+
|
|
196
|
+
// Subscribe to global store — re-render whenever store keys this page uses change.
|
|
197
|
+
const _unsubStore = _storeKeys.size > 0 ? subscribe(() => render()) : null
|
|
198
|
+
|
|
199
|
+
// If the element was server-rendered, skip the initial render to avoid
|
|
200
|
+
// touching existing DOM — this preserves the early LCP paint from SSR.
|
|
201
|
+
// Morph on first mutation will diff from whatever SSR left in the DOM.
|
|
202
|
+
if (!(options.ssr && !restoredFromPersist)) {
|
|
203
|
+
render()
|
|
204
|
+
}
|
|
205
|
+
|
|
206
|
+
// ---------------------------------------------------------------------------
|
|
207
|
+
// Public API
|
|
208
|
+
// ---------------------------------------------------------------------------
|
|
209
|
+
|
|
210
|
+
return {
|
|
211
|
+
/** Read current state — useful for debugging and testing */
|
|
212
|
+
getState: () => deepClone(state),
|
|
213
|
+
|
|
214
|
+
/** Programmatically dispatch a mutation or action */
|
|
215
|
+
dispatch,
|
|
216
|
+
|
|
217
|
+
/** Force a re-render — useful after external state changes */
|
|
218
|
+
refresh: render,
|
|
219
|
+
|
|
220
|
+
/** Tear down — remove event listeners, unsubscribe from store, clear element */
|
|
221
|
+
destroy: () => { _unsubStore?.(); _eventAbort.abort(); el.innerHTML = '' }
|
|
222
|
+
}
|
|
223
|
+
}
|
|
224
|
+
|
|
225
|
+
// ---------------------------------------------------------------------------
|
|
226
|
+
// View resolution
|
|
227
|
+
// ---------------------------------------------------------------------------
|
|
228
|
+
|
|
229
|
+
/**
|
|
230
|
+
* Execute the spec's view function(s) and return a complete HTML string.
|
|
231
|
+
*
|
|
232
|
+
* @param {import('../spec/schema.js').PulseSpec} spec
|
|
233
|
+
* @param {Object} state
|
|
234
|
+
* @param {Object} serverState
|
|
235
|
+
* @returns {string}
|
|
236
|
+
*/
|
|
237
|
+
function resolveView(spec, state, serverState) {
|
|
238
|
+
if (typeof spec.view === 'function') {
|
|
239
|
+
return spec.view(state, serverState)
|
|
240
|
+
}
|
|
241
|
+
|
|
242
|
+
// Segmented view — concatenate all segments in definition order
|
|
243
|
+
return Object.values(spec.view)
|
|
244
|
+
.map(fn => fn(state, serverState))
|
|
245
|
+
.join('')
|
|
246
|
+
}
|
|
247
|
+
|
|
248
|
+
// ---------------------------------------------------------------------------
|
|
249
|
+
// Event binding
|
|
250
|
+
// ---------------------------------------------------------------------------
|
|
251
|
+
|
|
252
|
+
/**
|
|
253
|
+
* Bind all data-event and data-action attributes within el.
|
|
254
|
+
* Uses event delegation — one listener per event type per element.
|
|
255
|
+
*
|
|
256
|
+
* Attribute format:
|
|
257
|
+
* data-event="mutationName" → fires on click
|
|
258
|
+
* data-event="change:mutationName" → fires on change
|
|
259
|
+
* data-event="input:mutationName" → fires on input
|
|
260
|
+
* data-action="actionName" → fires on form submit
|
|
261
|
+
*
|
|
262
|
+
* @param {HTMLElement} el
|
|
263
|
+
* @param {function} dispatch
|
|
264
|
+
*/
|
|
265
|
+
/**
|
|
266
|
+
* Bind events via delegation on the root element.
|
|
267
|
+
* One listener per event type — survives DOM morphing without rebinding.
|
|
268
|
+
*/
|
|
269
|
+
function bindEvents(el, dispatch, signal) {
|
|
270
|
+
const opts = signal ? { signal } : {}
|
|
271
|
+
|
|
272
|
+
el.addEventListener('click', e => {
|
|
273
|
+
// Store mutations
|
|
274
|
+
const storeTarget = e.target?.closest?.('[data-store-event]')
|
|
275
|
+
if (storeTarget) {
|
|
276
|
+
const [sType, sName] = parseEventAttr(storeTarget.dataset.storeEvent)
|
|
277
|
+
if (sType === 'click') { e.preventDefault(); dispatchStoreMutation(sName, e) }
|
|
278
|
+
return
|
|
279
|
+
}
|
|
280
|
+
|
|
281
|
+
// Dialog open — data-dialog-open="dialogId"
|
|
282
|
+
const dialogOpenTarget = e.target?.closest?.('[data-dialog-open]')
|
|
283
|
+
if (dialogOpenTarget) {
|
|
284
|
+
const dialog = document.getElementById(dialogOpenTarget.dataset.dialogOpen)
|
|
285
|
+
if (dialog?.showModal) { e.preventDefault(); dialog.showModal() }
|
|
286
|
+
return
|
|
287
|
+
}
|
|
288
|
+
|
|
289
|
+
// Dialog close — data-dialog-close (closes nearest ancestor <dialog>)
|
|
290
|
+
const dialogCloseTarget = e.target?.closest?.('[data-dialog-close]')
|
|
291
|
+
if (dialogCloseTarget) {
|
|
292
|
+
const dialog = dialogCloseTarget.closest('dialog')
|
|
293
|
+
if (dialog) { e.preventDefault(); dialog.close() }
|
|
294
|
+
return
|
|
295
|
+
}
|
|
296
|
+
|
|
297
|
+
// Backdrop close — click lands on the <dialog> element itself (outside content area)
|
|
298
|
+
if (e.target?.tagName === 'DIALOG') { e.target.close(); return }
|
|
299
|
+
|
|
300
|
+
// Spec events
|
|
301
|
+
const target = e.target?.closest?.('[data-event]')
|
|
302
|
+
if (!target) return
|
|
303
|
+
const [type, name] = parseEventAttr(target.dataset.event)
|
|
304
|
+
if (type !== 'click') return
|
|
305
|
+
e.preventDefault()
|
|
306
|
+
dispatch(name, e)
|
|
307
|
+
}, opts)
|
|
308
|
+
|
|
309
|
+
el.addEventListener('change', e => {
|
|
310
|
+
const storeTarget = e.target?.closest?.('[data-store-event]')
|
|
311
|
+
if (storeTarget) {
|
|
312
|
+
const [sType, sName] = parseEventAttr(storeTarget.dataset.storeEvent)
|
|
313
|
+
if (sType === 'change') dispatchStoreMutation(sName, e)
|
|
314
|
+
return
|
|
315
|
+
}
|
|
316
|
+
const target = e.target?.closest?.('[data-event]')
|
|
317
|
+
if (!target) return
|
|
318
|
+
const [type, name] = parseEventAttr(target.dataset.event)
|
|
319
|
+
if (type !== 'change') return
|
|
320
|
+
dispatchTimed(target, name, e, dispatch)
|
|
321
|
+
}, opts)
|
|
322
|
+
|
|
323
|
+
el.addEventListener('input', e => {
|
|
324
|
+
const storeTarget = e.target?.closest?.('[data-store-event]')
|
|
325
|
+
if (storeTarget) {
|
|
326
|
+
const [sType, sName] = parseEventAttr(storeTarget.dataset.storeEvent)
|
|
327
|
+
if (sType === 'input') dispatchStoreMutation(sName, e)
|
|
328
|
+
return
|
|
329
|
+
}
|
|
330
|
+
const target = e.target?.closest?.('[data-event]')
|
|
331
|
+
if (!target) return
|
|
332
|
+
const [type, name] = parseEventAttr(target.dataset.event)
|
|
333
|
+
if (type !== 'input') return
|
|
334
|
+
dispatchTimed(target, name, e, dispatch)
|
|
335
|
+
}, opts)
|
|
336
|
+
|
|
337
|
+
el.addEventListener('submit', e => {
|
|
338
|
+
const target = e.target?.closest?.('[data-action]')
|
|
339
|
+
if (!target) return
|
|
340
|
+
e.preventDefault()
|
|
341
|
+
dispatch(target.dataset.action, new FormData(target))
|
|
342
|
+
if (target.hasAttribute('data-reset')) target.reset()
|
|
343
|
+
}, opts)
|
|
344
|
+
}
|
|
345
|
+
|
|
346
|
+
/**
|
|
347
|
+
* Morph the children of `el` to match `newHtml` — updates only what changed.
|
|
348
|
+
* Preserves existing DOM nodes (images, inputs, etc.) rather than replacing them.
|
|
349
|
+
* Falls back to innerHTML in non-browser environments (tests, SSR).
|
|
350
|
+
*/
|
|
351
|
+
function morph(el, newHtml) {
|
|
352
|
+
if (typeof document === 'undefined') {
|
|
353
|
+
el.innerHTML = newHtml
|
|
354
|
+
return
|
|
355
|
+
}
|
|
356
|
+
const temp = document.createElement('div')
|
|
357
|
+
temp.innerHTML = newHtml
|
|
358
|
+
morphNodes(el, temp)
|
|
359
|
+
}
|
|
360
|
+
|
|
361
|
+
function morphNodes(cur, nxt) {
|
|
362
|
+
const curNodes = Array.from(cur.childNodes)
|
|
363
|
+
const nxtNodes = Array.from(nxt.childNodes)
|
|
364
|
+
|
|
365
|
+
nxtNodes.forEach((nxtNode, i) => {
|
|
366
|
+
const curNode = curNodes[i]
|
|
367
|
+
|
|
368
|
+
if (!curNode) {
|
|
369
|
+
cur.appendChild(nxtNode.cloneNode(true))
|
|
370
|
+
return
|
|
371
|
+
}
|
|
372
|
+
|
|
373
|
+
if (curNode.nodeType !== nxtNode.nodeType || curNode.nodeName !== nxtNode.nodeName) {
|
|
374
|
+
cur.replaceChild(nxtNode.cloneNode(true), curNode)
|
|
375
|
+
return
|
|
376
|
+
}
|
|
377
|
+
|
|
378
|
+
if (nxtNode.nodeType === 3) { // TEXT_NODE
|
|
379
|
+
if (curNode.nodeValue !== nxtNode.nodeValue) curNode.nodeValue = nxtNode.nodeValue
|
|
380
|
+
return
|
|
381
|
+
}
|
|
382
|
+
|
|
383
|
+
if (nxtNode.nodeType === 1) { // ELEMENT_NODE
|
|
384
|
+
morphAttrs(curNode, nxtNode)
|
|
385
|
+
morphNodes(curNode, nxtNode)
|
|
386
|
+
}
|
|
387
|
+
})
|
|
388
|
+
|
|
389
|
+
// Remove surplus nodes
|
|
390
|
+
while (cur.childNodes.length > nxtNodes.length) cur.removeChild(cur.lastChild)
|
|
391
|
+
}
|
|
392
|
+
|
|
393
|
+
function morphAttrs(cur, nxt) {
|
|
394
|
+
for (const { name, value } of Array.from(nxt.attributes)) {
|
|
395
|
+
if (cur.getAttribute(name) !== value) cur.setAttribute(name, value)
|
|
396
|
+
}
|
|
397
|
+
for (const { name } of Array.from(cur.attributes)) {
|
|
398
|
+
if (!nxt.hasAttribute(name)) cur.removeAttribute(name)
|
|
399
|
+
}
|
|
400
|
+
}
|
|
401
|
+
|
|
402
|
+
/**
|
|
403
|
+
* Parse a data-event attribute value.
|
|
404
|
+
* "click:increment" → ['click', 'increment']
|
|
405
|
+
* "increment" → ['click', 'increment'] (click is default)
|
|
406
|
+
*
|
|
407
|
+
* @param {string} value
|
|
408
|
+
* @returns {[string, string]}
|
|
409
|
+
*/
|
|
410
|
+
function parseEventAttr(value) {
|
|
411
|
+
const parts = value.split(':')
|
|
412
|
+
if (parts.length === 1) return ['click', parts[0]]
|
|
413
|
+
return [parts[0], parts[1]]
|
|
414
|
+
}
|
|
415
|
+
|
|
416
|
+
/**
|
|
417
|
+
* Dispatch a mutation, applying debounce or throttle if declared on the element.
|
|
418
|
+
* data-debounce="300" — waits 300ms after the last event before firing
|
|
419
|
+
* data-throttle="300" — fires at most once every 300ms
|
|
420
|
+
* No attribute — fires immediately (existing behaviour)
|
|
421
|
+
*
|
|
422
|
+
* The wrapped function is cached on the element via WeakMap so accumulation
|
|
423
|
+
* works correctly across renders without creating new timers on every event.
|
|
424
|
+
*/
|
|
425
|
+
function dispatchTimed(target, name, e, dispatch) {
|
|
426
|
+
const debounceMs = parseInt(target.dataset.debounce, 10)
|
|
427
|
+
if (debounceMs > 0) {
|
|
428
|
+
getCached(target, 'd', name, debounceMs, (ev) => dispatch(name, ev))(e)
|
|
429
|
+
return
|
|
430
|
+
}
|
|
431
|
+
const throttleMs = parseInt(target.dataset.throttle, 10)
|
|
432
|
+
if (throttleMs > 0) {
|
|
433
|
+
getCached(target, 't', name, throttleMs, (ev) => dispatch(name, ev))(e)
|
|
434
|
+
return
|
|
435
|
+
}
|
|
436
|
+
dispatch(name, e)
|
|
437
|
+
}
|
|
438
|
+
|
|
439
|
+
// ---------------------------------------------------------------------------
|
|
440
|
+
// Constraints
|
|
441
|
+
// ---------------------------------------------------------------------------
|
|
442
|
+
|
|
443
|
+
/**
|
|
444
|
+
* Apply declared constraints to a state object.
|
|
445
|
+
* Returns a new state object — never mutates in place.
|
|
446
|
+
*
|
|
447
|
+
* @param {Object} state
|
|
448
|
+
* @param {Object} [constraints]
|
|
449
|
+
* @returns {Object}
|
|
450
|
+
*/
|
|
451
|
+
function applyConstraints(state, constraints) {
|
|
452
|
+
if (!constraints) return state
|
|
453
|
+
|
|
454
|
+
const next = deepClone(state)
|
|
455
|
+
|
|
456
|
+
for (const [path, rules] of Object.entries(constraints)) {
|
|
457
|
+
const { obj, key } = resolvePath(next, path)
|
|
458
|
+
if (obj === null || obj[key] === undefined) continue
|
|
459
|
+
|
|
460
|
+
if (rules.min !== undefined && typeof obj[key] === 'number') {
|
|
461
|
+
obj[key] = Math.max(obj[key], rules.min)
|
|
462
|
+
}
|
|
463
|
+
if (rules.max !== undefined && typeof obj[key] === 'number') {
|
|
464
|
+
obj[key] = Math.min(obj[key], rules.max)
|
|
465
|
+
}
|
|
466
|
+
}
|
|
467
|
+
|
|
468
|
+
return next
|
|
469
|
+
}
|
|
470
|
+
|
|
471
|
+
// ---------------------------------------------------------------------------
|
|
472
|
+
// Validation
|
|
473
|
+
// ---------------------------------------------------------------------------
|
|
474
|
+
|
|
475
|
+
/**
|
|
476
|
+
* Run validation rules against the current state.
|
|
477
|
+
* Returns an array of error objects — empty means valid.
|
|
478
|
+
*
|
|
479
|
+
* @param {Object} state
|
|
480
|
+
* @param {Object} [validation]
|
|
481
|
+
* @returns {{ path: string, rule: string, message: string }[]}
|
|
482
|
+
*/
|
|
483
|
+
export function validateFields(state, validation) {
|
|
484
|
+
if (!validation) return []
|
|
485
|
+
|
|
486
|
+
const errors = []
|
|
487
|
+
|
|
488
|
+
for (const [path, rules] of Object.entries(validation)) {
|
|
489
|
+
const { obj, key } = resolvePath(state, path)
|
|
490
|
+
const value = obj?.[key]
|
|
491
|
+
|
|
492
|
+
if (rules.required && !value) {
|
|
493
|
+
errors.push({ path, rule: 'required', message: `${path} is required` })
|
|
494
|
+
continue // no point running further rules on an empty value
|
|
495
|
+
}
|
|
496
|
+
|
|
497
|
+
if (value === undefined || value === null || value === '') continue
|
|
498
|
+
|
|
499
|
+
if (rules.minLength !== undefined && String(value).length < rules.minLength) {
|
|
500
|
+
errors.push({ path, rule: 'minLength', message: `${path} must be at least ${rules.minLength} characters` })
|
|
501
|
+
}
|
|
502
|
+
|
|
503
|
+
if (rules.maxLength !== undefined && String(value).length > rules.maxLength) {
|
|
504
|
+
errors.push({ path, rule: 'maxLength', message: `${path} must be no more than ${rules.maxLength} characters` })
|
|
505
|
+
}
|
|
506
|
+
|
|
507
|
+
if (rules.min !== undefined && Number(value) < rules.min) {
|
|
508
|
+
errors.push({ path, rule: 'min', message: `${path} must be at least ${rules.min}` })
|
|
509
|
+
}
|
|
510
|
+
|
|
511
|
+
if (rules.max !== undefined && Number(value) > rules.max) {
|
|
512
|
+
errors.push({ path, rule: 'max', message: `${path} must be no more than ${rules.max}` })
|
|
513
|
+
}
|
|
514
|
+
|
|
515
|
+
if (rules.format === 'email' && !isValidEmail(String(value))) {
|
|
516
|
+
errors.push({ path, rule: 'format', message: `${path} must be a valid email address` })
|
|
517
|
+
}
|
|
518
|
+
|
|
519
|
+
if (rules.format === 'url' && !isValidUrl(String(value))) {
|
|
520
|
+
errors.push({ path, rule: 'format', message: `${path} must be a valid URL` })
|
|
521
|
+
}
|
|
522
|
+
|
|
523
|
+
if (rules.format === 'numeric' && isNaN(Number(value))) {
|
|
524
|
+
errors.push({ path, rule: 'format', message: `${path} must be numeric` })
|
|
525
|
+
}
|
|
526
|
+
|
|
527
|
+
if (rules.pattern && !rules.pattern.test(String(value))) {
|
|
528
|
+
errors.push({ path, rule: 'pattern', message: `${path} does not match the required format` })
|
|
529
|
+
}
|
|
530
|
+
}
|
|
531
|
+
|
|
532
|
+
return errors
|
|
533
|
+
}
|
|
534
|
+
|
|
535
|
+
// ---------------------------------------------------------------------------
|
|
536
|
+
// Utilities
|
|
537
|
+
// ---------------------------------------------------------------------------
|
|
538
|
+
|
|
539
|
+
/**
|
|
540
|
+
* Resolve a dot-notation path into an object.
|
|
541
|
+
* Returns the parent object and the final key so the caller can read or write.
|
|
542
|
+
*
|
|
543
|
+
* resolvePath({ fields: { email: '' } }, 'fields.email')
|
|
544
|
+
* → { obj: { email: '' }, key: 'email' }
|
|
545
|
+
*
|
|
546
|
+
* @param {Object} obj
|
|
547
|
+
* @param {string} path
|
|
548
|
+
* @returns {{ obj: Object|null, key: string }}
|
|
549
|
+
*/
|
|
550
|
+
function resolvePath(obj, path) {
|
|
551
|
+
const parts = path.split('.')
|
|
552
|
+
const key = parts.pop()
|
|
553
|
+
let cur = obj
|
|
554
|
+
|
|
555
|
+
for (const part of parts) {
|
|
556
|
+
if (cur === null || cur === undefined || typeof cur !== 'object') {
|
|
557
|
+
return { obj: null, key }
|
|
558
|
+
}
|
|
559
|
+
cur = cur[part]
|
|
560
|
+
}
|
|
561
|
+
|
|
562
|
+
return { obj: cur, key }
|
|
563
|
+
}
|
|
564
|
+
|
|
565
|
+
/**
|
|
566
|
+
* Deep clone a plain object. No circular reference support needed —
|
|
567
|
+
* spec state is always a plain serialisable object.
|
|
568
|
+
*
|
|
569
|
+
* @param {Object} obj
|
|
570
|
+
* @returns {Object}
|
|
571
|
+
*/
|
|
572
|
+
function deepClone(obj) {
|
|
573
|
+
return JSON.parse(JSON.stringify(obj))
|
|
574
|
+
}
|
|
575
|
+
|
|
576
|
+
function viewErrorFallback(err) {
|
|
577
|
+
const msg = err?.message ? `: ${err.message}` : ''
|
|
578
|
+
return `<div style="padding:1rem;color:#b91c1c;background:#fef2f2;border:1px solid #fca5a5;border-radius:.375rem;font-family:monospace;font-size:.875rem"><strong>View error</strong>${msg}</div>`
|
|
579
|
+
}
|
|
580
|
+
|
|
581
|
+
function isValidEmail(value) {
|
|
582
|
+
return /^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(value)
|
|
583
|
+
}
|
|
584
|
+
|
|
585
|
+
// ---------------------------------------------------------------------------
|
|
586
|
+
// Debounce / throttle
|
|
587
|
+
// ---------------------------------------------------------------------------
|
|
588
|
+
|
|
589
|
+
/**
|
|
590
|
+
* WeakMap cache so each element gets one stable debounced/throttled wrapper
|
|
591
|
+
* per (name, delay) pair — survives DOM morphing without creating new timers.
|
|
592
|
+
*/
|
|
593
|
+
const _timerCache = new WeakMap()
|
|
594
|
+
|
|
595
|
+
function getCached(el, kind, name, delay, fn) {
|
|
596
|
+
if (!_timerCache.has(el)) _timerCache.set(el, {})
|
|
597
|
+
const cache = _timerCache.get(el)
|
|
598
|
+
const key = `${kind}:${name}:${delay}`
|
|
599
|
+
if (!cache[key]) cache[key] = kind === 'd' ? debounce(fn, delay) : throttle(fn, delay)
|
|
600
|
+
return cache[key]
|
|
601
|
+
}
|
|
602
|
+
|
|
603
|
+
export function debounce(fn, delay) {
|
|
604
|
+
let timer
|
|
605
|
+
return function(...args) {
|
|
606
|
+
clearTimeout(timer)
|
|
607
|
+
timer = setTimeout(() => fn(...args), delay)
|
|
608
|
+
}
|
|
609
|
+
}
|
|
610
|
+
|
|
611
|
+
export function throttle(fn, delay) {
|
|
612
|
+
let last = 0
|
|
613
|
+
return function(...args) {
|
|
614
|
+
const now = Date.now()
|
|
615
|
+
if (now - last >= delay) { last = now; fn(...args) }
|
|
616
|
+
}
|
|
617
|
+
}
|
|
618
|
+
|
|
619
|
+
function isValidUrl(value) {
|
|
620
|
+
try { new URL(value); return true } catch { return false }
|
|
621
|
+
}
|