@invisibleloop/pulse 0.4.0 → 0.5.1

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 (81) hide show
  1. package/CLAUDE.md +2 -1
  2. package/docs/src/pages/accessibility.js +0 -1
  3. package/docs/src/pages/auth.js +0 -2
  4. package/docs/src/pages/caching.js +0 -1
  5. package/docs/src/pages/components/accordion.js +0 -1
  6. package/docs/src/pages/components/alert.js +0 -1
  7. package/docs/src/pages/components/app-badge.js +0 -1
  8. package/docs/src/pages/components/avatar.js +0 -1
  9. package/docs/src/pages/components/badge.js +0 -1
  10. package/docs/src/pages/components/banner.js +0 -1
  11. package/docs/src/pages/components/breadcrumbs.js +0 -1
  12. package/docs/src/pages/components/button.js +0 -1
  13. package/docs/src/pages/components/card.js +0 -1
  14. package/docs/src/pages/components/carousel.js +0 -1
  15. package/docs/src/pages/components/charts.js +0 -1
  16. package/docs/src/pages/components/checkbox.js +0 -1
  17. package/docs/src/pages/components/cluster.js +0 -1
  18. package/docs/src/pages/components/code-window.js +0 -1
  19. package/docs/src/pages/components/container.js +0 -1
  20. package/docs/src/pages/components/divider.js +0 -1
  21. package/docs/src/pages/components/empty.js +0 -1
  22. package/docs/src/pages/components/feature.js +0 -1
  23. package/docs/src/pages/components/fieldset.js +0 -1
  24. package/docs/src/pages/components/footer.js +0 -1
  25. package/docs/src/pages/components/grid.js +0 -1
  26. package/docs/src/pages/components/heading.js +0 -1
  27. package/docs/src/pages/components/icons.js +0 -1
  28. package/docs/src/pages/components/image.js +0 -1
  29. package/docs/src/pages/components/input.js +0 -1
  30. package/docs/src/pages/components/list.js +0 -1
  31. package/docs/src/pages/components/media.js +0 -1
  32. package/docs/src/pages/components/modal.js +0 -1
  33. package/docs/src/pages/components/nav.js +0 -1
  34. package/docs/src/pages/components/pricing.js +0 -1
  35. package/docs/src/pages/components/progress.js +0 -1
  36. package/docs/src/pages/components/prose.js +0 -1
  37. package/docs/src/pages/components/pullquote.js +0 -1
  38. package/docs/src/pages/components/radio.js +0 -1
  39. package/docs/src/pages/components/rating.js +0 -1
  40. package/docs/src/pages/components/search.js +0 -1
  41. package/docs/src/pages/components/section.js +0 -1
  42. package/docs/src/pages/components/segmented.js +0 -1
  43. package/docs/src/pages/components/select.js +0 -1
  44. package/docs/src/pages/components/spinner.js +0 -1
  45. package/docs/src/pages/components/stack.js +0 -1
  46. package/docs/src/pages/components/stat.js +0 -1
  47. package/docs/src/pages/components/stepper.js +0 -1
  48. package/docs/src/pages/components/table.js +0 -1
  49. package/docs/src/pages/components/testimonial.js +0 -1
  50. package/docs/src/pages/components/textarea.js +0 -1
  51. package/docs/src/pages/components/timeline.js +0 -1
  52. package/docs/src/pages/components/toggle.js +0 -1
  53. package/docs/src/pages/components/tooltip.js +0 -1
  54. package/docs/src/pages/components.js +0 -1
  55. package/docs/src/pages/config.js +0 -1
  56. package/docs/src/pages/deploy.js +0 -1
  57. package/docs/src/pages/extending.js +0 -1
  58. package/docs/src/pages/faq.js +0 -1
  59. package/docs/src/pages/getting-started.js +0 -1
  60. package/docs/src/pages/guard.js +0 -3
  61. package/docs/src/pages/how-it-works.js +0 -1
  62. package/docs/src/pages/images.js +0 -2
  63. package/docs/src/pages/meta.js +0 -2
  64. package/docs/src/pages/navigation.js +0 -1
  65. package/docs/src/pages/performance.js +0 -2
  66. package/docs/src/pages/project-structure.js +0 -2
  67. package/docs/src/pages/prompt-examples.js +0 -1
  68. package/docs/src/pages/raw-responses.js +0 -4
  69. package/docs/src/pages/routing.js +0 -2
  70. package/docs/src/pages/server-api.js +0 -2
  71. package/docs/src/pages/server-data.js +0 -1
  72. package/docs/src/pages/slash-commands.js +0 -1
  73. package/docs/src/pages/spec.js +3 -2
  74. package/docs/src/pages/streaming.js +0 -2
  75. package/docs/src/pages/testing.js +0 -1
  76. package/docs/src/pages/validation.js +0 -1
  77. package/package.json +1 -1
  78. package/public/.pulse-ui-version +1 -1
  79. package/src/runtime/index.js +4 -4
  80. package/src/spec/schema.js +6 -3
  81. package/src/spec/schema.test.js +7 -2
package/CLAUDE.md CHANGED
@@ -35,7 +35,7 @@ public/
35
35
 
36
36
  ## The Spec
37
37
 
38
- A spec is a plain JS object. Every property is optional except `route`, `state`, and `view`.
38
+ A spec is a plain JS object. Every property is optional except `route` and `view`. `state` defaults to `{}` — omit it on purely server-rendered pages.
39
39
 
40
40
  ```js
41
41
  export const mySpec = {
@@ -62,6 +62,7 @@ export const mySpec = {
62
62
  store: ['user', 'cart'],
63
63
 
64
64
  // Initial client state — deep cloned on mount, never mutated directly
65
+ // Optional — omit entirely on purely server-rendered pages (no mutations/actions/persist)
65
66
  state: { count: 0 },
66
67
 
67
68
  // Declarative min/max bounds — always enforced after mutations
@@ -11,7 +11,6 @@ export default {
11
11
  description: 'Keyboard navigation, focus management, semantic HTML, and ARIA patterns in Pulse.',
12
12
  styles: ['/docs.css'],
13
13
  },
14
- state: {},
15
14
  view: () => renderLayout({
16
15
  currentHref: '/accessibility',
17
16
  prev,
@@ -11,7 +11,6 @@ export default {
11
11
  description: 'Integrating Auth0 OAuth authentication with Pulse using guard functions, ctx.setCookie, and raw response specs.',
12
12
  styles: ['/docs.css'],
13
13
  },
14
- state: {},
15
14
  view: () => renderLayout({
16
15
  currentHref: '/auth',
17
16
  prev,
@@ -141,7 +140,6 @@ export default {
141
140
  profile: async (ctx) => fetchUserProfile(ctx.cookies.session),
142
141
  },
143
142
 
144
- state: {},
145
143
  view: (state, server) => \`
146
144
  <main id="main-content">
147
145
  <h1>Welcome, \${server.profile.name}</h1>
@@ -11,7 +11,6 @@ export default {
11
11
  description: 'HTTP cache headers, in-process server data caching, and asset caching in Pulse.',
12
12
  styles: ['/docs.css'],
13
13
  },
14
- state: {},
15
14
  view: () => renderLayout({
16
15
  currentHref: '/caching',
17
16
  prev,
@@ -12,7 +12,6 @@ export default {
12
12
  description: 'Accordion component for Pulse UI.',
13
13
  styles: ['/pulse-ui.css', '/docs.css'],
14
14
  },
15
- state: {},
16
15
  view: () => renderComponentPage({
17
16
  currentHref: '/components/accordion',
18
17
  prev,
@@ -11,7 +11,6 @@ export default {
11
11
  description: 'Alert component for Pulse UI.',
12
12
  styles: ['/pulse-ui.css', '/docs.css'],
13
13
  },
14
- state: {},
15
14
  view: () => renderComponentPage({
16
15
  currentHref: '/components/alert',
17
16
  prev,
@@ -12,7 +12,6 @@ export default {
12
12
  description: 'App Badge component for Pulse UI.',
13
13
  styles: ['/pulse-ui.css', '/docs.css'],
14
14
  },
15
- state: {},
16
15
  view: () => renderComponentPage({
17
16
  currentHref: '/components/app-badge',
18
17
  prev,
@@ -11,7 +11,6 @@ export default {
11
11
  description: 'Avatar component for Pulse UI.',
12
12
  styles: ['/pulse-ui.css', '/docs.css'],
13
13
  },
14
- state: {},
15
14
  view: () => renderComponentPage({
16
15
  currentHref: '/components/avatar',
17
16
  prev,
@@ -11,7 +11,6 @@ export default {
11
11
  description: 'Badge component for Pulse UI.',
12
12
  styles: ['/pulse-ui.css', '/docs.css'],
13
13
  },
14
- state: {},
15
14
  view: () => renderComponentPage({
16
15
  currentHref: '/components/badge',
17
16
  prev,
@@ -12,7 +12,6 @@ export default {
12
12
  description: 'Banner component for Pulse UI.',
13
13
  styles: ['/pulse-ui.css', '/docs.css'],
14
14
  },
15
- state: {},
16
15
  view: () => renderComponentPage({
17
16
  currentHref: '/components/banner',
18
17
  prev,
@@ -12,7 +12,6 @@ export default {
12
12
  description: 'Accessible breadcrumb navigation component for Pulse UI.',
13
13
  styles: ['/pulse-ui.css', '/docs.css'],
14
14
  },
15
- state: {},
16
15
  view: () => renderComponentPage({
17
16
  currentHref: '/components/breadcrumbs',
18
17
  prev,
@@ -13,7 +13,6 @@ export default {
13
13
  description: 'Button component for Pulse UI.',
14
14
  styles: ['/pulse-ui.css', '/docs.css'],
15
15
  },
16
- state: {},
17
16
  view: () => renderComponentPage({
18
17
  currentHref: '/components/button',
19
18
  prev,
@@ -12,7 +12,6 @@ export default {
12
12
  description: 'Card component for Pulse UI.',
13
13
  styles: ['/pulse-ui.css', '/docs.css'],
14
14
  },
15
- state: {},
16
15
  view: () => renderComponentPage({
17
16
  currentHref: '/components/card',
18
17
  prev,
@@ -16,7 +16,6 @@ export default {
16
16
  description: 'Carousel / slider component for Pulse UI.',
17
17
  styles: ['/pulse-ui.css', '/docs.css'],
18
18
  },
19
- state: {},
20
19
  view: () => renderComponentPage({
21
20
  currentHref: '/components/carousel',
22
21
  prev,
@@ -32,7 +32,6 @@ export default {
32
32
  description: 'Server-rendered SVG charts for Pulse UI.',
33
33
  styles: ['/pulse-ui.css', '/docs.css'],
34
34
  },
35
- state: {},
36
35
  view: () => renderComponentPage({
37
36
  currentHref: '/components/charts',
38
37
  prev,
@@ -12,7 +12,6 @@ export default {
12
12
  description: 'Custom-styled checkbox component for Pulse UI.',
13
13
  styles: ['/pulse-ui.css', '/docs.css'],
14
14
  },
15
- state: {},
16
15
  view: () => renderComponentPage({
17
16
  currentHref: '/components/checkbox',
18
17
  prev,
@@ -12,7 +12,6 @@ export default {
12
12
  description: 'Cluster component for Pulse UI.',
13
13
  styles: ['/pulse-ui.css', '/docs.css'],
14
14
  },
15
- state: {},
16
15
  view: () => renderComponentPage({
17
16
  currentHref: '/components/cluster',
18
17
  prev,
@@ -12,7 +12,6 @@ export default {
12
12
  description: 'macOS-style window chrome around a code block. Accepts pre-highlighted HTML as the content slot.',
13
13
  styles: ['/pulse-ui.css', '/docs.css'],
14
14
  },
15
- state: {},
16
15
  view: () => renderComponentPage({
17
16
  currentHref: '/components/code-window',
18
17
  prev,
@@ -12,7 +12,6 @@ export default {
12
12
  description: 'Container component for Pulse UI.',
13
13
  styles: ['/pulse-ui.css', '/docs.css'],
14
14
  },
15
- state: {},
16
15
  view: () => renderComponentPage({
17
16
  currentHref: '/components/container',
18
17
  prev,
@@ -12,7 +12,6 @@ export default {
12
12
  description: 'Divider component for Pulse UI.',
13
13
  styles: ['/pulse-ui.css', '/docs.css'],
14
14
  },
15
- state: {},
16
15
  view: () => renderComponentPage({
17
16
  currentHref: '/components/divider',
18
17
  prev,
@@ -11,7 +11,6 @@ export default {
11
11
  description: 'Empty state component for Pulse UI.',
12
12
  styles: ['/pulse-ui.css', '/docs.css'],
13
13
  },
14
- state: {},
15
14
  view: () => renderComponentPage({
16
15
  currentHref: '/components/empty',
17
16
  prev,
@@ -13,7 +13,6 @@ export default {
13
13
  description: 'Feature component for Pulse UI.',
14
14
  styles: ['/pulse-ui.css', '/docs.css'],
15
15
  },
16
- state: {},
17
16
  view: () => renderComponentPage({
18
17
  currentHref: '/components/feature',
19
18
  prev,
@@ -12,7 +12,6 @@ export default {
12
12
  description: 'Semantic grouping of related form fields with an accessible legend.',
13
13
  styles: ['/pulse-ui.css', '/docs.css'],
14
14
  },
15
- state: {},
16
15
  view: () => renderComponentPage({
17
16
  currentHref: '/components/fieldset',
18
17
  prev,
@@ -12,7 +12,6 @@ export default {
12
12
  description: 'Accessible site footer with logo slot, navigation links, and legal text.',
13
13
  styles: ['/pulse-ui.css', '/docs.css'],
14
14
  },
15
- state: {},
16
15
  view: () => renderComponentPage({
17
16
  currentHref: '/components/footer',
18
17
  prev,
@@ -16,7 +16,6 @@ export default {
16
16
  description: 'Grid component for Pulse UI.',
17
17
  styles: ['/pulse-ui.css', '/docs.css'],
18
18
  },
19
- state: {},
20
19
  view: () => renderComponentPage({
21
20
  currentHref: '/components/grid',
22
21
  prev,
@@ -12,7 +12,6 @@ export default {
12
12
  description: 'Styled semantic heading component for Pulse UI.',
13
13
  styles: ['/pulse-ui.css', '/docs.css'],
14
14
  },
15
- state: {},
16
15
  view: () => renderComponentPage({
17
16
  currentHref: '/components/heading',
18
17
  prev,
@@ -145,7 +145,6 @@ export default {
145
145
  description: 'Built-in icon set for Pulse UI.',
146
146
  styles: ['/pulse-ui.css', '/docs.css'],
147
147
  },
148
- state: {},
149
148
  view: () => renderComponentPage({
150
149
  currentHref: '/components/icons',
151
150
  prev,
@@ -12,7 +12,6 @@ export default {
12
12
  description: 'Responsive image component with optional aspect-ratio crop and caption for Pulse UI.',
13
13
  styles: ['/pulse-ui.css', '/docs.css'],
14
14
  },
15
- state: {},
16
15
  view: () => renderComponentPage({
17
16
  currentHref: '/components/image',
18
17
  prev,
@@ -12,7 +12,6 @@ export default {
12
12
  description: 'Input component for Pulse UI.',
13
13
  styles: ['/pulse-ui.css', '/docs.css'],
14
14
  },
15
- state: {},
16
15
  view: () => renderComponentPage({
17
16
  currentHref: '/components/input',
18
17
  prev,
@@ -12,7 +12,6 @@ export default {
12
12
  description: 'Styled ordered and unordered list component for Pulse UI.',
13
13
  styles: ['/pulse-ui.css', '/docs.css'],
14
14
  },
15
- state: {},
16
15
  view: () => renderComponentPage({
17
16
  currentHref: '/components/list',
18
17
  prev,
@@ -12,7 +12,6 @@ export default {
12
12
  description: 'Media component for Pulse UI.',
13
13
  styles: ['/pulse-ui.css', '/docs.css'],
14
14
  },
15
- state: {},
16
15
  view: () => renderComponentPage({
17
16
  currentHref: '/components/media',
18
17
  prev,
@@ -12,7 +12,6 @@ export default {
12
12
  description: 'Modal dialog component for Pulse UI.',
13
13
  styles: ['/pulse-ui.css', '/docs.css'],
14
14
  },
15
- state: {},
16
15
  view: () => renderComponentPage({
17
16
  currentHref: '/components/modal',
18
17
  prev,
@@ -12,7 +12,6 @@ export default {
12
12
  description: 'Nav component for Pulse UI.',
13
13
  styles: ['/pulse-ui.css', '/docs.css'],
14
14
  },
15
- state: {},
16
15
  view: () => renderComponentPage({
17
16
  currentHref: '/components/nav',
18
17
  prev,
@@ -12,7 +12,6 @@ export default {
12
12
  description: 'Pricing component for Pulse UI.',
13
13
  styles: ['/pulse-ui.css', '/docs.css'],
14
14
  },
15
- state: {},
16
15
  view: () => renderComponentPage({
17
16
  currentHref: '/components/pricing',
18
17
  prev,
@@ -12,7 +12,6 @@ export default {
12
12
  description: 'Progress bar component for Pulse UI.',
13
13
  styles: ['/pulse-ui.css', '/docs.css'],
14
14
  },
15
- state: {},
16
15
  view: () => renderComponentPage({
17
16
  currentHref: '/components/progress',
18
17
  prev,
@@ -25,7 +25,6 @@ export default {
25
25
  description: 'Typography wrapper for CMS output and rich text content in Pulse UI.',
26
26
  styles: ['/pulse-ui.css', '/docs.css'],
27
27
  },
28
- state: {},
29
28
  view: () => renderComponentPage({
30
29
  currentHref: '/components/prose',
31
30
  prev,
@@ -12,7 +12,6 @@ export default {
12
12
  description: 'Styled blockquote component for Pulse UI.',
13
13
  styles: ['/pulse-ui.css', '/docs.css'],
14
14
  },
15
- state: {},
16
15
  view: () => renderComponentPage({
17
16
  currentHref: '/components/pullquote',
18
17
  prev,
@@ -12,7 +12,6 @@ export default {
12
12
  description: 'Radio button and radio group components for Pulse UI.',
13
13
  styles: ['/pulse-ui.css', '/docs.css'],
14
14
  },
15
- state: {},
16
15
  view: () => renderComponentPage({
17
16
  currentHref: '/components/radio',
18
17
  prev,
@@ -12,7 +12,6 @@ export default {
12
12
  description: 'Star rating display and interactive input component for Pulse UI.',
13
13
  styles: ['/pulse-ui.css', '/docs.css'],
14
14
  },
15
- state: {},
16
15
  view: () => renderComponentPage({
17
16
  currentHref: '/components/rating',
18
17
  prev,
@@ -12,7 +12,6 @@ export default {
12
12
  description: 'Search input component with icon, debounce binding, and clear button.',
13
13
  styles: ['/pulse-ui.css', '/docs.css'],
14
14
  },
15
- state: {},
16
15
  view: () => renderComponentPage({
17
16
  currentHref: '/components/search',
18
17
  prev,
@@ -12,7 +12,6 @@ export default {
12
12
  description: 'Vertical padding wrapper with optional background. Compose with container() for full-width background with constrained content.',
13
13
  styles: ['/pulse-ui.css', '/docs.css'],
14
14
  },
15
- state: {},
16
15
  view: () => renderComponentPage({
17
16
  currentHref: '/components/section',
18
17
  prev,
@@ -12,7 +12,6 @@ export default {
12
12
  description: 'iOS-style segmented control component for Pulse UI.',
13
13
  styles: ['/pulse-ui.css', '/docs.css'],
14
14
  },
15
- state: {},
16
15
  view: () => renderComponentPage({
17
16
  currentHref: '/components/segmented',
18
17
  prev,
@@ -11,7 +11,6 @@ export default {
11
11
  description: 'Select component for Pulse UI.',
12
12
  styles: ['/pulse-ui.css', '/docs.css'],
13
13
  },
14
- state: {},
15
14
  view: () => renderComponentPage({
16
15
  currentHref: '/components/select',
17
16
  prev,
@@ -12,7 +12,6 @@ export default {
12
12
  description: 'Loading spinner component for Pulse UI.',
13
13
  styles: ['/pulse-ui.css', '/docs.css'],
14
14
  },
15
- state: {},
16
15
  view: () => renderComponentPage({
17
16
  currentHref: '/components/spinner',
18
17
  prev,
@@ -12,7 +12,6 @@ export default {
12
12
  description: 'Stack component for Pulse UI.',
13
13
  styles: ['/pulse-ui.css', '/docs.css'],
14
14
  },
15
- state: {},
16
15
  view: () => renderComponentPage({
17
16
  currentHref: '/components/stack',
18
17
  prev,
@@ -12,7 +12,6 @@ export default {
12
12
  description: 'Stat component for Pulse UI.',
13
13
  styles: ['/pulse-ui.css', '/docs.css'],
14
14
  },
15
- state: {},
16
15
  view: () => renderComponentPage({
17
16
  currentHref: '/components/stat',
18
17
  prev,
@@ -14,7 +14,6 @@ export default {
14
14
  description: 'Horizontal step progress indicator component for Pulse UI.',
15
15
  styles: ['/pulse-ui.css', '/docs.css'],
16
16
  },
17
- state: {},
18
17
  view: () => renderComponentPage({
19
18
  currentHref: '/components/stepper',
20
19
  prev,
@@ -12,7 +12,6 @@ export default {
12
12
  description: 'Table component for Pulse UI.',
13
13
  styles: ['/pulse-ui.css', '/docs.css'],
14
14
  },
15
- state: {},
16
15
  view: () => renderComponentPage({
17
16
  currentHref: '/components/table',
18
17
  prev,
@@ -12,7 +12,6 @@ export default {
12
12
  description: 'Testimonial component for Pulse UI.',
13
13
  styles: ['/pulse-ui.css', '/docs.css'],
14
14
  },
15
- state: {},
16
15
  view: () => renderComponentPage({
17
16
  currentHref: '/components/testimonial',
18
17
  prev,
@@ -11,7 +11,6 @@ export default {
11
11
  description: 'Textarea component for Pulse UI.',
12
12
  styles: ['/pulse-ui.css', '/docs.css'],
13
13
  },
14
- state: {},
15
14
  view: () => renderComponentPage({
16
15
  currentHref: '/components/textarea',
17
16
  prev,
@@ -17,7 +17,6 @@ export default {
17
17
  description: 'Timeline component for Pulse UI.',
18
18
  styles: ['/pulse-ui.css', '/docs.css'],
19
19
  },
20
- state: {},
21
20
  view: () => renderComponentPage({
22
21
  currentHref: '/components/timeline',
23
22
  prev,
@@ -12,7 +12,6 @@ export default {
12
12
  description: 'iOS-style switch toggle component for Pulse UI.',
13
13
  styles: ['/pulse-ui.css', '/docs.css'],
14
14
  },
15
- state: {},
16
15
  view: () => renderComponentPage({
17
16
  currentHref: '/components/toggle',
18
17
  prev,
@@ -12,7 +12,6 @@ export default {
12
12
  description: 'Tooltip component for Pulse UI.',
13
13
  styles: ['/pulse-ui.css', '/docs.css'],
14
14
  },
15
- state: {},
16
15
  view: () => renderComponentPage({
17
16
  currentHref: '/components/tooltip',
18
17
  prev,
@@ -11,7 +11,6 @@ export default {
11
11
  description: 'Server-rendered UI components for Pulse — button, card, input, alert, stat, avatar, table and more. Fully accessible, mobile-ready, zero client JS.',
12
12
  styles: ['/pulse-ui.css', '/docs.css'],
13
13
  },
14
- state: {},
15
14
  view: () => renderLayout({
16
15
  currentHref: '/components',
17
16
  prev,
@@ -11,7 +11,6 @@ export default {
11
11
  description: 'Full reference for pulse.config.js — port, Lighthouse thresholds, load test config, environments, and per-route overrides.',
12
12
  styles: ['/docs.css'],
13
13
  },
14
- state: {},
15
14
  view: () => renderLayout({
16
15
  currentHref: '/config',
17
16
  prev,
@@ -11,7 +11,6 @@ export default {
11
11
  description: 'Deploy a Pulse app to a VPS, Docker, Fly.io, Railway, or Render.',
12
12
  styles: ['/docs.css'],
13
13
  },
14
- state: {},
15
14
  view: () => renderLayout({
16
15
  currentHref: '/deploy',
17
16
  prev,
@@ -11,7 +11,6 @@ export default {
11
11
  description: 'Escape hatches for features outside the Pulse spec — onRequest middleware, raw server access, WebSockets, SSE, and custom error handling.',
12
12
  styles: ['/docs.css'],
13
13
  },
14
- state: {},
15
14
  view: () => renderLayout({
16
15
  currentHref: '/extending',
17
16
  prev,
@@ -21,7 +21,6 @@ export default {
21
21
  description: 'Frequently asked questions about the Pulse framework.',
22
22
  styles: ['/pulse-ui.css', '/docs.css'],
23
23
  },
24
- state: {},
25
24
  view: () => renderLayout({
26
25
  currentHref: '/faq',
27
26
  prev,
@@ -11,7 +11,6 @@ export default {
11
11
  description: 'Install Pulse and build your first page with an AI agent in minutes.',
12
12
  styles: ['/docs.css'],
13
13
  },
14
- state: {},
15
14
  view: () => renderLayout({
16
15
  currentHref: '/getting-started',
17
16
  prev,
@@ -11,7 +11,6 @@ export default {
11
11
  description: 'Per-route authorization in Pulse. Guard functions run before server data fetchers and redirect unauthorized requests.',
12
12
  styles: ['/docs.css'],
13
13
  },
14
- state: {},
15
14
  view: () => renderLayout({
16
15
  currentHref: '/guard',
17
16
  prev,
@@ -33,7 +32,6 @@ export default {
33
32
  user: async (ctx) => getCurrentUser(ctx.cookies.session),
34
33
  },
35
34
 
36
- state: {},
37
35
  view: (state, server) => \`
38
36
  <main id="main-content">
39
37
  <h1>Welcome, \${server.user.name}</h1>
@@ -87,7 +85,6 @@ export default {
87
85
  if (ctx.cookies.session) return { redirect: '/dashboard' }
88
86
  },
89
87
 
90
- state: {},
91
88
  view: () => \`<main id="main-content">...</main>\`,
92
89
  }`, 'js'))}
93
90
 
@@ -70,7 +70,6 @@ export default {
70
70
  description: 'How the Pulse MCP server gives an AI agent the knowledge and tools to build Pulse apps correctly.',
71
71
  styles: ['/pulse-ui.css', '/docs.css'],
72
72
  },
73
- state: {},
74
73
  view: () => renderLayout({
75
74
  currentHref: '/how-it-works',
76
75
  prev,
@@ -11,7 +11,6 @@ export default {
11
11
  description: 'The img() and picture() helpers for optimised, CLS-free image markup.',
12
12
  styles: ['/docs.css'],
13
13
  },
14
- state: {},
15
14
  view: () => renderLayout({
16
15
  currentHref: '/images',
17
16
  prev,
@@ -95,7 +94,6 @@ import { img, picture } from '@invisibleloop/pulse/image'`, 'js'))}
95
94
 
96
95
  export default {
97
96
  route: '/blog/:slug',
98
- state: {},
99
97
  server: {
100
98
  data: async (ctx) => ({ post: await db.posts.findBySlug(ctx.params.slug) }),
101
99
  },
@@ -11,7 +11,6 @@ export default {
11
11
  description: 'How to configure page metadata, Open Graph tags, and structured data in Pulse.',
12
12
  styles: ['/docs.css'],
13
13
  },
14
- state: {},
15
14
  view: () => renderLayout({
16
15
  currentHref: '/meta',
17
16
  prev,
@@ -28,7 +27,6 @@ export default {
28
27
  description: 'Learn about the team behind Acme Corp.',
29
28
  styles: ['/app.css'],
30
29
  },
31
- state: {},
32
30
  view: () => \`<h1>About Us</h1>\`,
33
31
  }`, 'js'))}
34
32
  <p>This generates:</p>
@@ -11,7 +11,6 @@ export default {
11
11
  description: 'Client-side navigation in Pulse — how link interception, JSON responses, and spec re-mounting work.',
12
12
  styles: ['/docs.css'],
13
13
  },
14
- state: {},
15
14
  view: () => renderLayout({
16
15
  currentHref: '/navigation',
17
16
  prev,
@@ -11,7 +11,6 @@ export default {
11
11
  description: 'Performance targets, techniques, and built-in optimisations in Pulse.',
12
12
  styles: ['/docs.css'],
13
13
  },
14
- state: {},
15
14
  view: () => renderLayout({
16
15
  currentHref: '/performance',
17
16
  prev,
@@ -85,7 +84,6 @@ export default {
85
84
  export default {
86
85
  route: '/about',
87
86
  meta: { title: 'About', styles: ['/app.css'] },
88
- state: {},
89
87
  view: () => \`<main><h1>About us</h1></main>\`,
90
88
  }`, 'js'))}
91
89
 
@@ -11,7 +11,6 @@ export default {
11
11
  description: 'Recommended directory layout for a Pulse application.',
12
12
  styles: ['/docs.css'],
13
13
  },
14
- state: {},
15
14
  view: () => renderLayout({
16
15
  currentHref: '/project-structure',
17
16
  prev,
@@ -77,7 +76,6 @@ import { card } from '../components/card.js'
77
76
 
78
77
  export default {
79
78
  route: '/',
80
- state: {},
81
79
  view: () => card({ title: 'Hello', body: 'Welcome to Pulse' }),
82
80
  }`, 'js'), 'src/pages/home.js')}
83
81
 
@@ -29,7 +29,6 @@ export default {
29
29
  description: 'Real prompts for building Pulse pages with your AI agent — from creating pages to authentication, performance audits, and integrations.',
30
30
  styles: ['/docs.css'],
31
31
  },
32
- state: {},
33
32
  view: () => renderLayout({
34
33
  currentHref: '/prompt-examples',
35
34
  prev,
@@ -11,7 +11,6 @@ export default {
11
11
  description: 'Return non-HTML responses from Pulse specs — RSS, XML, JSON, and other content types.',
12
12
  styles: ['/docs.css'],
13
13
  },
14
- state: {},
15
14
  view: () => renderLayout({
16
15
  currentHref: '/raw-responses',
17
16
  prev,
@@ -25,7 +24,6 @@ export default {
25
24
  ${codeBlock(highlight(`export default {
26
25
  route: '/feed.xml',
27
26
  contentType: 'application/rss+xml',
28
- state: {},
29
27
  server: {
30
28
  data: async () => ({
31
29
  posts: await db.posts.latest(20),
@@ -53,7 +51,6 @@ export default {
53
51
  ${codeBlock(highlight(`export default {
54
52
  route: '/api/products',
55
53
  contentType: 'application/json',
56
- state: {},
57
54
  server: {
58
55
  data: async (ctx) => ({
59
56
  products: await db.products.list({
@@ -73,7 +70,6 @@ export default {
73
70
  route: '/sitemap.xml',
74
71
  contentType: 'application/xml',
75
72
  serverTtl: 3600,
76
- state: {},
77
73
  server: {
78
74
  data: async () => ({
79
75
  pages: await db.pages.allPublished(),
@@ -11,7 +11,6 @@ export default {
11
11
  description: 'How Pulse routes requests to page specs — static and dynamic routes, params, and conventions.',
12
12
  styles: ['/docs.css'],
13
13
  },
14
- state: {},
15
14
  view: () => renderLayout({
16
15
  currentHref: '/routing',
17
16
  prev,
@@ -24,7 +23,6 @@ export default {
24
23
  <p>Every spec has a <code>route</code> field. This is the URL pattern the spec handles:</p>
25
24
  ${codeBlock(highlight(`export default {
26
25
  route: '/about',
27
- state: {},
28
26
  view: () => \`<h1>About</h1>\`,
29
27
  }`, 'js'))}
30
28
  <p>Pulse matches the exact path. By default, trailing slashes are removed — <code>/about/</code> redirects to <code>/about</code> with a 301. This is controlled by the <code>trailingSlash</code> option in <code>createServer</code>:</p>
@@ -11,7 +11,6 @@ export default {
11
11
  description: 'Complete reference for createServer — all options, hooks, and response behaviour.',
12
12
  styles: ['/docs.css'],
13
13
  },
14
- state: {},
15
14
  view: () => renderLayout({
16
15
  currentHref: '/server-api',
17
16
  prev,
@@ -176,7 +175,6 @@ await createServer(specs, {
176
175
  }
177
176
  },
178
177
 
179
- state: {},
180
178
  view: () => \`<form method="POST">...</form>\`,
181
179
  }`, 'js'))}
182
180
  ${callout('note', 'Raw response specs (<code>contentType</code> set) accept any HTTP method without <code>spec.methods</code> — they are always method-agnostic.')}
@@ -11,7 +11,6 @@ export default {
11
11
  description: 'Fetch, transform, and combine data on the server before rendering — external APIs, multiple fetchers, parallel requests.',
12
12
  styles: ['/docs.css'],
13
13
  },
14
- state: {},
15
14
  view: () => renderLayout({
16
15
  currentHref: '/server-data',
17
16
  prev,
@@ -11,7 +11,6 @@ export default {
11
11
  description: 'The built-in slash commands available in the Pulse AI agent session.',
12
12
  styles: ['/docs.css'],
13
13
  },
14
- state: {},
15
14
  view: () => renderLayout({
16
15
  currentHref: '/slash-commands',
17
16
  prev,
@@ -25,7 +25,7 @@ export default {
25
25
  ['Field', 'Type', 'Required', 'Description'],
26
26
  [
27
27
  ['<code>route</code>', '<code>string</code>', 'Yes', 'URL pattern for this page. Supports <code>:param</code> segments.'],
28
- ['<code>state</code>', '<code>object</code>', 'Yes', 'Initial client-side state. Deep-cloned on mount.'],
28
+ ['<code>state</code>', '<code>object</code>', 'No', 'Initial client-side state. Deep-cloned on mount. Defaults to <code>{}</code> — omit on purely server-rendered pages.'],
29
29
  ['<code>view</code>', '<code>function</code>', 'Yes', 'Returns an HTML string. Receives <code>(state, serverState)</code>.'],
30
30
  ['<code>meta</code>', '<code>object</code>', 'No', 'Page metadata: title, description, styles, OG tags, schema.'],
31
31
  ['<code>hydrate</code>', '<code>string</code>', 'Auto', 'Auto-derived from the URL entry in <code>createServer</code>. Do not set manually.'],
@@ -35,6 +35,7 @@ export default {
35
35
  ['<code>constraints</code>', '<code>object</code>', 'No', 'Min/max bounds enforced after every mutation.'],
36
36
  ['<code>persist</code>', '<code>string[]</code>', 'No', 'State keys to save in <code>localStorage</code>.'],
37
37
  ['<code>server</code>', '<code>object</code>', 'No', 'Server-side data fetcher. Result passed to <code>view</code> as second arg.'],
38
+ ['<code>guard</code>', '<code>function</code>', 'No', 'Async pre-render hook. Return <code>{ redirect }</code> or <code>{ status, json }</code> to short-circuit. See <a href="/guard">Guard</a>.'],
38
39
  ['<code>store</code>', '<code>string[]</code>', 'No', 'Global store keys this page subscribes to. See <a href="/store">Global Store</a>.'],
39
40
  ['<code>methods</code>', '<code>string[]</code>', 'No', 'HTTP methods this page accepts. Default <code>[\'GET\', \'HEAD\']</code>. Add <code>\'POST\'</code> etc. to opt in.'],
40
41
  ['<code>stream</code>', '<code>object</code>', 'No', 'Streaming SSR config: <code>shell</code> + <code>deferred</code> segment names.'],
@@ -53,7 +54,7 @@ route: '/blog/:year/:slug'`, 'js'))}
53
54
  <p>Dynamic segments are available in server data and actions via <code>ctx.params</code>. See <a href="/routing">Routing</a> for more.</p>
54
55
 
55
56
  ${section('state', 'state')}
56
- <p>The initial client-side state for the page. Always a plain object. Pulse deep-clones it on every mount — mutations never affect the original spec, and state cannot leak between page loads.</p>
57
+ <p>The initial client-side state for the page. Always a plain object. Defaults to <code>{}</code> — you can omit it entirely on purely server-rendered pages with no <code>mutations</code>, <code>actions</code>, or <code>persist</code>. Pulse deep-clones it on every mount — mutations never affect the original spec, and state cannot leak between page loads.</p>
57
58
  ${codeBlock(highlight(`state: {
58
59
  count: 0,
59
60
  user: { name: '', email: '' },
@@ -11,7 +11,6 @@ export default {
11
11
  description: 'How streaming server-side rendering works in Pulse — shell, deferred segments, and when to use it.',
12
12
  styles: ['/docs.css'],
13
13
  },
14
- state: {},
15
14
  view: () => renderLayout({
16
15
  currentHref: '/streaming',
17
16
  prev,
@@ -28,7 +27,6 @@ export default {
28
27
  <p>To use streaming, the <code>view</code> is an <strong>object of named segment functions</strong> rather than a single function. The spec declares which segments are in the shell and which are deferred:</p>
29
28
  ${codeBlock(highlight(`export default {
30
29
  route: '/dashboard',
31
- state: {},
32
30
  server: {
33
31
  data: async (ctx) => ({
34
32
  user: await auth.getUser(ctx.cookies.sessionId), // fast
@@ -11,7 +11,6 @@ export default {
11
11
  description: 'Test Pulse view functions with renderSync and render — query the HTML output with CSS-like selectors, no DOM required.',
12
12
  styles: ['/docs.css'],
13
13
  },
14
- state: {},
15
14
  view: () => renderLayout({
16
15
  currentHref: '/testing',
17
16
  prev,
@@ -11,7 +11,6 @@ export default {
11
11
  description: 'Declarative validation rules in Pulse — syntax, formats, dot-path notation, and error handling.',
12
12
  styles: ['/docs.css'],
13
13
  },
14
- state: {},
15
14
  view: () => renderLayout({
16
15
  currentHref: '/validation',
17
16
  prev,
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@invisibleloop/pulse",
3
- "version": "0.4.0",
3
+ "version": "0.5.1",
4
4
  "type": "module",
5
5
  "description": "AI-first frontend framework. The spec is the source of truth.",
6
6
  "license": "MIT",
@@ -1 +1 @@
1
- 0.4.0
1
+ 0.5.1
@@ -24,8 +24,8 @@ const showToast = (opts) => import('./toast.js').then(m => m.showToast(opts))
24
24
  * @param {Object} [serverState] - Serialised server state from SSR
25
25
  */
26
26
  export function mount(spec, el, serverState = {}, options = {}) {
27
- if (!spec || spec.state === undefined || !spec.view) {
28
- throw new Error('[Pulse] mount: spec must have state and view')
27
+ if (!spec || !spec.view) {
28
+ throw new Error('[Pulse] mount: spec must have a view')
29
29
  }
30
30
  // Spec is validated server-side at startup — no need to re-validate in the browser
31
31
  // Initialise the client store from SSR data (no-op after the first page mount).
@@ -60,7 +60,7 @@ export function mount(spec, el, serverState = {}, options = {}) {
60
60
  }
61
61
 
62
62
  // Deep clone initial state — never mutate the spec itself
63
- let state = deepClone(spec.state)
63
+ let state = deepClone(spec.state ?? {})
64
64
 
65
65
  // Restore persisted keys from localStorage
66
66
  const persistKey = spec.persist?.length ? `pulse:${spec.route || location.pathname}` : null
@@ -69,7 +69,7 @@ export function mount(spec, el, serverState = {}, options = {}) {
69
69
  try {
70
70
  const saved = JSON.parse(localStorage.getItem(persistKey) || '{}')
71
71
  spec.persist.forEach(k => {
72
- if (saved[k] !== undefined && saved[k] !== spec.state[k]) {
72
+ if (saved[k] !== undefined && saved[k] !== (spec.state ?? {})[k]) {
73
73
  state[k] = saved[k]
74
74
  restoredFromPersist = true
75
75
  }
@@ -159,9 +159,7 @@ export function validateSpec(spec) {
159
159
  }
160
160
 
161
161
  // state
162
- if (spec.state === undefined) {
163
- errors.push('spec.state is required — use {} if there is no client state')
164
- } else if (typeof spec.state !== 'object' || Array.isArray(spec.state)) {
162
+ if (spec.state !== undefined && (typeof spec.state !== 'object' || Array.isArray(spec.state))) {
165
163
  errors.push('spec.state must be a plain object')
166
164
  }
167
165
 
@@ -371,6 +369,11 @@ export function validateSpec(spec) {
371
369
  }
372
370
  }
373
371
 
372
+ // hydrate — auto-derived by the framework, should never be set manually
373
+ if (spec.hydrate !== undefined) {
374
+ warnings.push('spec.hydrate is set manually — it is auto-derived by the framework from the URL entry passed to createServer. Remove it from the spec.')
375
+ }
376
+
374
377
  // meta quality
375
378
  if (!spec.meta) {
376
379
  warnings.push('spec.meta is missing — add at minimum title and description for SEO')
@@ -54,8 +54,13 @@ test('rejects route that does not start with /', () => {
54
54
  assertErrors({ route: 'contact', state: {}, view: () => '' }, 'must start with "/"')
55
55
  })
56
56
 
57
- test('requires state', () => {
58
- assertErrors({ route: '/contact', view: () => '' }, 'spec.state is required')
57
+ test('state is optional — omitting it is valid', () => {
58
+ const { valid, errors } = validateSpec({ route: '/contact', view: () => '' })
59
+ if (!valid) throw new Error(`Expected valid but got: ${errors.join(', ')}`)
60
+ })
61
+
62
+ test('state must be a plain object when provided', () => {
63
+ assertErrors({ route: '/contact', state: 'bad', view: () => '' }, 'spec.state must be a plain object')
59
64
  })
60
65
 
61
66
  test('requires view', () => {