@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.
Files changed (124) hide show
  1. package/.github/workflows/publish.yml +11 -20
  2. package/README.md +1 -1
  3. package/docs/public/.pulse-ui-version +1 -1
  4. package/docs/public/docs.css +19 -1
  5. package/docs/public/pulse-ui.css +1 -0
  6. package/docs/server.js +5 -2
  7. package/docs/src/lib/highlight.js +57 -13
  8. package/docs/src/lib/layout.js +5 -2
  9. package/docs/src/pages/faq.js +5 -2
  10. package/docs/src/pages/home.js +9 -5
  11. package/docs/src/pages/meta.js +21 -0
  12. package/docs/src/pages/routing.js +12 -1
  13. package/package.json +1 -1
  14. package/public/pulse-ui.css +2 -2
  15. package/src/agent/guide-components.md +1 -1
  16. package/src/agent/guide-routing.md +20 -0
  17. package/src/agent/guide-spec.md +10 -1
  18. package/src/agent/guide-styles.md +16 -1
  19. package/src/agent/workflow.md +1 -1
  20. package/src/cli/scaffold.js +63 -2
  21. package/src/mcp/server.js +34 -18
  22. package/src/server/index.js +26 -7
  23. package/src/server/server.test.js +47 -0
  24. package/src/ui/stat.js +1 -1
  25. package/src/ui/ui.test.js +6 -0
  26. package/docs/public/dist/accessibility.boot-5DVTARJU.js +0 -115
  27. package/docs/public/dist/actions.boot-P66HKQEM.js +0 -164
  28. package/docs/public/dist/auth.boot-IMAJAUPH.js +0 -140
  29. package/docs/public/dist/caching.boot-DVR6KDE7.js +0 -53
  30. package/docs/public/dist/components--accordion.boot-3HVKMNWC.js +0 -11
  31. package/docs/public/dist/components--alert.boot-GCEXOZAC.js +0 -6
  32. package/docs/public/dist/components--app-badge.boot-DVT3GCHJ.js +0 -6
  33. package/docs/public/dist/components--avatar.boot-PSW24EVA.js +0 -5
  34. package/docs/public/dist/components--badge.boot-TYDY2RMK.js +0 -7
  35. package/docs/public/dist/components--banner.boot-EI5PZSZK.js +0 -7
  36. package/docs/public/dist/components--breadcrumbs.boot-SMA2E2GO.js +0 -34
  37. package/docs/public/dist/components--button.boot-J54BQM2E.js +0 -23
  38. package/docs/public/dist/components--card.boot-PZGNDIB6.js +0 -138
  39. package/docs/public/dist/components--carousel.boot-TP6LPFZZ.js +0 -12
  40. package/docs/public/dist/components--charts.boot-2EOYQWKL.js +0 -108
  41. package/docs/public/dist/components--checkbox.boot-DS5BSL6T.js +0 -54
  42. package/docs/public/dist/components--cluster.boot-HHVIBBJG.js +0 -9
  43. package/docs/public/dist/components--code-window.boot-2GR2DV33.js +0 -20
  44. package/docs/public/dist/components--container.boot-7LOOGK2K.js +0 -5
  45. package/docs/public/dist/components--cta.boot-FSNZ5YRT.js +0 -11
  46. package/docs/public/dist/components--divider.boot-3NI2C3QG.js +0 -6
  47. package/docs/public/dist/components--empty.boot-YX2UR3PV.js +0 -7
  48. package/docs/public/dist/components--feature.boot-MUD7NSUO.js +0 -13
  49. package/docs/public/dist/components--fieldset.boot-J7BYHMKF.js +0 -19
  50. package/docs/public/dist/components--fileupload.boot-NIKVTTPD.js +0 -52
  51. package/docs/public/dist/components--footer.boot-EYUK5FRG.js +0 -14
  52. package/docs/public/dist/components--grid.boot-URDQVDDR.js +0 -59
  53. package/docs/public/dist/components--heading.boot-BPQKU43E.js +0 -44
  54. package/docs/public/dist/components--hero.boot-4RAPRGAB.js +0 -17
  55. package/docs/public/dist/components--icons.boot-ZITNU5JP.js +0 -68
  56. package/docs/public/dist/components--image.boot-XEEGHQZF.js +0 -19
  57. package/docs/public/dist/components--input.boot-SGASZG5K.js +0 -7
  58. package/docs/public/dist/components--list.boot-W3XC5MHD.js +0 -55
  59. package/docs/public/dist/components--media.boot-5VFIETZO.js +0 -13
  60. package/docs/public/dist/components--modal.boot-RZUYXBN2.js +0 -47
  61. package/docs/public/dist/components--nav.boot-ODBOHU7O.js +0 -33
  62. package/docs/public/dist/components--pricing.boot-4AQ4ZVBY.js +0 -21
  63. package/docs/public/dist/components--progress.boot-GHAGYZOK.js +0 -30
  64. package/docs/public/dist/components--prose.boot-QANJL6JI.js +0 -67
  65. package/docs/public/dist/components--pullquote.boot-Q2WMNAZU.js +0 -22
  66. package/docs/public/dist/components--radio.boot-TJRDQ2OL.js +0 -75
  67. package/docs/public/dist/components--rating.boot-QBAN6DEL.js +0 -38
  68. package/docs/public/dist/components--search.boot-PXH5O5AG.js +0 -17
  69. package/docs/public/dist/components--section.boot-AQGIYHWW.js +0 -12
  70. package/docs/public/dist/components--segmented.boot-BEVTKEJO.js +0 -33
  71. package/docs/public/dist/components--select.boot-47X5RHOC.js +0 -10
  72. package/docs/public/dist/components--slider.boot-PSRRX7XL.js +0 -47
  73. package/docs/public/dist/components--spinner.boot-MZ5MO2OH.js +0 -22
  74. package/docs/public/dist/components--stack.boot-DI4NJXBF.js +0 -9
  75. package/docs/public/dist/components--stat.boot-QMFUWBQT.js +0 -9
  76. package/docs/public/dist/components--stepper.boot-34PP2NEV.js +0 -22
  77. package/docs/public/dist/components--table.boot-FCQGSFIQ.js +0 -11
  78. package/docs/public/dist/components--testimonial.boot-DWQPDKYG.js +0 -11
  79. package/docs/public/dist/components--textarea.boot-QVXLBOJ5.js +0 -4
  80. package/docs/public/dist/components--timeline.boot-26LN52P2.js +0 -95
  81. package/docs/public/dist/components--toggle.boot-IQQEI76S.js +0 -29
  82. package/docs/public/dist/components--tooltip.boot-LGHCO6NN.js +0 -9
  83. package/docs/public/dist/components.boot-SE6PQ4P7.js +0 -103
  84. package/docs/public/dist/config.boot-DTRRWUE6.js +0 -126
  85. package/docs/public/dist/constraints.boot-DUHDZBMC.js +0 -71
  86. package/docs/public/dist/deploy.boot-SLAD3NI2.js +0 -163
  87. package/docs/public/dist/docs-8e3d4b5c.css +0 -1
  88. package/docs/public/dist/extending.boot-UA3CN243.js +0 -159
  89. package/docs/public/dist/faq.boot-6EQAWLQR.js +0 -43
  90. package/docs/public/dist/getting-started.boot-TDKIFL5U.js +0 -86
  91. package/docs/public/dist/guard.boot-AUHAWTG4.js +0 -80
  92. package/docs/public/dist/home.boot-BVQXRH32.js +0 -383
  93. package/docs/public/dist/how-it-works.boot-LTWAKWKW.js +0 -104
  94. package/docs/public/dist/hydration.boot-JRM6IPJL.js +0 -78
  95. package/docs/public/dist/images.boot-M6ZVKTZS.js +0 -80
  96. package/docs/public/dist/manifest.json +0 -94
  97. package/docs/public/dist/meta.boot-7NXGPHR4.js +0 -79
  98. package/docs/public/dist/mutations.boot-F6F43UDX.js +0 -79
  99. package/docs/public/dist/navigation.boot-AOXWS3ZF.js +0 -57
  100. package/docs/public/dist/performance.boot-C3UPCOBK.js +0 -98
  101. package/docs/public/dist/persist.boot-WT32PQOQ.js +0 -61
  102. package/docs/public/dist/project-structure.boot-FB3LRVJ4.js +0 -63
  103. package/docs/public/dist/prompt-examples.boot-YKR4VDK4.js +0 -31
  104. package/docs/public/dist/pulse-ui-81a85c03.css +0 -1
  105. package/docs/public/dist/raw-responses.boot-M4KA5YXL.js +0 -104
  106. package/docs/public/dist/routing.boot-FNX5FDGH.js +0 -70
  107. package/docs/public/dist/runtime-B73WLANC.js +0 -1
  108. package/docs/public/dist/runtime-KO4BHUQ3.js +0 -49
  109. package/docs/public/dist/runtime-L2HNXIHW.js +0 -59
  110. package/docs/public/dist/runtime-QFURDKA2.js +0 -5
  111. package/docs/public/dist/runtime-UVPXO4IR.js +0 -375
  112. package/docs/public/dist/runtime-VMJA3Z4N.js +0 -10
  113. package/docs/public/dist/runtime-ZJ4FXT5O.js +0 -11
  114. package/docs/public/dist/server-api.boot-K7X3LCFB.js +0 -219
  115. package/docs/public/dist/server-data.boot-Y7HQYC4R.js +0 -157
  116. package/docs/public/dist/slash-commands.boot-V2UV7OW2.js +0 -26
  117. package/docs/public/dist/spec.boot-2WU7ZHCV.js +0 -159
  118. package/docs/public/dist/state.boot-B24GUE3R.js +0 -73
  119. package/docs/public/dist/store.boot-TLIB4XHH.js +0 -150
  120. package/docs/public/dist/streaming.boot-W2DZSMW4.js +0 -80
  121. package/docs/public/dist/stripe.boot-QN3C2GEL.js +0 -164
  122. package/docs/public/dist/supabase.boot-BG4XXLZE.js +0 -303
  123. package/docs/public/dist/testing.boot-6U4WKMTE.js +0 -130
  124. 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: `Create a new page in the Pulse project. Filename determines route: home.js /, about.js → /about.
236
+ description: `Validate and register a page spec that you have already written to disk with the Write tool.
237
237
 
238
- IMPORTANT: Always follow these rules when writing the spec content:
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 (u-flex, u-flex-col, u-gap-4, u-mt-8, u-text-center, etc.) — never inline styles
242
- - Use var(--ui-*) CSS tokens in any custom CSS — never hardcode hex colours
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: z.string().describe('Filename without extension, e.g. "about" or "blog/post"'),
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, content }) => {
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.mkdirSync(path.dirname(fullPath), { recursive: true })
263
- fs.writeFileSync(fullPath, content, 'utf8')
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(`Created ${path.relative(ROOT, fullPath)} → route "${route}"`)
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: 'Re-copy pulse-ui.css, pulse-ui.js, and the agent checklist from the installed package into public/. Run after npm update @invisibleloop/pulse, or when visual output looks wrong and you suspect stale CSS.',
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(`pulse-ui updated to v${version}\n\n${updated.map(f => `✓ ${f}`).join('\n')}`)
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\` — create a new page spec. Validates before writing.
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 from the installed package into \`public/\`. Run this after \`npm update @invisibleloop/pulse\`, or whenever visual output looks wrong and you suspect stale CSS.
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.
@@ -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 } from 'util'
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
- // Build canonical URL — prefer spec.meta.canonical, otherwise derive from request.
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 canonicalUrl = spec.meta?.canonical || `${proto}://${host}${canonicalPath}`
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, canonicalUrl, nonce, runtimeBundle, defaultCache, store, csp)
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, canonicalUrl, nonce, runtimeBundle, defaultCache, store, csp)
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, canonicalUrl = '', nonce = '', runtimeBundle = '', defaultCache = null, store = null, csp = {}) {
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, canonicalUrl = '', nonce = '', runtimeBundle = '', defaultCache = null, store = null, csp = {}) {
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>&lt;main&gt;</code>, or <code>&lt;h1&gt;</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>&lt;main id="main-content"&gt;</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>&lt;main id="main-content"&gt;</code> with a single <code>&lt;h1&gt;</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>&lt;header&gt;</code>","Site header, logo, primary nav"],["<code>&lt;nav&gt;</code>","Navigation links \u2014 <code>aria-label</code> distinguishes multiple navs"],['<code>&lt;main id="main-content"&gt;</code>',"Primary page content \u2014 one per page"],["<code>&lt;aside&gt;</code>","Supplementary content (sidebars, related links)"],["<code>&lt;footer&gt;</code>","Site footer"]])}
18
-
19
- ${e("interactive","Interactive elements")}
20
- <p>Actions are expressed as <code>&lt;button&gt;</code> elements, navigation as <code>&lt;a href&gt;</code> links. Both are keyboard-accessible by default. <code>&lt;div&gt;</code> and <code>&lt;span&gt;</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>&lt;label&gt;</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>&lt;form&gt;</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};