@invisibleloop/pulse 0.1.28 → 0.1.30
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/.github/workflows/publish.yml +11 -20
- package/README.md +1 -1
- package/docs/public/.pulse-ui-version +1 -1
- package/docs/public/docs.css +19 -1
- package/docs/public/pulse-ui.css +1 -0
- package/docs/server.js +5 -2
- package/docs/src/lib/highlight.js +57 -13
- package/docs/src/lib/layout.js +5 -2
- package/docs/src/pages/faq.js +5 -2
- package/docs/src/pages/home.js +9 -5
- package/docs/src/pages/meta.js +21 -0
- package/docs/src/pages/routing.js +12 -1
- package/package.json +1 -1
- package/public/pulse-ui.css +2 -2
- package/src/agent/guide-components.md +1 -1
- package/src/agent/guide-routing.md +20 -0
- package/src/agent/guide-spec.md +10 -1
- package/src/agent/guide-styles.md +16 -1
- package/src/agent/workflow.md +1 -1
- package/src/cli/scaffold.js +63 -2
- package/src/mcp/server.js +34 -18
- package/src/server/index.js +26 -7
- package/src/server/server.test.js +47 -0
- package/src/ui/stat.js +1 -1
- package/src/ui/ui.test.js +6 -0
- package/docs/public/dist/accessibility.boot-5DVTARJU.js +0 -115
- package/docs/public/dist/actions.boot-P66HKQEM.js +0 -164
- package/docs/public/dist/auth.boot-IMAJAUPH.js +0 -140
- package/docs/public/dist/caching.boot-DVR6KDE7.js +0 -53
- package/docs/public/dist/components--accordion.boot-3HVKMNWC.js +0 -11
- package/docs/public/dist/components--alert.boot-GCEXOZAC.js +0 -6
- package/docs/public/dist/components--app-badge.boot-DVT3GCHJ.js +0 -6
- package/docs/public/dist/components--avatar.boot-PSW24EVA.js +0 -5
- package/docs/public/dist/components--badge.boot-TYDY2RMK.js +0 -7
- package/docs/public/dist/components--banner.boot-EI5PZSZK.js +0 -7
- package/docs/public/dist/components--breadcrumbs.boot-SMA2E2GO.js +0 -34
- package/docs/public/dist/components--button.boot-J54BQM2E.js +0 -23
- package/docs/public/dist/components--card.boot-PZGNDIB6.js +0 -138
- package/docs/public/dist/components--carousel.boot-TP6LPFZZ.js +0 -12
- package/docs/public/dist/components--charts.boot-2EOYQWKL.js +0 -108
- package/docs/public/dist/components--checkbox.boot-DS5BSL6T.js +0 -54
- package/docs/public/dist/components--cluster.boot-HHVIBBJG.js +0 -9
- package/docs/public/dist/components--code-window.boot-2GR2DV33.js +0 -20
- package/docs/public/dist/components--container.boot-7LOOGK2K.js +0 -5
- package/docs/public/dist/components--cta.boot-FSNZ5YRT.js +0 -11
- package/docs/public/dist/components--divider.boot-3NI2C3QG.js +0 -6
- package/docs/public/dist/components--empty.boot-YX2UR3PV.js +0 -7
- package/docs/public/dist/components--feature.boot-MUD7NSUO.js +0 -13
- package/docs/public/dist/components--fieldset.boot-J7BYHMKF.js +0 -19
- package/docs/public/dist/components--fileupload.boot-NIKVTTPD.js +0 -52
- package/docs/public/dist/components--footer.boot-EYUK5FRG.js +0 -14
- package/docs/public/dist/components--grid.boot-URDQVDDR.js +0 -59
- package/docs/public/dist/components--heading.boot-BPQKU43E.js +0 -44
- package/docs/public/dist/components--hero.boot-4RAPRGAB.js +0 -17
- package/docs/public/dist/components--icons.boot-ZITNU5JP.js +0 -68
- package/docs/public/dist/components--image.boot-XEEGHQZF.js +0 -19
- package/docs/public/dist/components--input.boot-SGASZG5K.js +0 -7
- package/docs/public/dist/components--list.boot-W3XC5MHD.js +0 -55
- package/docs/public/dist/components--media.boot-5VFIETZO.js +0 -13
- package/docs/public/dist/components--modal.boot-RZUYXBN2.js +0 -47
- package/docs/public/dist/components--nav.boot-ODBOHU7O.js +0 -33
- package/docs/public/dist/components--pricing.boot-4AQ4ZVBY.js +0 -21
- package/docs/public/dist/components--progress.boot-GHAGYZOK.js +0 -30
- package/docs/public/dist/components--prose.boot-QANJL6JI.js +0 -67
- package/docs/public/dist/components--pullquote.boot-Q2WMNAZU.js +0 -22
- package/docs/public/dist/components--radio.boot-TJRDQ2OL.js +0 -75
- package/docs/public/dist/components--rating.boot-QBAN6DEL.js +0 -38
- package/docs/public/dist/components--search.boot-PXH5O5AG.js +0 -17
- package/docs/public/dist/components--section.boot-AQGIYHWW.js +0 -12
- package/docs/public/dist/components--segmented.boot-BEVTKEJO.js +0 -33
- package/docs/public/dist/components--select.boot-47X5RHOC.js +0 -10
- package/docs/public/dist/components--slider.boot-PSRRX7XL.js +0 -47
- package/docs/public/dist/components--spinner.boot-MZ5MO2OH.js +0 -22
- package/docs/public/dist/components--stack.boot-DI4NJXBF.js +0 -9
- package/docs/public/dist/components--stat.boot-QMFUWBQT.js +0 -9
- package/docs/public/dist/components--stepper.boot-34PP2NEV.js +0 -22
- package/docs/public/dist/components--table.boot-FCQGSFIQ.js +0 -11
- package/docs/public/dist/components--testimonial.boot-DWQPDKYG.js +0 -11
- package/docs/public/dist/components--textarea.boot-QVXLBOJ5.js +0 -4
- package/docs/public/dist/components--timeline.boot-26LN52P2.js +0 -95
- package/docs/public/dist/components--toggle.boot-IQQEI76S.js +0 -29
- package/docs/public/dist/components--tooltip.boot-LGHCO6NN.js +0 -9
- package/docs/public/dist/components.boot-SE6PQ4P7.js +0 -103
- package/docs/public/dist/config.boot-DTRRWUE6.js +0 -126
- package/docs/public/dist/constraints.boot-DUHDZBMC.js +0 -71
- package/docs/public/dist/deploy.boot-SLAD3NI2.js +0 -163
- package/docs/public/dist/docs-8e3d4b5c.css +0 -1
- package/docs/public/dist/extending.boot-UA3CN243.js +0 -159
- package/docs/public/dist/faq.boot-6EQAWLQR.js +0 -43
- package/docs/public/dist/getting-started.boot-TDKIFL5U.js +0 -86
- package/docs/public/dist/guard.boot-AUHAWTG4.js +0 -80
- package/docs/public/dist/home.boot-BVQXRH32.js +0 -383
- package/docs/public/dist/how-it-works.boot-LTWAKWKW.js +0 -104
- package/docs/public/dist/hydration.boot-JRM6IPJL.js +0 -78
- package/docs/public/dist/images.boot-M6ZVKTZS.js +0 -80
- package/docs/public/dist/manifest.json +0 -94
- package/docs/public/dist/meta.boot-7NXGPHR4.js +0 -79
- package/docs/public/dist/mutations.boot-F6F43UDX.js +0 -79
- package/docs/public/dist/navigation.boot-AOXWS3ZF.js +0 -57
- package/docs/public/dist/performance.boot-C3UPCOBK.js +0 -98
- package/docs/public/dist/persist.boot-WT32PQOQ.js +0 -61
- package/docs/public/dist/project-structure.boot-FB3LRVJ4.js +0 -63
- package/docs/public/dist/prompt-examples.boot-YKR4VDK4.js +0 -31
- package/docs/public/dist/pulse-ui-81a85c03.css +0 -1
- package/docs/public/dist/raw-responses.boot-M4KA5YXL.js +0 -104
- package/docs/public/dist/routing.boot-FNX5FDGH.js +0 -70
- package/docs/public/dist/runtime-B73WLANC.js +0 -1
- package/docs/public/dist/runtime-KO4BHUQ3.js +0 -49
- package/docs/public/dist/runtime-L2HNXIHW.js +0 -59
- package/docs/public/dist/runtime-QFURDKA2.js +0 -5
- package/docs/public/dist/runtime-UVPXO4IR.js +0 -375
- package/docs/public/dist/runtime-VMJA3Z4N.js +0 -10
- package/docs/public/dist/runtime-ZJ4FXT5O.js +0 -11
- package/docs/public/dist/server-api.boot-K7X3LCFB.js +0 -219
- package/docs/public/dist/server-data.boot-Y7HQYC4R.js +0 -157
- package/docs/public/dist/slash-commands.boot-V2UV7OW2.js +0 -26
- package/docs/public/dist/spec.boot-2WU7ZHCV.js +0 -159
- package/docs/public/dist/state.boot-B24GUE3R.js +0 -73
- package/docs/public/dist/store.boot-TLIB4XHH.js +0 -150
- package/docs/public/dist/streaming.boot-W2DZSMW4.js +0 -80
- package/docs/public/dist/stripe.boot-QN3C2GEL.js +0 -164
- package/docs/public/dist/supabase.boot-BG4XXLZE.js +0 -303
- package/docs/public/dist/testing.boot-6U4WKMTE.js +0 -130
- package/docs/public/dist/validation.boot-PQHYGW5B.js +0 -100
package/src/mcp/server.js
CHANGED
|
@@ -233,25 +233,27 @@ server.registerTool(
|
|
|
233
233
|
server.registerTool(
|
|
234
234
|
'pulse_create_page',
|
|
235
235
|
{
|
|
236
|
-
description: `
|
|
236
|
+
description: `Validate and register a page spec that you have already written to disk with the Write tool.
|
|
237
237
|
|
|
238
|
-
|
|
238
|
+
Workflow — always in this order:
|
|
239
|
+
1. Write the spec file to src/pages/<name>.js using the Write tool (user sees the diff)
|
|
240
|
+
2. Call pulse_create_page with just the name to validate it
|
|
241
|
+
|
|
242
|
+
Do NOT pass content here — write the file first, then call this tool.
|
|
243
|
+
|
|
244
|
+
Rules for the spec you write:
|
|
239
245
|
- Import Pulse UI components from '@invisibleloop/pulse/ui' — never write raw HTML for nav, hero, button, card, input, etc.
|
|
240
246
|
- Include '/pulse-ui.css' in meta.styles whenever using any UI component
|
|
241
|
-
- Use u- utility classes for spacing/layout
|
|
242
|
-
- Use var(--ui-*) CSS tokens
|
|
247
|
+
- Use u- utility classes for spacing/layout — never inline styles
|
|
248
|
+
- Use var(--ui-*) CSS tokens for any colour — never hardcode hex values
|
|
243
249
|
- onSuccess AND onError are both required in every action
|
|
244
250
|
- Do NOT use data-event on text inputs — use FormData in onStart/run instead
|
|
245
251
|
- Always export default spec`,
|
|
246
252
|
inputSchema: {
|
|
247
|
-
name:
|
|
248
|
-
content: z.string().describe('Complete JS spec — must export default a valid Pulse spec object'),
|
|
253
|
+
name: z.string().describe('Filename without extension, matching what you wrote — e.g. "about" or "blog/post"'),
|
|
249
254
|
},
|
|
250
255
|
},
|
|
251
|
-
async ({ name
|
|
252
|
-
const validation = await validateContent(content)
|
|
253
|
-
if (validation.content[0].text.startsWith('Invalid')) return validation
|
|
254
|
-
|
|
256
|
+
async ({ name }) => {
|
|
255
257
|
const segments = name.replace(/\.js$/, '').split('/')
|
|
256
258
|
const fullPath = path.join(PAGES_DIR, ...segments) + '.js'
|
|
257
259
|
|
|
@@ -259,11 +261,16 @@ IMPORTANT: Always follow these rules when writing the spec content:
|
|
|
259
261
|
return text('Error: page name must not escape src/pages/')
|
|
260
262
|
}
|
|
261
263
|
|
|
262
|
-
fs.
|
|
263
|
-
|
|
264
|
+
if (!fs.existsSync(fullPath)) {
|
|
265
|
+
return text(`Error: ${path.relative(ROOT, fullPath)} does not exist — write the file with the Write tool first, then call pulse_create_page.`)
|
|
266
|
+
}
|
|
267
|
+
|
|
268
|
+
const content = fs.readFileSync(fullPath, 'utf8')
|
|
269
|
+
const validation = await validateContent(content)
|
|
270
|
+
if (validation.content[0].text.startsWith('Invalid')) return validation
|
|
264
271
|
|
|
265
272
|
const route = derivedRouteFromName(name)
|
|
266
|
-
return text(`
|
|
273
|
+
return text(`Validated ${path.relative(ROOT, fullPath)} → route "${route}"`)
|
|
267
274
|
}
|
|
268
275
|
)
|
|
269
276
|
|
|
@@ -685,10 +692,19 @@ server.registerTool(
|
|
|
685
692
|
server.registerTool(
|
|
686
693
|
'pulse_update',
|
|
687
694
|
{
|
|
688
|
-
description: '
|
|
695
|
+
description: 'Install the latest @invisibleloop/pulse package, then re-copy pulse-ui.css, pulse-ui.js, and the agent checklist into public/. One command does the full upgrade.',
|
|
689
696
|
inputSchema: {},
|
|
690
697
|
},
|
|
691
|
-
() => {
|
|
698
|
+
async () => {
|
|
699
|
+
// 1. npm install latest
|
|
700
|
+
const { execSync } = await import('child_process')
|
|
701
|
+
try {
|
|
702
|
+
execSync('npm install @invisibleloop/pulse@latest', { cwd: ROOT, stdio: 'pipe' })
|
|
703
|
+
} catch (e) {
|
|
704
|
+
return text(`npm install failed:\n${e.stderr?.toString() || e.message}`)
|
|
705
|
+
}
|
|
706
|
+
|
|
707
|
+
// 2. Copy assets from the newly installed package
|
|
692
708
|
const pkgPublic = new URL('../../public', import.meta.url).pathname
|
|
693
709
|
const publicDir = path.join(ROOT, 'public')
|
|
694
710
|
const assets = ['pulse-ui.css', 'pulse-ui.js', '.pulse-ui-version']
|
|
@@ -711,7 +727,7 @@ server.registerTool(
|
|
|
711
727
|
|
|
712
728
|
const versionFile = path.join(publicDir, '.pulse-ui-version')
|
|
713
729
|
const version = fs.existsSync(versionFile) ? fs.readFileSync(versionFile, 'utf8').trim() : '?'
|
|
714
|
-
return text(`
|
|
730
|
+
return text(`Pulse updated to v${version}\n\n${updated.map(f => `✓ ${f}`).join('\n')}`)
|
|
715
731
|
}
|
|
716
732
|
)
|
|
717
733
|
|
|
@@ -826,7 +842,7 @@ const PULSE_GUIDE_INDEX = `# Pulse Framework Guide
|
|
|
826
842
|
- \`pulse_list_structure\` — list pages, components, and pulse-ui version. Call at the start of every session.
|
|
827
843
|
- \`pulse_validate\` — validate spec content. Call after every write. Fix all errors AND warnings.
|
|
828
844
|
- \`pulse_review\` — switch into reviewer mode and critically examine a spec you just built. Returns the source, rendered HTML, validator output, and a full review checklist. **Call this only after validate, Lighthouse (desktop + mobile), and tests all pass — it is the final phase before declaring done.**
|
|
829
|
-
- \`pulse_create_page\` —
|
|
845
|
+
- \`pulse_create_page\` — validate a page spec you already wrote to disk. **Always write the file with the Write tool first, then call this.** Never pass content to this tool.
|
|
830
846
|
- \`pulse_create_component\` — create a reusable component.
|
|
831
847
|
- \`pulse_create_store\` — create the pulse.store.js global store.
|
|
832
848
|
- \`pulse_create_action\` — generate a correctly-structured action snippet.
|
|
@@ -834,7 +850,7 @@ const PULSE_GUIDE_INDEX = `# Pulse Framework Guide
|
|
|
834
850
|
- \`pulse_restart_server\` — stop and restart the dev server.
|
|
835
851
|
- \`pulse_build\` — production build + starts prod server on devPort+1 for Lighthouse. Returns the URL. Call \`pulse_restart_server\` after to return to dev. **Slow — takes 30–60 s. Tell the user before calling.**
|
|
836
852
|
- \`pulse_check_version\` — check installed package version, static asset version, and latest on npm. Use this instead of running npm commands when the user asks about updates.
|
|
837
|
-
- \`pulse_update\` — re-copy \`pulse-ui.css\`, \`pulse-ui.js\`, and the agent checklist
|
|
853
|
+
- \`pulse_update\` — install the latest \`@invisibleloop/pulse\` package and re-copy \`pulse-ui.css\`, \`pulse-ui.js\`, and the agent checklist into \`public/\`. One command does the full upgrade.
|
|
838
854
|
|
|
839
855
|
**Chrome DevTools MCP tools** (globally available):
|
|
840
856
|
- \`mcp__chrome-devtools__take_screenshot\` — visual screenshot of the page.
|
package/src/server/index.js
CHANGED
|
@@ -16,7 +16,11 @@ import fs from 'fs'
|
|
|
16
16
|
import path from 'path'
|
|
17
17
|
import zlib from 'zlib'
|
|
18
18
|
import crypto from 'crypto'
|
|
19
|
-
import { promisify }
|
|
19
|
+
import { promisify } from 'util'
|
|
20
|
+
import { createRequire } from 'module'
|
|
21
|
+
|
|
22
|
+
const _require = createRequire(import.meta.url)
|
|
23
|
+
export const version = _require('../../package.json').version
|
|
20
24
|
import { renderToString, renderToStream, wrapDocument, resolveServerState } from '../runtime/ssr.js'
|
|
21
25
|
import { validateSpec } from '../spec/schema.js'
|
|
22
26
|
import { validateStore, resolveStoreState } from '../store/index.js'
|
|
@@ -541,14 +545,16 @@ export function createServer(specs, options = {}) {
|
|
|
541
545
|
}
|
|
542
546
|
}
|
|
543
547
|
|
|
544
|
-
//
|
|
548
|
+
// Derive the canonical base URL from the request.
|
|
545
549
|
// Canonical path follows the trailingSlash mode so the <link> is consistent with redirects.
|
|
550
|
+
// spec.meta.canonical (string or function) is resolved inside each handler, where serverState
|
|
551
|
+
// is available — allowing dynamic canonicals from server fetcher results.
|
|
546
552
|
const proto = req.headers['x-forwarded-proto'] || 'http'
|
|
547
553
|
const host = req.headers['x-forwarded-host'] || req.headers.host || `localhost:${port}`
|
|
548
554
|
const canonicalPath = trailingSlash === 'add' && pathname !== '/'
|
|
549
555
|
? (pathname.endsWith('/') ? pathname : pathname + '/')
|
|
550
556
|
: (pathname !== '/' && pathname.endsWith('/') ? pathname.slice(0, -1) : pathname)
|
|
551
|
-
const
|
|
557
|
+
const canonicalBase = `${proto}://${host}${canonicalPath}`
|
|
552
558
|
|
|
553
559
|
// Raw content spec (RSS, sitemap, JSON API, webhooks) — bypass HTML pipeline
|
|
554
560
|
if (spec.contentType) {
|
|
@@ -563,9 +569,9 @@ export function createServer(specs, options = {}) {
|
|
|
563
569
|
}
|
|
564
570
|
|
|
565
571
|
if (stream) {
|
|
566
|
-
await handleStreamResponse(spec, ctx, req, res, extraBody, dev,
|
|
572
|
+
await handleStreamResponse(spec, ctx, req, res, extraBody, dev, canonicalBase, nonce, runtimeBundle, defaultCache, store, csp)
|
|
567
573
|
} else {
|
|
568
|
-
await handleStringResponse(spec, ctx, req, res, extraBody, dev,
|
|
574
|
+
await handleStringResponse(spec, ctx, req, res, extraBody, dev, canonicalBase, nonce, runtimeBundle, defaultCache, store, csp)
|
|
569
575
|
}
|
|
570
576
|
|
|
571
577
|
} catch (err) {
|
|
@@ -676,7 +682,7 @@ async function handleNavResponse(spec, ctx, res, dev = false) {
|
|
|
676
682
|
* Render to a complete string then send — simpler, easier to cache.
|
|
677
683
|
* Checks the in-process page cache before rendering; stores result after.
|
|
678
684
|
*/
|
|
679
|
-
async function handleStringResponse(spec, ctx, req, res, extraBody = '', dev = false,
|
|
685
|
+
async function handleStringResponse(spec, ctx, req, res, extraBody = '', dev = false, canonicalBase = '', nonce = '', runtimeBundle = '', defaultCache = null, store = null, csp = {}) {
|
|
680
686
|
const cacheKey = spec.route + '\0' + JSON.stringify(ctx.params) + '\0' + JSON.stringify(ctx.query)
|
|
681
687
|
// Pages with server data or store data embed a nonce'd __PULSE_SERVER__ script — don't cache them
|
|
682
688
|
const ttl = (!spec.server && !spec.store?.length) ? pageCacheTtl(spec, dev, defaultCache) : 0
|
|
@@ -689,6 +695,12 @@ async function handleStringResponse(spec, ctx, req, res, extraBody = '', dev = f
|
|
|
689
695
|
fromCache = true
|
|
690
696
|
} else {
|
|
691
697
|
const { html: content, serverState, timing } = await cachedRenderToString(spec, ctx, dev)
|
|
698
|
+
// Resolve canonical — supports a function receiving (ctx, serverState) so the URL can be
|
|
699
|
+
// derived from server fetcher results (e.g. a canonical slug from a database lookup).
|
|
700
|
+
const canonicalRaw = spec.meta?.canonical
|
|
701
|
+
const canonicalUrl = typeof canonicalRaw === 'function'
|
|
702
|
+
? (canonicalRaw(ctx, serverState) || canonicalBase)
|
|
703
|
+
: (canonicalRaw || canonicalBase)
|
|
692
704
|
const canonicalTag = canonicalUrl ? `<link rel="canonical" href="${escHtml(canonicalUrl)}">` : ''
|
|
693
705
|
const resolvedSpec = { ...spec, meta: resolveMeta(spec.meta, ctx) }
|
|
694
706
|
const resolvedExtraBody = typeof extraBody === 'function' ? extraBody(nonce) : extraBody
|
|
@@ -720,7 +732,7 @@ async function handleStringResponse(spec, ctx, req, res, extraBody = '', dev = f
|
|
|
720
732
|
* Stream the response — shell first, deferred segments follow.
|
|
721
733
|
* On a page-cache hit, serves the buffered HTML as a string (no streaming needed).
|
|
722
734
|
*/
|
|
723
|
-
async function handleStreamResponse(spec, ctx, req, res, extraBody = '', dev = false,
|
|
735
|
+
async function handleStreamResponse(spec, ctx, req, res, extraBody = '', dev = false, canonicalBase = '', nonce = '', runtimeBundle = '', defaultCache = null, store = null, csp = {}) {
|
|
724
736
|
// Serve from in-process page cache when available — skip streaming overhead.
|
|
725
737
|
// Pages with spec.server or spec.store embed a nonce'd __PULSE_SERVER__ script so are never cached.
|
|
726
738
|
const cacheKey = spec.route + '\0' + JSON.stringify(ctx.params) + '\0' + JSON.stringify(ctx.query)
|
|
@@ -748,6 +760,13 @@ async function handleStreamResponse(spec, ctx, req, res, extraBody = '', dev = f
|
|
|
748
760
|
// Write the document opening immediately so the browser starts parsing
|
|
749
761
|
const meta = resolveMeta(spec.meta, ctx)
|
|
750
762
|
const title = meta.title || 'Pulse'
|
|
763
|
+
// Resolve canonical — supports a string or a function receiving (ctx).
|
|
764
|
+
// Note: server fetcher results are not yet available when the <head> is written in streaming mode.
|
|
765
|
+
// If your canonical depends on server data, use stream: false on that spec, or set it as a string.
|
|
766
|
+
const canonicalRaw = spec.meta?.canonical
|
|
767
|
+
const canonicalUrl = typeof canonicalRaw === 'function'
|
|
768
|
+
? (canonicalRaw(ctx) || canonicalBase)
|
|
769
|
+
: (canonicalRaw || canonicalBase)
|
|
751
770
|
|
|
752
771
|
const stylePreloads = (meta.styles || [])
|
|
753
772
|
.map(href => ` <link rel="preload" as="style" href="${escHtml(href)}">`)
|
|
@@ -685,6 +685,53 @@ await test('meta.canonical overrides auto-generated canonical URL', async () =>
|
|
|
685
685
|
})
|
|
686
686
|
})
|
|
687
687
|
|
|
688
|
+
await test('meta.canonical as a function receives ctx (string path)', async () => {
|
|
689
|
+
const spec = {
|
|
690
|
+
route: '/about',
|
|
691
|
+
meta: { title: 'About', styles: [], canonical: (ctx) => `https://example.com${ctx.params.slug ?? '/about'}` },
|
|
692
|
+
state: {},
|
|
693
|
+
view: () => `<main id="main-content"><h1>About</h1></main>`,
|
|
694
|
+
}
|
|
695
|
+
await withServer([spec], { stream: false }, async (port) => {
|
|
696
|
+
const { body } = await get(port, '/about')
|
|
697
|
+
assert(body.includes(`<link rel="canonical" href="https://example.com/about">`),
|
|
698
|
+
`Expected function canonical, got: ${body.slice(0, 500)}`)
|
|
699
|
+
})
|
|
700
|
+
})
|
|
701
|
+
|
|
702
|
+
await test('meta.canonical as a function receives serverState (string path)', async () => {
|
|
703
|
+
const spec = {
|
|
704
|
+
route: '/product',
|
|
705
|
+
meta: {
|
|
706
|
+
title: 'Product',
|
|
707
|
+
styles: [],
|
|
708
|
+
canonical: (ctx, serverState) => `https://example.com/products/${serverState?.data?.slug ?? 'fallback'}`,
|
|
709
|
+
},
|
|
710
|
+
state: {},
|
|
711
|
+
server: { data: async () => ({ slug: 'my-product-slug' }) },
|
|
712
|
+
view: (state, server) => `<main id="main-content"><h1>${server.data.slug}</h1></main>`,
|
|
713
|
+
}
|
|
714
|
+
await withServer([spec], { stream: false }, async (port) => {
|
|
715
|
+
const { body } = await get(port, '/product')
|
|
716
|
+
assert(body.includes(`<link rel="canonical" href="https://example.com/products/my-product-slug">`),
|
|
717
|
+
`Expected serverState-derived canonical, got: ${body.slice(0, 500)}`)
|
|
718
|
+
})
|
|
719
|
+
})
|
|
720
|
+
|
|
721
|
+
await test('meta.canonical as a function receives ctx in streaming mode', async () => {
|
|
722
|
+
const spec = {
|
|
723
|
+
route: '/about',
|
|
724
|
+
meta: { title: 'About', styles: [], canonical: (ctx) => `https://example.com/about` },
|
|
725
|
+
state: {},
|
|
726
|
+
view: () => `<main id="main-content"><h1>About</h1></main>`,
|
|
727
|
+
}
|
|
728
|
+
await withServer([spec], { stream: true }, async (port) => {
|
|
729
|
+
const { body } = await get(port, '/about')
|
|
730
|
+
assert(body.includes(`<link rel="canonical" href="https://example.com/about">`),
|
|
731
|
+
`Expected function canonical in stream, got: ${body.slice(0, 500)}`)
|
|
732
|
+
})
|
|
733
|
+
})
|
|
734
|
+
|
|
688
735
|
await test('canonical tag injected in streaming response', async () => {
|
|
689
736
|
await withServer([canonicalSpec], { stream: true }, async (port) => {
|
|
690
737
|
const { body } = await get(port, '/about')
|
package/src/ui/stat.js
CHANGED
|
@@ -39,7 +39,7 @@ export function stat({
|
|
|
39
39
|
|
|
40
40
|
const changeHtml = change
|
|
41
41
|
? `<p class="ui-stat-change ui-stat-change--${e(trend)}">
|
|
42
|
-
<span aria-label="${e(TREND_LABELS[trend])}">${TREND_ICONS[trend]}</span>
|
|
42
|
+
<span role="img" aria-label="${e(TREND_LABELS[trend])}">${TREND_ICONS[trend]}</span>
|
|
43
43
|
${e(change)}
|
|
44
44
|
</p>`
|
|
45
45
|
: ''
|
package/src/ui/ui.test.js
CHANGED
|
@@ -346,6 +346,12 @@ test('stat: renders change with trend class', () => {
|
|
|
346
346
|
assert.match(html, /\+5%/)
|
|
347
347
|
})
|
|
348
348
|
|
|
349
|
+
test('stat: trend icon span has role=img for valid aria-label', () => {
|
|
350
|
+
const html = stat({ label: 'X', value: '10', change: '+5%', trend: 'up' })
|
|
351
|
+
assert.match(html, /role="img"/)
|
|
352
|
+
assert.match(html, /aria-label="increase"/)
|
|
353
|
+
})
|
|
354
|
+
|
|
349
355
|
test('stat: no change rendered when change is empty', () => {
|
|
350
356
|
const html = stat({ label: 'X', value: '10' })
|
|
351
357
|
assert.doesNotMatch(html, /ui-stat-change/)
|
|
@@ -1,115 +0,0 @@
|
|
|
1
|
-
import{a}from"./runtime-QFURDKA2.js";import{a as r,b as c,c as l,d,e,g as t,h as i,i as u}from"./runtime-L2HNXIHW.js";import{a as s,b as m}from"./runtime-B73WLANC.js";var{prev:p,next:g}=r("/accessibility"),n={route:"/accessibility",meta:{title:"Accessibility \u2014 Pulse Docs",description:"Keyboard navigation, focus management, semantic HTML, and ARIA patterns in Pulse.",styles:["/docs.css"]},state:{},view:()=>c({currentHref:"/accessibility",prev:p,next:g,content:`
|
|
2
|
-
${l("Accessibility")}
|
|
3
|
-
${d("Pulse enforces a 100 Lighthouse Accessibility score as the baseline. The foundations \u2014 skip link, focus styles, and focus management on navigation \u2014 are provided by the framework on every page. There is nothing to configure and nothing to forget.")}
|
|
4
|
-
|
|
5
|
-
${e("built-in","What the framework provides")}
|
|
6
|
-
${i(["Feature","How"],[["Skip link","Injected on every page as the first focusable element. Targets <code>#main-content</code>. Visible only on focus."],["Focus styles","<code>:focus-visible</code> outline applied globally \u2014 purple, 3px, offset 2px. Suppressed for mouse users via <code>:focus:not(:focus-visible)</code>."],["Navigation focus","After client-side navigation, focus moves to <code>#main-content</code>, <code><main></code>, or <code><h1></code> \u2014 whichever is found first. Screen reader users hear the new page heading without a full reload."]])}
|
|
7
|
-
${u("note",'The skip link targets <code>#main-content</code>. Pages use <code><main id="main-content"></code> as the content wrapper so the link resolves correctly.')}
|
|
8
|
-
|
|
9
|
-
${e("structure","Page structure")}
|
|
10
|
-
<p>Each page view is wrapped in <code><main id="main-content"></code> with a single <code><h1></code> that describes the current page. Landmark elements communicate structure to assistive technology:</p>
|
|
11
|
-
${t(a(`view: (state) => \`
|
|
12
|
-
<main id="main-content">
|
|
13
|
-
<h1>Page title</h1>
|
|
14
|
-
<!-- page content -->
|
|
15
|
-
</main>
|
|
16
|
-
\``,"js"))}
|
|
17
|
-
${i(["Element","Purpose"],[["<code><header></code>","Site header, logo, primary nav"],["<code><nav></code>","Navigation links \u2014 <code>aria-label</code> distinguishes multiple navs"],['<code><main id="main-content"></code>',"Primary page content \u2014 one per page"],["<code><aside></code>","Supplementary content (sidebars, related links)"],["<code><footer></code>","Site footer"]])}
|
|
18
|
-
|
|
19
|
-
${e("interactive","Interactive elements")}
|
|
20
|
-
<p>Actions are expressed as <code><button></code> elements, navigation as <code><a href></code> links. Both are keyboard-accessible by default. <code><div></code> and <code><span></code> elements with click handlers are not reachable by keyboard:</p>
|
|
21
|
-
${t(a(`<!-- Keyboard accessible -->
|
|
22
|
-
<button data-event="toggle">Open menu</button>
|
|
23
|
-
<a href="/about">About</a>
|
|
24
|
-
|
|
25
|
-
<!-- Not keyboard accessible \u2014 avoid -->
|
|
26
|
-
<div data-event="toggle">Open menu</div>
|
|
27
|
-
<span onclick="...">About</span>`,"html"))}
|
|
28
|
-
<p>Buttons that toggle state carry <code>aria-expanded</code> or <code>aria-pressed</code> to communicate the current state to screen readers:</p>
|
|
29
|
-
${t(a(`view: (state) => \`
|
|
30
|
-
<button data-event="toggleMenu" aria-expanded="\${state.menuOpen}">
|
|
31
|
-
Menu
|
|
32
|
-
</button>
|
|
33
|
-
\${state.menuOpen ? \`<nav>...</nav>\` : ''}
|
|
34
|
-
\``,"js"))}
|
|
35
|
-
|
|
36
|
-
${e("focus","Focus management")}
|
|
37
|
-
<p>When a modal or dialog opens, focus moves inside it. When it closes, focus returns to the element that opened it. Since Pulse updates the DOM via morphing rather than a full replacement, the triggering element stays in the DOM and receives focus back naturally.</p>
|
|
38
|
-
<p>The <code>autofocus</code> attribute on the first interactive element inside newly revealed content moves focus there after the DOM update \u2014 no JavaScript required:</p>
|
|
39
|
-
${t(a(`view: (state) => \`
|
|
40
|
-
<button data-event="openDialog">Delete item</button>
|
|
41
|
-
|
|
42
|
-
\${state.dialogOpen ? \`
|
|
43
|
-
<div role="dialog" aria-modal="true" aria-labelledby="dialog-title">
|
|
44
|
-
<h2 id="dialog-title">Confirm deletion</h2>
|
|
45
|
-
<p>This cannot be undone.</p>
|
|
46
|
-
<button autofocus data-event="confirmDelete">Delete</button>
|
|
47
|
-
<button data-event="closeDialog">Cancel</button>
|
|
48
|
-
</div>
|
|
49
|
-
\` : ''}
|
|
50
|
-
\``,"js"))}
|
|
51
|
-
|
|
52
|
-
${e("live-regions","Dynamic content announcements")}
|
|
53
|
-
<p>State changes that produce status messages \u2014 loading indicators, confirmations, validation errors \u2014 are wrapped in live regions so screen readers announce them without a page reload:</p>
|
|
54
|
-
${i(["Role","Politeness","Used for"],[['<code>role="status"</code>',"Polite \u2014 waits for the user to finish","Loading states, success messages, counts"],['<code>role="alert"</code>',"Assertive \u2014 interrupts immediately","Validation errors, destructive confirmations"]])}
|
|
55
|
-
${t(a(`view: (state) => \`
|
|
56
|
-
<form data-action="submit">
|
|
57
|
-
<!-- fields -->
|
|
58
|
-
<button type="submit" \${state.status === 'loading' ? 'disabled' : ''}>
|
|
59
|
-
\${state.status === 'loading' ? 'Saving\u2026' : 'Save'}
|
|
60
|
-
</button>
|
|
61
|
-
</form>
|
|
62
|
-
|
|
63
|
-
\${state.status === 'loading' ? \`
|
|
64
|
-
<p role="status">Saving\u2026</p>
|
|
65
|
-
\` : ''}
|
|
66
|
-
|
|
67
|
-
\${state.errors.length ? \`
|
|
68
|
-
<div role="alert">
|
|
69
|
-
\${state.errors.map(e => \`<p>\${e.message}</p>\`).join('')}
|
|
70
|
-
</div>
|
|
71
|
-
\` : ''}
|
|
72
|
-
\``,"js"))}
|
|
73
|
-
|
|
74
|
-
${e("forms","Forms")}
|
|
75
|
-
<p>Form controls are paired with <code><label></code> elements. Error messages are linked to their input via <code>aria-describedby</code> so screen readers announce them when the field receives focus:</p>
|
|
76
|
-
${t(a(`<form data-action="submit">
|
|
77
|
-
<div class="field">
|
|
78
|
-
<label for="email">Email</label>
|
|
79
|
-
<input
|
|
80
|
-
id="email"
|
|
81
|
-
name="email"
|
|
82
|
-
type="email"
|
|
83
|
-
required
|
|
84
|
-
aria-describedby="\${state.emailError ? 'email-error' : ''}"
|
|
85
|
-
>
|
|
86
|
-
\${state.emailError
|
|
87
|
-
? \`<p id="email-error" role="alert">\${state.emailError}</p>\`
|
|
88
|
-
: ''}
|
|
89
|
-
</div>
|
|
90
|
-
|
|
91
|
-
<fieldset>
|
|
92
|
-
<legend>Notification preferences</legend>
|
|
93
|
-
<label><input type="checkbox" name="email-notifs"> Email</label>
|
|
94
|
-
<label><input type="checkbox" name="sms-notifs"> SMS</label>
|
|
95
|
-
</fieldset>
|
|
96
|
-
|
|
97
|
-
<button type="submit">Submit</button>
|
|
98
|
-
</form>`,"html"))}
|
|
99
|
-
|
|
100
|
-
${e("images","Images")}
|
|
101
|
-
<p>Decorative images carry <code>alt=""</code> so screen readers skip them. Informative images have descriptive alt text. Icon-only buttons are labelled with <code>aria-label</code>, with the icon marked <code>aria-hidden="true"</code>:</p>
|
|
102
|
-
${t(a(`<!-- Informative image -->
|
|
103
|
-
<img src="/team.jpg" alt="The Pulse team at the 2025 offsite" width="800" height="450">
|
|
104
|
-
|
|
105
|
-
<!-- Decorative image -->
|
|
106
|
-
<img src="/divider.svg" alt="" width="600" height="4">
|
|
107
|
-
|
|
108
|
-
<!-- Icon-only button -->
|
|
109
|
-
<button aria-label="Close">
|
|
110
|
-
<svg aria-hidden="true" focusable="false">...</svg>
|
|
111
|
-
</button>`,"html"))}
|
|
112
|
-
|
|
113
|
-
${e("lighthouse","Lighthouse score")}
|
|
114
|
-
<p>Every page is expected to score 100 on Lighthouse Accessibility. Run <code>/pulse-report</code> after every new page or significant change. Regressions \u2014 contrast failures, missing labels, unreachable controls \u2014 are caught before they reach users.</p>
|
|
115
|
-
`})};var o=document.getElementById("pulse-root");o&&!o.dataset.pulseMounted&&(o.dataset.pulseMounted="1",s(n,o,window.__PULSE_SERVER__||{},{ssr:!0}),m(o,s));var k=n;export{k as default};
|
|
@@ -1,164 +0,0 @@
|
|
|
1
|
-
import{a as o}from"./runtime-QFURDKA2.js";import{a as i,b as d,c,d as l,e,g as t,h as u,i as a}from"./runtime-L2HNXIHW.js";import{a as s,b as m}from"./runtime-B73WLANC.js";var{prev:p,next:f}=i("/actions"),n={route:"/actions",meta:{title:"Actions \u2014 Pulse Docs",description:"Async operations in Pulse \u2014 lifecycle, FormData, error handling.",styles:["/docs.css"]},state:{},view:()=>d({currentHref:"/actions",prev:p,next:f,content:`
|
|
2
|
-
${c("Actions")}
|
|
3
|
-
${l("Actions handle async operations with an enforced lifecycle. The order of steps \u2014 capture inputs, validate, run, succeed or fail \u2014 is fixed. Skipping validation or running async work before showing a loading state is not possible within the action structure.")}
|
|
4
|
-
|
|
5
|
-
${e("lifecycle","The action lifecycle")}
|
|
6
|
-
<p>When a form with <code>data-action</code> is submitted, Pulse runs the action through a fixed sequence of steps:</p>
|
|
7
|
-
${t(o(`onStart(state, formData)
|
|
8
|
-
\u2193 (optional) validate \u2014 checks spec.validation rules
|
|
9
|
-
run(state, serverState, formData)
|
|
10
|
-
\u2193 success \u2193 error
|
|
11
|
-
onSuccess(state, payload) onError(state, err)`,"bash"))}
|
|
12
|
-
<p>Each step triggers a view re-render, so the UI always reflects the current state \u2014 loading, validated, succeeded, or failed. The sequence cannot be reordered.</p>
|
|
13
|
-
|
|
14
|
-
${e("defining","Defining an action")}
|
|
15
|
-
${t(o(`export default {
|
|
16
|
-
route: '/contact',
|
|
17
|
-
state: {
|
|
18
|
-
status: 'idle', // 'idle' | 'loading' | 'success' | 'error'
|
|
19
|
-
errors: [],
|
|
20
|
-
},
|
|
21
|
-
validation: {
|
|
22
|
-
'fields.name': { required: true, minLength: 2 },
|
|
23
|
-
'fields.email': { required: true, format: 'email' },
|
|
24
|
-
'fields.message': { required: true, minLength: 10 },
|
|
25
|
-
},
|
|
26
|
-
actions: {
|
|
27
|
-
submit: {
|
|
28
|
-
// 1. Immediately update state \u2014 show loading indicator
|
|
29
|
-
onStart: (state, formData) => ({
|
|
30
|
-
status: 'loading',
|
|
31
|
-
errors: [],
|
|
32
|
-
// Capture form values into state before validation runs
|
|
33
|
-
fields: {
|
|
34
|
-
name: formData.get('name'),
|
|
35
|
-
email: formData.get('email'),
|
|
36
|
-
message: formData.get('message'),
|
|
37
|
-
},
|
|
38
|
-
}),
|
|
39
|
-
|
|
40
|
-
// 2. Run spec.validation before proceeding to run()
|
|
41
|
-
validate: true,
|
|
42
|
-
|
|
43
|
-
// 3. Perform the async work
|
|
44
|
-
run: async (state, serverState, formData) => {
|
|
45
|
-
const res = await fetch('/api/contact', {
|
|
46
|
-
method: 'POST',
|
|
47
|
-
headers: { 'Content-Type': 'application/json' },
|
|
48
|
-
body: JSON.stringify(Object.fromEntries(formData)),
|
|
49
|
-
})
|
|
50
|
-
if (!res.ok) throw new Error('Request failed')
|
|
51
|
-
return res.json()
|
|
52
|
-
},
|
|
53
|
-
|
|
54
|
-
// 4a. Success
|
|
55
|
-
onSuccess: (state, payload) => ({
|
|
56
|
-
status: 'success',
|
|
57
|
-
errors: [],
|
|
58
|
-
}),
|
|
59
|
-
|
|
60
|
-
// 4b. Error \u2014 payload may have validation errors
|
|
61
|
-
onError: (state, err) => ({
|
|
62
|
-
status: 'error',
|
|
63
|
-
errors: err?.validation ?? [{ message: err.message }],
|
|
64
|
-
}),
|
|
65
|
-
},
|
|
66
|
-
},
|
|
67
|
-
}`,"js"))}
|
|
68
|
-
|
|
69
|
-
${e("binding","Binding actions to forms")}
|
|
70
|
-
<p>A <code>data-action</code> attribute on a <code><form></code> element binds it to an action. When the form is submitted, Pulse creates a <code>FormData</code> object from the form's inputs and passes it through the action lifecycle:</p>
|
|
71
|
-
${t(o(`<form data-action="submit">
|
|
72
|
-
<input name="name" type="text" placeholder="Your name">
|
|
73
|
-
<input name="email" type="email" placeholder="Email">
|
|
74
|
-
<textarea name="message" placeholder="Message"></textarea>
|
|
75
|
-
<button type="submit">Send</button>
|
|
76
|
-
</form>`,"html"))}
|
|
77
|
-
${a("note","Pulse intercepts and prevents the default form submission. The action lifecycle is fully in control of what happens with the data \u2014 no manual <code>event.preventDefault()</code> needed.")}
|
|
78
|
-
|
|
79
|
-
${e("on-start","onStart")}
|
|
80
|
-
<p><code>onStart(state, formData)</code> runs synchronously as soon as the form is submitted, before any async work begins. It sets a loading state, captures form values into state so validation can check them, and clears previous errors.</p>
|
|
81
|
-
${a("warning","<code>onStart</code> runs <strong>before</strong> validation. <code>FormData</code> values are captured into state first, because the HTML re-renders (destroying the form inputs) once validation runs. All form values are captured here so validation can read them from state via dot-paths.")}
|
|
82
|
-
|
|
83
|
-
${e("validate","validate")}
|
|
84
|
-
<p>Set <code>validate: true</code> to run the spec's <a href="/validation">validation rules</a> after <code>onStart</code>. If validation fails, <code>onError</code> is called immediately \u2014 <code>run</code> is never reached. Async work cannot execute against invalid input.</p>
|
|
85
|
-
${t(o(`// Validation error structure
|
|
86
|
-
{
|
|
87
|
-
message: 'Validation failed',
|
|
88
|
-
validation: [
|
|
89
|
-
{ field: 'fields.email', rule: 'format', message: 'Must be a valid email' },
|
|
90
|
-
{ field: 'fields.name', rule: 'required', message: 'Required' },
|
|
91
|
-
]
|
|
92
|
-
}`,"js"))}
|
|
93
|
-
<p>Access the errors in <code>onError</code>:</p>
|
|
94
|
-
${t(o(`onError: (state, err) => ({
|
|
95
|
-
status: 'error',
|
|
96
|
-
errors: err?.validation ?? [{ message: err.message }],
|
|
97
|
-
})`,"js"))}
|
|
98
|
-
|
|
99
|
-
${e("run","run")}
|
|
100
|
-
<p><code>run(state, serverState, formData)</code> is where the async work happens. Throw or reject to trigger <code>onError</code>. The return value is passed to <code>onSuccess</code> as <code>payload</code>.</p>
|
|
101
|
-
${t(o(`run: async (state, serverState, formData) => {
|
|
102
|
-
const res = await fetch('/api/submit', {
|
|
103
|
-
method: 'POST',
|
|
104
|
-
body: formData,
|
|
105
|
-
})
|
|
106
|
-
if (!res.ok) {
|
|
107
|
-
const err = await res.json()
|
|
108
|
-
throw Object.assign(new Error('Server error'), err)
|
|
109
|
-
}
|
|
110
|
-
return res.json() // \u2192 onSuccess payload
|
|
111
|
-
},`,"js"))}
|
|
112
|
-
|
|
113
|
-
${e("on-success","onSuccess")}
|
|
114
|
-
<p><code>onSuccess(state, payload)</code> receives the current state and whatever <code>run</code> returned. Return a partial state update:</p>
|
|
115
|
-
${t(o(`onSuccess: (state, payload) => ({
|
|
116
|
-
status: 'success',
|
|
117
|
-
userId: payload.id,
|
|
118
|
-
})`,"js"))}
|
|
119
|
-
|
|
120
|
-
${e("on-error","onError")}
|
|
121
|
-
<p><code>onError(state, err)</code> receives the current state and the thrown error. Return a partial state update to surface the error in the view:</p>
|
|
122
|
-
${t(o(`onError: (state, err) => ({
|
|
123
|
-
status: 'error',
|
|
124
|
-
errors: err?.validation ?? [{ message: err.message }],
|
|
125
|
-
})`,"js"))}
|
|
126
|
-
|
|
127
|
-
${e("toast","Toast notifications")}
|
|
128
|
-
<p>Return <code>_toast</code> from any action hook to show a notification. It is stripped from spec state automatically \u2014 it never appears in <code>getState()</code> or the view.</p>
|
|
129
|
-
${t(o(`onSuccess: (state, payload) => ({
|
|
130
|
-
status: 'success',
|
|
131
|
-
_toast: { message: 'Saved successfully', variant: 'success' },
|
|
132
|
-
}),
|
|
133
|
-
|
|
134
|
-
onError: (state, err) => ({
|
|
135
|
-
status: 'error',
|
|
136
|
-
errors: err?.validation ?? [{ message: err.message }],
|
|
137
|
-
_toast: { message: 'Something went wrong', variant: 'error' },
|
|
138
|
-
}),`,"js"))}
|
|
139
|
-
<p><code>_toast</code> works in <code>onStart</code>, <code>onSuccess</code>, and <code>onError</code>, and also in mutations. The toast container is injected into <code>document.body</code> once and survives client-side navigations.</p>
|
|
140
|
-
${u(["Option","Type","Default",""],[["<code>message</code>","string","\u2014","Required. The notification text."],["<code>variant</code>","<code>success | error | warning | info</code>","<code>info</code>",""],["<code>duration</code>","number (ms)","<code>4000</code>","Auto-dismiss delay. <code>0</code> = sticky until dismissed."]])}
|
|
141
|
-
|
|
142
|
-
${e("store-update","Pushing to the global store")}
|
|
143
|
-
<p>Return <code>_storeUpdate</code> from <code>onSuccess</code> to push a partial update into the <a href="/store">global store</a>. Every mounted page that subscribes to the updated keys re-renders immediately \u2014 no navigation, no polling.</p>
|
|
144
|
-
${t(o(`onSuccess: (state, theme) => ({
|
|
145
|
-
saved: true,
|
|
146
|
-
_storeUpdate: { settings: { theme } }, // \u2190 merged into global store state
|
|
147
|
-
}),`,"js"))}
|
|
148
|
-
<p><code>_storeUpdate</code> is stripped from the page's own state \u2014 only the rest of the return object is merged into local state as usual. See <a href="/store">Global Store</a> for the full store API.</p>
|
|
149
|
-
|
|
150
|
-
${e("rendering-errors","Rendering errors in the view")}
|
|
151
|
-
${t(o(`view: (state) => \`
|
|
152
|
-
<form data-action="submit">
|
|
153
|
-
\${state.errors.map(e => \`
|
|
154
|
-
<p class="error">
|
|
155
|
-
\${e.field ? \`<strong>\${e.field}:</strong> \` : ''}\${e.message}
|
|
156
|
-
</p>
|
|
157
|
-
\`).join('')}
|
|
158
|
-
<!-- ... form fields ... -->
|
|
159
|
-
<button \${state.status === 'loading' ? 'disabled' : ''}>
|
|
160
|
-
\${state.status === 'loading' ? 'Sending\u2026' : 'Send'}
|
|
161
|
-
</button>
|
|
162
|
-
</form>
|
|
163
|
-
\``,"js"))}
|
|
164
|
-
`})};var r=document.getElementById("pulse-root");r&&!r.dataset.pulseMounted&&(r.dataset.pulseMounted="1",s(n,r,window.__PULSE_SERVER__||{},{ssr:!0}),m(r,s));var $=n;export{$ as default};
|