@invisibleloop/pulse 0.14.2 → 0.15.0

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.
@@ -1,54 +1,332 @@
1
- ## Example page — contact form
1
+ ## Pattern: action with validation and field errors
2
2
 
3
- ```js
4
- import { nav, section, input, textarea, button, alert, container } from '@invisibleloop/pulse/ui'
5
- import { layout } from '../components/layout.js'
3
+ The correct wiring for a form action with per-field validation errors. `onStart` captures `FormData` before `validate` runs (uncontrolled inputs — never use `data-event` to mirror input values). `validate: true` runs the spec's `validation` rules before `run`. Field errors are surfaced via `state.errors`.
6
4
 
5
+ ```js
7
6
  export default {
8
- meta: {
9
- title: 'Contact',
10
- styles: ['/app.css', '/pulse-ui.css'],
11
- },
7
+ route: '/signup',
8
+ meta: { title: 'Sign up', styles: ['/pulse-ui.css', '/app.css'] },
12
9
  state: {
13
- status: 'idle',
10
+ status: 'idle', // 'idle' | 'loading' | 'success' | 'error'
14
11
  errors: [],
12
+ fields: { name: '', email: '' },
13
+ },
14
+ validation: {
15
+ 'fields.name': { required: true, minLength: 2 },
16
+ 'fields.email': { required: true, format: 'email' },
15
17
  },
16
18
  actions: {
17
- send: {
19
+ submit: {
18
20
  onStart: (state, formData) => ({
19
- status: 'loading',
20
- name: formData.get('name'),
21
- email: formData.get('email'),
22
- message: formData.get('message'),
21
+ status: 'loading',
22
+ errors: [],
23
+ fields: { name: formData.get('name') ?? '', email: formData.get('email') ?? '' },
23
24
  }),
25
+ validate: true,
24
26
  run: async (state, serverState, formData) => {
25
- const res = await fetch('/api/contact', { method: 'POST', body: formData })
26
- if (!res.ok) throw new Error(await res.text())
27
+ const res = await fetch('/api/signup', { method: 'POST', body: formData })
28
+ if (!res.ok) {
29
+ let message = `Request failed: ${res.status}`
30
+ try { const j = await res.json(); message = j.message || j.error || message } catch {}
31
+ throw new Error(message)
32
+ }
27
33
  return await res.json()
28
34
  },
29
- onSuccess: (state, result) => ({ status: 'success' }),
30
- onError: (state, err) => ({
35
+ onSuccess: (state, result) => ({
36
+ status: 'success',
37
+ _toast: { message: 'Account created!', variant: 'success' },
38
+ }),
39
+ onError: (state, err) => ({
31
40
  status: 'error',
32
41
  errors: err?.validation ?? [{ message: err.message }],
42
+ _toast: { message: 'Please check the form', variant: 'error' },
33
43
  }),
34
44
  },
35
45
  },
36
- view: (state) => layout(`
37
- ${container({ content: `
38
- ${section({ title: 'Contact Us', subtitle: 'We will get back to you within 24 hours.' })}
46
+ view: (state) => `
47
+ <main id="main-content">
39
48
  ${state.status === 'success'
40
- ? alert({ type: 'success', message: 'Message sent!' })
41
- : `<form data-action="send" class="u-flex u-flex-col u-gap-4">
42
- ${grid({ cols: 2, gap: 'md', content: `
43
- ${input({ name: 'name', label: 'Name', placeholder: 'Your name', required: true })}
44
- ${input({ name: 'email', label: 'Email', type: 'email', placeholder: 'you@example.com', required: true })}
45
- ` })}
46
- ${textarea({ name: 'message', label: 'Message', rows: 5, required: true })}
47
- ${state.errors.length ? alert({ type: 'error', message: state.errors[0].message }) : ''}
48
- ${button({ label: state.status === 'loading' ? 'Sending…' : 'Send', type: 'submit', variant: 'primary', fullWidth: true })}
49
+ ? `<p>You're signed up.</p>`
50
+ : `<form data-action="submit" class="u-flex u-flex-col u-gap-4">
51
+ ${input({ name: 'name', label: 'Name', value: state.fields.name,
52
+ error: state.errors.find(e => e.field === 'fields.name')?.message })}
53
+ ${input({ name: 'email', label: 'Email', type: 'email', value: state.fields.email,
54
+ error: state.errors.find(e => e.field === 'fields.email')?.message })}
55
+ ${button({ label: state.status === 'loading' ? 'Saving…' : 'Sign up',
56
+ type: 'submit', variant: 'primary',
57
+ attrs: state.status === 'loading' ? 'aria-busy="true" disabled' : '' })}
49
58
  </form>`
50
59
  }
51
- ` })}
52
- `),
60
+ </main>
61
+ `,
62
+ }
63
+ ```
64
+
65
+ **Key points:**
66
+ - `onStart` captures `FormData` into state so field values survive the re-render
67
+ - `validate: true` runs before `run()` — validation errors arrive in `onError` as `err.validation`
68
+ - Always check `err?.validation` first; fall back to `err.message`
69
+ - `aria-busy="true"` on the submit button while loading — spinner alone is not sufficient for accessibility
70
+ - `res.ok` checked before parsing; never use `await res.text()` as an error message
71
+
72
+ ---
73
+
74
+ ## Pattern: server data with dynamic route and missing-data fallback
75
+
76
+ The correct pattern for a dynamic route that loads data from a server fetcher. Always add `onViewError` — a missing or invalid param must not produce a 500.
77
+
78
+ ```js
79
+ import { md } from '@invisibleloop/pulse/md'
80
+ import { prose } from '@invisibleloop/pulse/ui'
81
+
82
+ const post = md('content/blog/:slug.md')
83
+
84
+ export default {
85
+ route: '/blog/:slug',
86
+ meta: {
87
+ title: async (ctx) => (await post(ctx)).frontmatter.title ?? 'Post not found',
88
+ description: async (ctx) => (await post(ctx)).frontmatter.description ?? '',
89
+ },
90
+ server: { post },
91
+ view: (state, server) => `
92
+ <main id="main-content">
93
+ <h1>${server.post.frontmatter.title}</h1>
94
+ ${prose({ content: server.post.html })}
95
+ </main>
96
+ `,
97
+ onViewError: (err) => `
98
+ <main id="main-content">
99
+ <p>Post not found. <a href="/blog">Back to blog</a></p>
100
+ </main>
101
+ `,
102
+ }
103
+ ```
104
+
105
+ **Key points:**
106
+ - Call the same `md()` fetcher in both `meta` and `server` — it caches on `ctx._mdCache`, file is read once
107
+ - `onViewError` is required on any dynamic route where the view can receive null/missing data
108
+ - For database-backed routes: validate `ctx.params.id` before use; return a 404-equivalent via `onViewError` if not found
109
+ - Use `?.` and `??` throughout the view when server data can be null
110
+
111
+ ---
112
+
113
+ ## Pattern: streaming SSR with scoped fetchers
114
+
115
+ Use streaming when a page has slow data (feeds, lists, analytics) and you want the shell to paint instantly. `scope` prevents slow fetchers from blocking the shell.
116
+
117
+ ```js
118
+ export default {
119
+ route: '/dashboard',
120
+ meta: { title: 'Dashboard', styles: ['/pulse-ui.css', '/app.css'] },
121
+ state: {},
122
+ server: {
123
+ user: async (ctx) => db.users.findByCookie(ctx.cookies.session), // fast
124
+ feed: async () => fetchFeed(), // slow
125
+ ads: async () => fetchAds(), // slow
126
+ },
127
+ stream: {
128
+ shell: ['header'],
129
+ deferred: ['feed'],
130
+ scope: {
131
+ header: ['user'], // shell sends as soon as 'user' resolves
132
+ feed: ['feed', 'ads'], // deferred sends when both resolve
133
+ },
134
+ },
135
+ view: {
136
+ header: (state, server) => `
137
+ <header>
138
+ <p>Hello, ${server.user?.name ?? 'Guest'}</p>
139
+ </header>
140
+ `,
141
+ feed: (state, server) => `
142
+ <ul>
143
+ ${server.feed.map(item => `<li data-key="${item.id}">${item.title}</li>`).join('')}
144
+ </ul>
145
+ `,
146
+ },
147
+ }
148
+ ```
149
+
150
+ **Key points:**
151
+ - `view` must be a named object of functions — not a single function — when using `stream`
152
+ - `scope` is optional but important: without it the shell waits for all fetchers
153
+ - `data-key` on list items enables O(1) keyed reconciliation when the list updates client-side
154
+ - A fetcher listed in `scope.header` but slow will delay the shell — keep shell fetchers fast
155
+
156
+ ---
157
+
158
+ ## Pattern: global store subscription and `_storeUpdate`
159
+
160
+ Use the global store for data shared across pages (user session, cart, theme). Read store data via `spec.store`; push changes back with `_storeUpdate` from `onSuccess`.
161
+
162
+ ```js
163
+ // Page that reads from store and updates it on save
164
+ export default {
165
+ route: '/settings',
166
+ store: ['user', 'settings'], // declares which store keys this page subscribes to
167
+ meta: { title: 'Settings', styles: ['/pulse-ui.css', '/app.css'] },
168
+ state: { status: 'idle' },
169
+ actions: {
170
+ save: {
171
+ onStart: (state, formData) => ({ status: 'loading' }),
172
+ run: async (state, serverState, formData) => {
173
+ const theme = formData.get('theme')
174
+ const res = await fetch('/api/settings', {
175
+ method: 'POST',
176
+ body: JSON.stringify({ theme }),
177
+ headers: { 'Content-Type': 'application/json' },
178
+ })
179
+ if (!res.ok) throw new Error(`Save failed: ${res.status}`)
180
+ return { theme }
181
+ },
182
+ onSuccess: (state, { theme }) => ({
183
+ status: 'idle',
184
+ _storeUpdate: { settings: { theme } }, // merged into store; all subscribed pages re-render
185
+ _toast: { message: 'Saved', variant: 'success' },
186
+ }),
187
+ onError: (state, err) => ({
188
+ status: 'error',
189
+ _toast: { message: err.message, variant: 'error' },
190
+ }),
191
+ },
192
+ },
193
+ view: (state, server) => `
194
+ <main id="main-content">
195
+ <form data-action="save" class="u-flex u-flex-col u-gap-4">
196
+ ${select({ name: 'theme', label: 'Theme',
197
+ options: [{ value: 'dark', label: 'Dark' }, { value: 'light', label: 'Light' }],
198
+ value: server.settings?.theme ?? 'dark' })}
199
+ ${button({ label: state.status === 'loading' ? 'Saving…' : 'Save', type: 'submit', variant: 'primary' })}
200
+ </form>
201
+ </main>
202
+ `,
203
+ }
204
+ ```
205
+
206
+ **Key points:**
207
+ - `spec.store` declares subscriptions — store data appears in the view's `server` argument
208
+ - `_storeUpdate` is stripped from page state and merged into the store — do not store it
209
+ - All pages subscribed to the affected store keys re-render without a server round-trip
210
+ - Never use a full page reload to propagate shared state changes — use `_storeUpdate`
211
+
212
+ ---
213
+
214
+ ## Pattern: guard with auth redirect
215
+
216
+ Use `guard` to protect routes. It runs before server fetchers. Return `{ redirect }` to send unauthenticated users elsewhere.
217
+
218
+ ```js
219
+ export default {
220
+ route: '/account',
221
+ meta: { title: 'Account', styles: ['/pulse-ui.css', '/app.css'] },
222
+ server: {
223
+ user: async (ctx) => db.users.find(ctx.cookies.session),
224
+ },
225
+ guard: async (ctx) => {
226
+ const user = await db.users.findByCookie(ctx.cookies.session)
227
+ if (!user) return { redirect: '/login?next=/account' }
228
+ },
229
+ state: {},
230
+ view: (state, server) => `
231
+ <main id="main-content">
232
+ <h1>Hello, ${server.user?.name}</h1>
233
+ </main>
234
+ `,
235
+ }
236
+ ```
237
+
238
+ **Key points:**
239
+ - `guard` runs before server fetchers — returning `{ redirect }` short-circuits the entire request
240
+ - Return nothing (or `undefined`) to allow the request through
241
+ - For shared auth logic, extract to a helper and call it from `guard` on each protected spec
242
+ - `ctx.cookies` is available in `guard` — check session cookies here, not in the view
243
+
244
+ ---
245
+
246
+ ## Pattern: keyed list with client mutations
247
+
248
+ Use `data-key` on repeated elements whenever a list can be reordered, filtered, or have items added/removed client-side.
249
+
250
+ ```js
251
+ export default {
252
+ route: '/todos',
253
+ meta: { title: 'Todos', styles: ['/pulse-ui.css', '/app.css'] },
254
+ state: {
255
+ items: [
256
+ { id: 1, text: 'Buy milk', done: false },
257
+ { id: 2, text: 'Write spec', done: false },
258
+ ],
259
+ filter: 'all', // 'all' | 'active' | 'done'
260
+ },
261
+ mutations: {
262
+ toggle: (state, e) => {
263
+ const id = Number(e.target.closest('[data-id]').dataset.id)
264
+ return { items: state.items.map(i => i.id === id ? { ...i, done: !i.done } : i) }
265
+ },
266
+ setFilter: (state, e) => ({ filter: e.target.value }),
267
+ },
268
+ view: (state) => {
269
+ const visible = state.items.filter(i =>
270
+ state.filter === 'all' ? true : state.filter === 'done' ? i.done : !i.done
271
+ )
272
+ return `
273
+ <main id="main-content">
274
+ <ul>
275
+ ${visible.map(item => `
276
+ <li data-key="${item.id}" data-id="${item.id}">
277
+ <button data-event="toggle" aria-pressed="${item.done}">${item.text}</button>
278
+ </li>
279
+ `).join('')}
280
+ </ul>
281
+ <select data-event="change:setFilter" aria-label="Filter todos">
282
+ <option value="all">All</option>
283
+ <option value="active">Active</option>
284
+ <option value="done">Done</option>
285
+ </select>
286
+ </main>
287
+ `
288
+ },
289
+ }
290
+ ```
291
+
292
+ **Key points:**
293
+ - `data-key` must be on every sibling element in the container — mixed keyed/unkeyed falls back to position matching
294
+ - Use `e.target.closest('[data-id]')` in mutations — never assume `e.target` is the element with the attribute
295
+ - `aria-pressed` on toggle buttons communicates state to screen readers
296
+ - Mutations are pure — no fetch, no DOM access, return partial state only
297
+
298
+ ---
299
+
300
+ ## Pattern: persist — what belongs and what doesn't
301
+
302
+ `persist` survives page reload via `localStorage`. Only serialisable, user-owned state that should survive a refresh belongs here.
303
+
304
+ ```js
305
+ export default {
306
+ route: '/builder',
307
+ meta: { title: 'Builder', styles: ['/pulse-ui.css', '/app.css'] },
308
+ state: {
309
+ // ✓ persist these — user-owned, meaningful across sessions
310
+ items: [],
311
+ activeStep: 0,
312
+ draft: { name: '', description: '' },
313
+
314
+ // ✗ do NOT persist these — ephemeral UI state
315
+ status: 'idle',
316
+ errors: [],
317
+ modalOpen: false,
318
+ },
319
+ persist: ['items', 'activeStep', 'draft'],
320
+ mutations: {
321
+ nextStep: (state) => ({ activeStep: Math.min(state.activeStep + 1, 3) }),
322
+ prevStep: (state) => ({ activeStep: Math.max(state.activeStep - 1, 0) }),
323
+ },
324
+ view: (state) => `<main id="main-content">…</main>`,
53
325
  }
54
326
  ```
327
+
328
+ **Key points:**
329
+ - Never persist `status`, `errors`, loading flags, or modal open state — these are session-only
330
+ - Never persist large objects or arrays that could grow unbounded — `localStorage` has a ~5MB limit
331
+ - Restored persist values override spec defaults on mount and trigger a re-render if different
332
+ - `persist` keys must exist in `state` — persisting a key not in state has no effect
package/src/cli/dev.js CHANGED
@@ -140,6 +140,48 @@ process.on('SIGINT', closeReloadClients)
140
140
  let reloadTimer = null
141
141
  let lastReload = 0
142
142
 
143
+ /**
144
+ * Check rendered HTML class names against CSS files in public/ and warn about
145
+ * any classes that are used in templates but not defined in any stylesheet.
146
+ * Only checks project-owned CSS (not pulse-ui.css) to avoid noise.
147
+ */
148
+ async function checkUndefinedClasses(specs) {
149
+ try {
150
+ // Collect all class names defined in project CSS files
151
+ const cssDir = path.join(ROOT, 'public')
152
+ const definedClasses = new Set()
153
+ if (fs.existsSync(cssDir)) {
154
+ for (const file of fs.readdirSync(cssDir).filter(f => f.endsWith('.css') && f !== 'pulse-ui.css')) {
155
+ const css = fs.readFileSync(path.join(cssDir, file), 'utf8')
156
+ for (const [, cls] of css.matchAll(/\.([a-zA-Z][a-zA-Z0-9_-]*)\s*[{,:\[]/g)) {
157
+ definedClasses.add(cls)
158
+ }
159
+ }
160
+ }
161
+ if (definedClasses.size === 0) return
162
+
163
+ // Render each spec and collect used class names from the HTML output
164
+ const { renderToString } = await import('../runtime/ssr.js')
165
+ for (const spec of specs) {
166
+ if (!spec.view) continue
167
+ try {
168
+ const { html } = await renderToString(spec, {})
169
+ const usedInSpec = new Set()
170
+ for (const [, classes] of html.matchAll(/class="([^"]+)"/g)) {
171
+ for (const cls of classes.trim().split(/\s+/)) {
172
+ if (cls && !cls.startsWith('ui-')) usedInSpec.add(cls)
173
+ }
174
+ }
175
+ const undefined_ = [...usedInSpec].filter(cls => !definedClasses.has(cls))
176
+ if (undefined_.length > 0) {
177
+ const route = spec.route ?? spec.filePath ?? '(unknown)'
178
+ log.warn(`${route}: class${undefined_.length === 1 ? '' : 'es'} used but not defined in any CSS: ${undefined_.slice(0, 5).map(c => `.${c}`).join(', ')}${undefined_.length > 5 ? ` … +${undefined_.length - 5} more` : ''}`)
179
+ }
180
+ } catch { /* render failures are not our concern here */ }
181
+ }
182
+ } catch { /* never crash the dev server over a lint warning */ }
183
+ }
184
+
143
185
  async function triggerReload(label = 'File changed') {
144
186
  clearTimeout(reloadTimer)
145
187
  reloadTimer = setTimeout(async () => {
@@ -151,6 +193,7 @@ async function triggerReload(label = 'File changed') {
151
193
  const fresh = await loadPages(ROOT, now)
152
194
  updateSpecs(fresh)
153
195
  log.info(`${label} — specs reloaded`)
196
+ checkUndefinedClasses(fresh)
154
197
  } catch (err) {
155
198
  log.error(`Spec reload failed: ${err.message}`)
156
199
  }