@invisibleloop/pulse 0.6.2 → 0.7.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.
@@ -4,6 +4,20 @@ Before finishing any spec, verify every point below. Fix anything that fails.
4
4
 
5
5
  ### Critical
6
6
 
7
+ - **`meta` must be a plain object — never a function.** Individual fields (`title`, `description`, `styles`) can be `async (ctx) => value` functions, but `meta` itself is always `{}`.
8
+
9
+ ```js
10
+ // WRONG — meta is not a function factory
11
+ meta: async (ctx) => ({ title: '...', styles: [...] })
12
+
13
+ // CORRECT — meta is a plain object; individual fields are functions
14
+ meta: {
15
+ title: async (ctx) => (await post(ctx)).frontmatter.title,
16
+ description: async (ctx) => (await post(ctx)).frontmatter.description,
17
+ styles: ['/pulse-ui.css', '/app.css'],
18
+ }
19
+ ```
20
+
7
21
  - **Do not set `hydrate` in specs.** It is auto-derived by the framework from the URL entry passed to `createServer`. Specs with `mutations`, `actions`, or `persist` are hydrated automatically. Purely server-rendered specs get zero JavaScript — no configuration needed.
8
22
 
9
23
  ### Components first
@@ -1 +1 @@
1
- 0.5.0
1
+ 0.7.0
@@ -2729,6 +2729,8 @@ fieldset[disabled] .ui-rating-stars .ui-rating-star:hover ~ .ui-rating-star { co
2729
2729
  .ui-prose ul { list-style-type: disc; }
2730
2730
  .ui-prose ol { list-style-type: decimal; }
2731
2731
  .ui-prose ul ul { list-style-type: circle; }
2732
+ .ui-prose ul.contains-task-list { list-style: none; padding-left: 0; }
2733
+ .ui-prose li.task-list-item input[type="checkbox"] { margin-right: .4em; }
2732
2734
  .ui-prose li { margin-bottom: .375em; }
2733
2735
  .ui-prose li > ul,
2734
2736
  .ui-prose li > ol { margin-top: .375em; margin-bottom: .375em; }
@@ -1,6 +1,7 @@
1
1
  import { renderComponentPage, demo } from '../../lib/component-page.js'
2
2
  import { prevNext } from '../../lib/nav.js'
3
3
  import { table } from '../../lib/layout.js'
4
+ import { modal } from '../../../../src/ui/index.js'
4
5
  import {
5
6
  button, feature,
6
7
  iconArrowLeft, iconArrowRight, iconArrowUp, iconArrowDown,
@@ -17,8 +18,12 @@ import {
17
18
  iconPhone, iconGamepad,
18
19
  iconHandPointUp, iconHandPointDown, iconHandPointLeft, iconHandPointRight,
19
20
  iconGlobe, iconShield, iconZap, iconTrendingUp, iconTrendingDown, iconLoader, iconGrid, iconBug,
20
- iconMapPin,
21
- iconSun, iconMoon,
21
+ iconMapPin, iconSun, iconMoon,
22
+ iconShoppingCart, iconShoppingBag, iconCreditCard, iconPackage, iconGift,
23
+ iconWallet, iconTruck, iconReceipt, iconStore, iconPercent, iconTicket, iconBanknote,
24
+ iconUtensils, iconCoffee, iconPizza, iconApple, iconCarrot,
25
+ iconWine, iconCakeSlice, iconFish, iconCherry, iconEgg,
26
+ iconCookie, iconIceCream, iconCroissant, iconSalad, iconWheat,
22
27
  } from '../../../../src/ui/index.js'
23
28
 
24
29
  const { prev, next } = prevNext('/components/icons')
@@ -92,11 +97,11 @@ const ICONS = [
92
97
  { name: 'iconTag', fn: iconTag },
93
98
  ]},
94
99
  { group: 'Media & Rating', icons: [
95
- { name: 'iconPlay', fn: iconPlay },
96
- { name: 'iconPause', fn: iconPause },
100
+ { name: 'iconPlay', fn: iconPlay },
101
+ { name: 'iconPause', fn: iconPause },
97
102
  { name: 'iconVolume', fn: iconVolume },
98
- { name: 'iconStar', fn: iconStar },
99
- { name: 'iconHeart', fn: iconHeart },
103
+ { name: 'iconStar', fn: iconStar },
104
+ { name: 'iconHeart', fn: iconHeart },
100
105
  ]},
101
106
  { group: 'Devices', icons: [
102
107
  { name: 'iconPhone', fn: iconPhone },
@@ -122,22 +127,117 @@ const ICONS = [
122
127
  { name: 'iconSun', fn: iconSun },
123
128
  { name: 'iconMoon', fn: iconMoon },
124
129
  ]},
130
+ { group: 'Ecommerce', icons: [
131
+ { name: 'iconShoppingCart', fn: iconShoppingCart },
132
+ { name: 'iconShoppingBag', fn: iconShoppingBag },
133
+ { name: 'iconCreditCard', fn: iconCreditCard },
134
+ { name: 'iconPackage', fn: iconPackage },
135
+ { name: 'iconGift', fn: iconGift },
136
+ { name: 'iconWallet', fn: iconWallet },
137
+ { name: 'iconTruck', fn: iconTruck },
138
+ { name: 'iconReceipt', fn: iconReceipt },
139
+ { name: 'iconStore', fn: iconStore },
140
+ { name: 'iconPercent', fn: iconPercent },
141
+ { name: 'iconTicket', fn: iconTicket },
142
+ { name: 'iconBanknote', fn: iconBanknote },
143
+ ]},
144
+ { group: 'Food & Drink', icons: [
145
+ { name: 'iconUtensils', fn: iconUtensils },
146
+ { name: 'iconCoffee', fn: iconCoffee },
147
+ { name: 'iconPizza', fn: iconPizza },
148
+ { name: 'iconApple', fn: iconApple },
149
+ { name: 'iconCarrot', fn: iconCarrot },
150
+ { name: 'iconWine', fn: iconWine },
151
+ { name: 'iconCakeSlice', fn: iconCakeSlice },
152
+ { name: 'iconFish', fn: iconFish },
153
+ { name: 'iconCherry', fn: iconCherry },
154
+ { name: 'iconEgg', fn: iconEgg },
155
+ { name: 'iconCookie', fn: iconCookie },
156
+ { name: 'iconIceCream', fn: iconIceCream },
157
+ { name: 'iconCroissant', fn: iconCroissant },
158
+ { name: 'iconSalad', fn: iconSalad },
159
+ { name: 'iconWheat', fn: iconWheat },
160
+ ]},
125
161
  ]
126
162
 
163
+ // Safe ID from icon name: iconArrowLeft → arrow-left
164
+ const safeId = name => name.replace(/^icon/, '').replace(/([A-Z])/g, m => '-' + m.toLowerCase()).replace(/^-/, '')
165
+
166
+ const PREVIEW_SIZES = [12, 16, 20, 24, 32, 48]
167
+ const PREVIEW_COLORS = [
168
+ { label: 'Default', style: '' },
169
+ { label: 'Accent', style: 'color:var(--accent)' },
170
+ { label: 'Green', style: 'color:var(--color-green,#4ade80)' },
171
+ { label: 'Yellow', style: 'color:var(--color-yellow,#facc15)' },
172
+ { label: 'Red', style: 'color:var(--color-red,#f87171)' },
173
+ { label: 'Muted', style: 'color:var(--muted)' },
174
+ ]
175
+
176
+ function iconPreviewModal({ name, fn }) {
177
+ const id = `icon-modal-${safeId(name)}`
178
+
179
+ const sizesHtml = PREVIEW_SIZES.map(size => `
180
+ <div style="display:flex;flex-direction:column;align-items:center;gap:4px">
181
+ ${fn({ size })}
182
+ <span style="font-size:0.6rem;color:var(--muted);font-family:var(--mono,monospace)">${size}</span>
183
+ </div>
184
+ `).join('')
185
+
186
+ const colorsHtml = PREVIEW_COLORS.map(({ label, style }) => `
187
+ <div style="display:flex;flex-direction:column;align-items:center;gap:4px;${style}">
188
+ ${fn({ size: 28 })}
189
+ <span style="font-size:0.6rem;color:var(--muted);font-family:var(--mono,monospace)">${label}</span>
190
+ </div>
191
+ `).join('')
192
+
193
+ const bgHtml =
194
+ ['circle', 'square'].map(shape =>
195
+ ['accent', 'success', 'warning', 'error', 'muted'].map(bgColor =>
196
+ fn({ size: 20, bg: shape, bgColor })
197
+ ).join('')
198
+ ).join('<br style="display:block;margin:.5rem 0">')
199
+
200
+ return modal({
201
+ id,
202
+ title: name,
203
+ size: 'md',
204
+ content: `
205
+ <div style="display:flex;flex-direction:column;gap:1.5rem">
206
+ <div>
207
+ <p style="font-size:.75rem;color:var(--muted);margin:0 0 .75rem">Sizes</p>
208
+ <div style="display:flex;align-items:flex-end;gap:1rem;flex-wrap:wrap">${sizesHtml}</div>
209
+ </div>
210
+ <div>
211
+ <p style="font-size:.75rem;color:var(--muted);margin:0 0 .75rem">Colours</p>
212
+ <div style="display:flex;align-items:center;gap:1rem;flex-wrap:wrap">${colorsHtml}</div>
213
+ </div>
214
+ <div>
215
+ <p style="font-size:.75rem;color:var(--muted);margin:0 0 .75rem">Backgrounds</p>
216
+ <div style="display:flex;align-items:center;gap:.5rem;flex-wrap:wrap">${bgHtml}</div>
217
+ </div>
218
+ </div>
219
+ `,
220
+ })
221
+ }
222
+
127
223
  function iconGrid_() {
128
224
  return ICONS.map(({ group, icons }) => `
129
225
  <h3 class="doc-h3" style="margin-top:2rem">${group}</h3>
130
226
  <div class="icon-grid">
131
227
  ${icons.map(({ name, fn }) => `
132
- <div class="icon-grid-item">
228
+ <button type="button" class="icon-grid-item" style="cursor:pointer;font:inherit;color:inherit" data-dialog-open="icon-modal-${safeId(name)}" aria-label="Preview ${name}">
133
229
  <div class="icon-grid-preview">${fn({ size: 20 })}</div>
134
230
  <span class="icon-grid-name">${name}</span>
135
- </div>
231
+ </button>
136
232
  `).join('')}
137
233
  </div>
138
234
  `).join('')
139
235
  }
140
236
 
237
+ function iconModals_() {
238
+ return ICONS.flatMap(({ icons }) => icons).map(iconPreviewModal).join('')
239
+ }
240
+
141
241
  export default {
142
242
  route: '/components/icons',
143
243
  meta: {
@@ -150,7 +250,7 @@ export default {
150
250
  prev,
151
251
  next,
152
252
  name: 'icons',
153
- description: '55 curated icons. All are pure functions returning an SVG string — no external library, no DOM dependency, tree-shakeable. Style: 24×24 viewBox, <code>stroke="currentColor"</code>, compatible with any colour token.',
253
+ description: '100 curated icons. All are pure functions returning an SVG string — no external library, no DOM dependency, tree-shakeable. Style: 24×24 viewBox, <code>stroke="currentColor"</code>, compatible with any colour token.',
154
254
  content: `
155
255
 
156
256
  <h2 class="doc-h2" id="usage">Usage</h2>
@@ -266,10 +366,12 @@ feature({ icon: iconCode({ size: 22, bg: 'square', bgColor: 'muted' }), titl
266
366
  )}
267
367
 
268
368
  <h2 class="doc-h2" id="all-icons">All icons</h2>
269
- <p>Click any icon name to copy the import.</p>
369
+ <p>Click any icon to preview sizes, colours, and backgrounds.</p>
270
370
 
271
371
  ${iconGrid_()}
272
372
 
373
+ ${iconModals_()}
374
+
273
375
  ${table(
274
376
  ['Prop', 'Type', 'Default', 'Description'],
275
377
  [
@@ -279,6 +381,8 @@ feature({ icon: iconCode({ size: 22, bg: 'square', bgColor: 'muted' }), titl
279
381
  ['<code>bgColor</code>', 'string', "'accent'", "'accent' · 'success' · 'warning' · 'error' · 'muted'"],
280
382
  ]
281
383
  )}
384
+
385
+ <p style="margin-top:2rem;font-size:.8rem;color:var(--muted)">SVG paths derived from <a href="https://lucide.dev" target="_blank" rel="noopener noreferrer">Lucide</a> (ISC License, Copyright © 2022 Lucide Contributors) and <a href="https://feathericons.com" target="_blank" rel="noopener noreferrer">Feather Icons</a> (MIT License, Copyright © 2013–2017 Cole Bemis). Phosphor hand-pointer icons MIT License, Copyright © 2020 Phosphor Icons.</p>
282
386
  `,
283
387
  }),
284
388
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@invisibleloop/pulse",
3
- "version": "0.6.2",
3
+ "version": "0.7.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.6.2
1
+ 0.7.1
package/scripts/build.js CHANGED
@@ -145,7 +145,38 @@ function isInsideString(source, pos) {
145
145
  if (c === '"' || c === "'" || c === '`') { stack.push(c); i++; continue }
146
146
  i++; continue
147
147
  }
148
- // top-level
148
+ // Top level — handle comments and regex literals before string delimiters.
149
+ // Without this, a `"` inside a regex literal (e.g. /"/g) or a comment would
150
+ // be mistaken for an opening string delimiter, causing all subsequent keys to
151
+ // be treated as "inside a string" and never stripped.
152
+ if (c === '/') {
153
+ if (source[i + 1] === '/') { // line comment — skip to end of line
154
+ while (i < pos && source[i] !== '\n') i++
155
+ continue
156
+ }
157
+ if (source[i + 1] === '*') { // block comment — skip to */
158
+ const end = source.indexOf('*/', i + 2)
159
+ i = end < 0 ? pos : end + 2
160
+ continue
161
+ }
162
+ // Regex literal heuristic: / is a regex when not preceded by ) ] or alnum.
163
+ // Division (a / b) always follows one of those; regex follows operators, keywords, etc.
164
+ let prev = ''
165
+ for (let k = i - 1; k >= 0; k--) {
166
+ if (source[k] !== ' ' && source[k] !== '\t') { prev = source[k]; break }
167
+ }
168
+ if (!/[a-zA-Z0-9_$)\]]/.test(prev)) {
169
+ i++ // skip opening /
170
+ while (i < pos) {
171
+ if (source[i] === '\\') { i += 2; continue }
172
+ if (source[i] === '/') { i++; break } // closing /
173
+ if (source[i] === '\n') break // regex can't span lines
174
+ i++
175
+ }
176
+ while (i < pos && /[gimsuy]/.test(source[i])) i++ // skip flags
177
+ continue
178
+ }
179
+ }
149
180
  if (c === '"' || c === "'" || c === '`') { stack.push(c) }
150
181
  i++
151
182
  }
@@ -379,7 +410,15 @@ const NAVIGATE_PATH = new URL('../src/runtime/navigate.js', import.meta.url).pat
379
410
 
380
411
  const PAGES_DIR = path.join(ROOT, 'src', 'pages')
381
412
 
382
- const bootstrapFiles = pages.map(({ filePath }) => {
413
+ const bootstrapFiles = pages.flatMap(({ filePath }) => {
414
+ // Strip server-only keys/imports to check what's left for the client.
415
+ // Skip specs with no view — these are API routes or raw content specs
416
+ // (using render: instead of view:) that have no client-side code. Including
417
+ // them in the bundle fragments the shared runtime chunk unnecessarily.
418
+ const source = fs.readFileSync(filePath, 'utf8')
419
+ const stripped = stripServerOnlyImports(stripServerOnlyKeys(source))
420
+ if (!/\bview\s*:/.test(stripped)) return []
421
+
383
422
  // Use the path relative to src/pages/ so nested pages get unique names.
384
423
  // e.g. src/pages/api/products.js → 'api--products' (not 'products')
385
424
  const relToPages = path.relative(PAGES_DIR, filePath)
@@ -404,7 +443,7 @@ if (root && !root.dataset.pulseMounted) {
404
443
  export default spec
405
444
  `)
406
445
 
407
- return { filePath, bootstrapPath, name }
446
+ return [{ filePath, bootstrapPath, name }]
408
447
  })
409
448
 
410
449
  // ---------------------------------------------------------------------------
@@ -73,6 +73,18 @@ function isInsideString(source, pos) {
73
73
  if (c === '"' || c === "'" || c === '`') { stack.push(c); i++; continue }
74
74
  i++; continue
75
75
  }
76
+ if (c === '/') {
77
+ if (source[i + 1] === '/') { while (i < pos && source[i] !== '\n') i++; continue }
78
+ if (source[i + 1] === '*') { const end = source.indexOf('*/', i + 2); i = end < 0 ? pos : end + 2; continue }
79
+ let prev = ''
80
+ for (let k = i - 1; k >= 0; k--) { if (source[k] !== ' ' && source[k] !== '\t') { prev = source[k]; break } }
81
+ if (!/[a-zA-Z0-9_$)\]]/.test(prev)) {
82
+ i++
83
+ while (i < pos) { if (source[i] === '\\') { i += 2; continue } if (source[i] === '/') { i++; break } if (source[i] === '\n') break; i++ }
84
+ while (i < pos && /[gimsuy]/.test(source[i])) i++
85
+ continue
86
+ }
87
+ }
76
88
  if (c === '"' || c === "'" || c === '`') { stack.push(c) }
77
89
  i++
78
90
  }
@@ -257,6 +269,21 @@ test('server: inside a double-quoted string is not stripped',
257
269
  'export default {\n route: \'/docs\',\n view: () => "<pre>server: { data: async () => {} }</pre>"\n}'
258
270
  )
259
271
 
272
+ test('server/meta not fooled by regex literal containing double quote (/"/g)',
273
+ 'const esc = s => s.replace(/"/g, \'&quot;\')\nexport default {\n meta: { title: \'Page\', description: \'Desc\' },\n server: { data: async () => [] },\n view: () => \'\'\n}',
274
+ 'const esc = s => s.replace(/"/g, \'&quot;\')\nexport default {\n view: () => \'\'\n}'
275
+ )
276
+
277
+ test('server not fooled by line comment containing double quote',
278
+ 'export default {\n // server: "comment"\n server: { data: async () => [] },\n view: () => \'\'\n}',
279
+ 'export default {\n // server: "comment"\n view: () => \'\'\n}'
280
+ )
281
+
282
+ test('server not fooled by block comment containing double quote',
283
+ 'export default {\n /* server: "comment" */\n server: { data: async () => [] },\n view: () => \'\'\n}',
284
+ 'export default {\n /* server: "comment" */\n view: () => \'\'\n}'
285
+ )
286
+
260
287
  test('meta object is stripped (client runtime never reads spec.meta)',
261
288
  `export default {\n route: '/blog',\n meta: {\n title: async (ctx) => fetchPost(ctx).frontmatter.title,\n styles: ['/pulse-ui.css'],\n },\n view: () => ''\n}`,
262
289
  `export default {\n route: '/blog',\n view: () => ''\n}`
@@ -95,6 +95,9 @@ ${port !== 3000 ? ` port: ${port},\n` : ''}}
95
95
 
96
96
  // settings.json — hooks that enforce correct agent behaviour
97
97
  write(targetDir, '.claude/settings.json', JSON.stringify({
98
+ enabledPlugins: {
99
+ 'frontend-design@claude-plugins-official': true,
100
+ },
98
101
  hooks: {
99
102
  SessionStart: [
100
103
  {
@@ -346,6 +349,19 @@ public/app.css ← global stylesheet
346
349
 
347
350
  The MCP guide is the single source of truth. Follow it for all technical decisions, component usage, and the mandatory verification workflow.
348
351
 
352
+ ## Design — do this before writing any HTML
353
+
354
+ Before planning any new page or component, use the \`frontend-design\` plugin to establish the visual direction:
355
+
356
+ 1. Call the plugin's design tools with the page brief (what it does, who it's for, the tone)
357
+ 2. The plugin will return decisions — colour palette, typography, spacing scale, component variants, layout approach
358
+ 3. **Apply those decisions as CSS custom property overrides in \`public/app.css\`** — not as inline styles or class hacks
359
+ 4. Document the chosen tokens at the top of \`public/app.css\` so every page stays consistent
360
+
361
+ The plugin informs *what* to build visually. The Pulse component library is *how* you build it. Use both — the plugin gives direction, the components implement it.
362
+
363
+ **Do not default to stock component styles.** If the design plugin gives you a colour, use it. If it suggests a layout pattern, follow it. Every page should reflect deliberate design decisions, not the component library defaults.
364
+
349
365
  ## Before writing any code
350
366
 
351
367
  **Always present a plan and wait for the user to confirm before writing a single line of code.**
package/src/md/index.js CHANGED
@@ -251,7 +251,7 @@ function renderBlocks(blocks) {
251
251
  const task = item.match(/^\[([ xX])\] (.*)/)
252
252
  if (task) {
253
253
  const checked = task[1].toLowerCase() === 'x' ? ' checked' : ''
254
- return `<li class="task-list-item"><input type="checkbox" disabled${checked}> ${renderListItem(task[2])}</li>`
254
+ return `<li class="task-list-item"><label class="ui-checkbox ui-checkbox--disabled"><input type="checkbox" class="ui-checkbox-input" disabled${checked}><span class="ui-checkbox-box" aria-hidden="true"></span><span class="ui-checkbox-label">${renderListItem(task[2])}</span></label></li>`
255
255
  }
256
256
  return `<li>${renderListItem(item)}</li>`
257
257
  })
package/src/md/md.test.js CHANGED
@@ -30,18 +30,18 @@ console.log('\nTask list (checkbox) rendering\n')
30
30
 
31
31
  test('unchecked item renders disabled checkbox', () => {
32
32
  const { html } = parseMd('- [ ] Buy milk')
33
- assert(html.includes('<input type="checkbox" disabled>'), `Got: ${html}`)
33
+ assert(html.includes('<input type="checkbox" class="ui-checkbox-input" disabled>'), `Got: ${html}`)
34
34
  assert(html.includes('Buy milk'), `Got: ${html}`)
35
35
  })
36
36
 
37
37
  test('checked item renders checked disabled checkbox', () => {
38
38
  const { html } = parseMd('- [x] Done')
39
- assert(html.includes('<input type="checkbox" disabled checked>'), `Got: ${html}`)
39
+ assert(html.includes('<input type="checkbox" class="ui-checkbox-input" disabled checked>'), `Got: ${html}`)
40
40
  })
41
41
 
42
42
  test('uppercase X is also treated as checked', () => {
43
43
  const { html } = parseMd('- [X] Done')
44
- assert(html.includes('<input type="checkbox" disabled checked>'), `Got: ${html}`)
44
+ assert(html.includes('<input type="checkbox" class="ui-checkbox-input" disabled checked>'), `Got: ${html}`)
45
45
  })
46
46
 
47
47
  test('task list items get task-list-item class', () => {
@@ -49,6 +49,14 @@ test('task list items get task-list-item class', () => {
49
49
  assert(html.includes('class="task-list-item"'), `Got: ${html}`)
50
50
  })
51
51
 
52
+ test('task item checkbox is wrapped in a label for accessibility', () => {
53
+ const { html } = parseMd('- [ ] Buy milk')
54
+ assert(html.includes('class="ui-checkbox ui-checkbox--disabled"'), `Got: ${html}`)
55
+ assert(html.includes('<input type="checkbox" class="ui-checkbox-input" disabled>'), `Got: ${html}`)
56
+ assert(html.includes('<span class="ui-checkbox-box" aria-hidden="true">'), `Got: ${html}`)
57
+ assert(html.includes('<span class="ui-checkbox-label">Buy milk</span>'), `Got: ${html}`)
58
+ })
59
+
52
60
  test('task list ul gets contains-task-list class', () => {
53
61
  const { html } = parseMd('- [ ] Item one\n- [x] Item two')
54
62
  assert(html.includes('class="contains-task-list"'), `Got: ${html}`)
@@ -73,8 +81,8 @@ test('task item label text is rendered as inline markdown', () => {
73
81
 
74
82
  test('multiple items preserve checked state independently', () => {
75
83
  const { html } = parseMd('- [ ] Unchecked\n- [x] Checked\n- [ ] Also unchecked')
76
- const checkedCount = (html.match(/checked>/g) || []).length
77
- const uncheckedCount = (html.match(/<input type="checkbox" disabled>/g) || []).length
84
+ const checkedCount = (html.match(/disabled checked>/g) || []).length
85
+ const uncheckedCount = (html.match(/disabled>/g) || []).length
78
86
  assert(checkedCount === 1, `Expected 1 checked, got ${checkedCount}`)
79
87
  assert(uncheckedCount === 2, `Expected 2 unchecked, got ${uncheckedCount}`)
80
88
  })
@@ -327,7 +327,9 @@ export function wrapDocument({ content, spec = {}, serverState = {}, storeState
327
327
  // to satisfy the style-src CSP directive.
328
328
  const storeStateScript = storeState && Object.keys(storeState).length > 0
329
329
  ? `<script nonce="${nonce}">window.__PULSE_NONCE__='${nonce}';window.__PULSE_STORE__=${JSON.stringify(storeState)};window.__updatePulseStore__=function(s){window.__PULSE_STORE__=Object.assign(window.__PULSE_STORE__||{},s);};</script>`
330
- : `<script nonce="${nonce}">window.__PULSE_NONCE__='${nonce}';</script>`
330
+ : spec.hydrate
331
+ ? `<script nonce="${nonce}">window.__PULSE_NONCE__='${nonce}';</script>`
332
+ : ''
331
333
 
332
334
  // Hydration bootstrap — makes the server-rendered HTML interactive.
333
335
  // When hydrate points to a self-executing bundle (/dist/…) a single <script>
@@ -92,7 +92,8 @@ function buildCsp(nonce, ext = {}) {
92
92
 
93
93
  /**
94
94
  * Build the nonce-free CSP for cached responses.
95
- * Safe because cached pages have no inline scripts.
95
+ * Safe because cached pages (no spec.server, no spec.store) are only cached
96
+ * when they have no spec.hydrate — so no inline nonce scripts are present.
96
97
  * @param {Record<string,string[]>} [ext] Extra sources to merge in per directive.
97
98
  */
98
99
  function buildCachedCsp(ext = {}) {
@@ -1004,7 +1005,9 @@ ${stylePreloads ? stylePreloads + '\n' : ''}${runtimePreload ? runtimePreload +
1004
1005
  // satisfy the style-src CSP directive (which disallows uninonce'd inline styles).
1005
1006
  const storeScript = ctx.store && Object.keys(ctx.store).length > 0
1006
1007
  ? `\n <script nonce="${nonce}">window.__PULSE_NONCE__='${nonce}';window.__PULSE_STORE__=${JSON.stringify(ctx.store)};window.__updatePulseStore__=function(s){window.__PULSE_STORE__=Object.assign(window.__PULSE_STORE__||{},s);};</script>`
1007
- : `\n <script nonce="${nonce}">window.__PULSE_NONCE__='${nonce}';</script>`
1008
+ : spec.hydrate
1009
+ ? `\n <script nonce="${nonce}">window.__PULSE_NONCE__='${nonce}';</script>`
1010
+ : ''
1008
1011
 
1009
1012
  const docClose = `
1010
1013
  </div>
package/src/ui/icons.js CHANGED
@@ -1,7 +1,20 @@
1
1
  /**
2
2
  * Pulse UI — Icon set
3
3
  *
4
- * ~55 curated icons. All are pure functions returning an SVG string.
4
+ * SVG paths are derived from Lucide (https://lucide.dev) and Feather Icons.
5
+ *
6
+ * Lucide is licensed under the ISC License:
7
+ * Copyright (c) 2022 Lucide Contributors
8
+ * Permission to use, copy, modify, and/or distribute this software for any
9
+ * purpose with or without fee is hereby granted, provided that the above
10
+ * copyright notice and this permission notice appear in all copies.
11
+ *
12
+ * Portions derived from Feather Icons, licensed under the MIT License:
13
+ * Copyright (c) 2013-2017 Cole Bemis
14
+ *
15
+ * Phosphor hand-pointer icons (MIT): Copyright (c) 2020 Phosphor Icons
16
+ *
17
+ * Icon functions are pure functions returning an SVG string.
5
18
  * Style: 24×24 viewBox, stroke="currentColor", 2px stroke-width (Lucide-compatible).
6
19
  *
7
20
  * Usage:
@@ -159,3 +172,36 @@ export const iconLoader = (o) => s('<line x1="12" y1="2" x2="12" y2="6"/
159
172
  export const iconGrid = (o) => s('<rect x="3" y="3" width="7" height="7"/><rect x="14" y="3" width="7" height="7"/><rect x="14" y="14" width="7" height="7"/><rect x="3" y="14" width="7" height="7"/>', opts(o))
160
173
  export const iconBug = (o) => s('<rect x="8" y="6" width="8" height="14" rx="4"/><path d="m19 7-3 2"/><path d="m5 7 3 2"/><path d="m19 19-3-2"/><path d="m5 19 3-2"/><path d="M20 13h-4"/><path d="M4 13h4"/><path d="m10 4 1 2"/><path d="m14 4-1 2"/>', opts(o))
161
174
  export const iconMapPin = (o) => s('<path d="M20 10c0 6-8 13-8 13s-8-7-8-13a8 8 0 0 1 16 0Z"/><circle cx="12" cy="10" r="3"/>', opts(o))
175
+
176
+ // ─── Ecommerce ───────────────────────────────────────────────────────────────
177
+
178
+ export const iconShoppingCart = (o) => s('<circle cx="8" cy="21" r="1"/><circle cx="19" cy="21" r="1"/><path d="M2.05 2.05h2l2.66 12.42a2 2 0 0 0 2 1.58h9.78a2 2 0 0 0 1.95-1.57l1.65-7.43H5.12"/>', opts(o))
179
+ export const iconShoppingBag = (o) => s('<path d="M16 10a4 4 0 0 1-8 0"/><path d="M3.103 6.034h17.794"/><path d="M3.4 5.467a2 2 0 0 0-.4 1.2V20a2 2 0 0 0 2 2h14a2 2 0 0 0 2-2V6.667a2 2 0 0 0-.4-1.2l-2-2.667A2 2 0 0 0 17 2H7a2 2 0 0 0-1.6.8z"/>', opts(o))
180
+ export const iconCreditCard = (o) => s('<rect width="20" height="14" x="2" y="5" rx="2"/><line x1="2" x2="22" y1="10" y2="10"/>', opts(o))
181
+ export const iconPackage = (o) => s('<path d="M11 21.73a2 2 0 0 0 2 0l7-4A2 2 0 0 0 21 16V8a2 2 0 0 0-1-1.73l-7-4a2 2 0 0 0-2 0l-7 4A2 2 0 0 0 3 8v8a2 2 0 0 0 1 1.73z"/><path d="M12 22V12"/><path d="m7.5 4.27 9 5.15"/><polyline points="3.29 7 12 12 20.71 7"/>', opts(o))
182
+ export const iconGift = (o) => s('<path d="M12 7v14"/><path d="M20 11v8a2 2 0 0 1-2 2H6a2 2 0 0 1-2-2v-8"/><path d="M7.5 7a1 1 0 0 1 0-5A4.8 8 0 0 1 12 7a4.8 8 0 0 1 4.5-5 1 1 0 0 1 0 5"/><rect x="3" y="7" width="18" height="4" rx="1"/>', opts(o))
183
+ export const iconWallet = (o) => s('<path d="M19 7V4a1 1 0 0 0-1-1H5a2 2 0 0 0 0 4h15a1 1 0 0 1 1 1v4h-3a2 2 0 0 0 0 4h3a1 1 0 0 0 1-1v-2a1 1 0 0 0-1-1"/><path d="M3 5v14a2 2 0 0 0 2 2h15a1 1 0 0 0 1-1v-4"/>', opts(o))
184
+ export const iconTruck = (o) => s('<path d="M14 18V6a2 2 0 0 0-2-2H4a2 2 0 0 0-2 2v11a1 1 0 0 0 1 1h2"/><path d="M15 18H9"/><path d="M19 18h2a1 1 0 0 0 1-1v-3.65a1 1 0 0 0-.22-.624l-3.48-4.35A1 1 0 0 0 17.52 8H14"/><circle cx="17" cy="18" r="2"/><circle cx="7" cy="18" r="2"/>', opts(o))
185
+ export const iconReceipt = (o) => s('<path d="M12 17V7"/><path d="M16 8h-6a2 2 0 0 0 0 4h4a2 2 0 0 1 0 4H8"/><path d="M4 3a1 1 0 0 1 1-1 1.3 1.3 0 0 1 .7.2l.933.6a1.3 1.3 0 0 0 1.4 0l.934-.6a1.3 1.3 0 0 1 1.4 0l.933.6a1.3 1.3 0 0 0 1.4 0l.933-.6a1.3 1.3 0 0 1 1.4 0l.934.6a1.3 1.3 0 0 0 1.4 0l.933-.6A1.3 1.3 0 0 1 19 2a1 1 0 0 1 1 1v18a1 1 0 0 1-1 1 1.3 1.3 0 0 1-.7-.2l-.933-.6a1.3 1.3 0 0 0-1.4 0l-.934.6a1.3 1.3 0 0 1-1.4 0l-.933-.6a1.3 1.3 0 0 0-1.4 0l-.933.6a1.3 1.3 0 0 1-1.4 0l-.934-.6a1.3 1.3 0 0 0-1.4 0l-.933.6a1.3 1.3 0 0 1-.7.2 1 1 0 0 1-1-1z"/>', opts(o))
186
+ export const iconStore = (o) => s('<path d="M15 21v-5a1 1 0 0 0-1-1h-4a1 1 0 0 0-1 1v5"/><path d="M17.774 10.31a1.12 1.12 0 0 0-1.549 0 2.5 2.5 0 0 1-3.451 0 1.12 1.12 0 0 0-1.548 0 2.5 2.5 0 0 1-3.452 0 1.12 1.12 0 0 0-1.549 0 2.5 2.5 0 0 1-3.77-3.248l2.889-4.184A2 2 0 0 1 7 2h10a2 2 0 0 1 1.653.873l2.895 4.192a2.5 2.5 0 0 1-3.774 3.244"/><path d="M4 10.95V19a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2v-8.05"/>', opts(o))
187
+ export const iconPercent = (o) => s('<line x1="19" x2="5" y1="5" y2="19"/><circle cx="6.5" cy="6.5" r="2.5"/><circle cx="17.5" cy="17.5" r="2.5"/>', opts(o))
188
+ export const iconTicket = (o) => s('<path d="M2 9a3 3 0 0 1 0 6v2a2 2 0 0 0 2 2h16a2 2 0 0 0 2-2v-2a3 3 0 0 1 0-6V7a2 2 0 0 0-2-2H4a2 2 0 0 0-2 2Z"/><path d="M13 5v2"/><path d="M13 17v2"/><path d="M13 11v2"/>', opts(o))
189
+ export const iconBanknote = (o) => s('<rect width="20" height="12" x="2" y="6" rx="2"/><circle cx="12" cy="12" r="2"/><path d="M6 12h.01M18 12h.01"/>', opts(o))
190
+
191
+ // ─── Food & Drink ─────────────────────────────────────────────────────────────
192
+
193
+ export const iconUtensils = (o) => s('<path d="M3 2v7c0 1.1.9 2 2 2h4a2 2 0 0 0 2-2V2"/><path d="M7 2v20"/><path d="M21 15V2a5 5 0 0 0-5 5v6c0 1.1.9 2 2 2h3Zm0 0v7"/>', opts(o))
194
+ export const iconCoffee = (o) => s('<path d="M10 2v2"/><path d="M14 2v2"/><path d="M16 8a1 1 0 0 1 1 1v8a4 4 0 0 1-4 4H7a4 4 0 0 1-4-4V9a1 1 0 0 1 1-1h14a4 4 0 1 1 0 8h-1"/><path d="M6 2v2"/>', opts(o))
195
+ export const iconPizza = (o) => s('<path d="m12 14-1 1"/><path d="m13.75 18.25-1.25 1.42"/><path d="M17.775 5.654a15.68 15.68 0 0 0-12.121 12.12"/><path d="M18.8 9.3a1 1 0 0 0 2.1 7.7"/><path d="M21.964 20.732a1 1 0 0 1-1.232 1.232l-18-5a1 1 0 0 1-.695-1.232A19.68 19.68 0 0 1 15.732 2.037a1 1 0 0 1 1.232.695z"/>', opts(o))
196
+ export const iconApple = (o) => s('<path d="M12 6.528V3a1 1 0 0 1 1-1h0"/><path d="M18.237 21A15 15 0 0 0 22 11a6 6 0 0 0-10-4.472A6 6 0 0 0 2 11a15.1 15.1 0 0 0 3.763 10 3 3 0 0 0 3.648.648 5.5 5.5 0 0 1 5.178 0A3 3 0 0 0 18.237 21"/>', opts(o))
197
+ export const iconCarrot = (o) => s('<path d="M2.27 21.7s9.87-3.5 12.73-6.36a4.5 4.5 0 0 0-6.36-6.37C5.77 11.84 2.27 21.7 2.27 21.7zM8.64 14l-2.05-2.04M15.34 15l-2.46-2.46"/><path d="M22 9s-1.33-2-3.5-2C16.86 7 15 9 15 9s1.33 2 3.5 2S22 9 22 9z"/><path d="M15 2s-2 1.33-2 3.5S15 9 15 9s2-1.84 2-3.5C17 3.33 15 2 15 2z"/>', opts(o))
198
+ export const iconWine = (o) => s('<path d="M8 22h8"/><path d="M7 10h10"/><path d="M12 15v7"/><path d="M12 15a5 5 0 0 0 5-5c0-2-.5-4-2-8H9c-1.5 4-2 6-2 8a5 5 0 0 0 5 5Z"/>', opts(o))
199
+ export const iconCakeSlice = (o) => s('<path d="M16 13H3"/><path d="M16 17H3"/><path d="m7.2 7.9-3.388 2.5A2 2 0 0 0 3 12.01V20a1 1 0 0 0 1 1h16a1 1 0 0 0 1-1v-8.654c0-2-2.44-6.026-6.44-8.026a1 1 0 0 0-1.082.057L10.4 5.6"/><circle cx="9" cy="7" r="2"/>', opts(o))
200
+ export const iconFish = (o) => s('<path d="M6.5 12c.94-3.46 4.94-6 8.5-6 3.56 0 6.06 2.54 7 6-.94 3.47-3.44 6-7 6s-7.56-2.53-8.5-6Z"/><path d="M18 12v.5"/><path d="M16 17.93a9.77 9.77 0 0 1 0-11.86"/><path d="M7 10.67C7 8 5.58 5.97 2.73 5.5c-1 1.5-1 5 .23 6.5-1.24 1.5-1.24 5-.23 6.5C5.58 18.03 7 16 7 13.33"/><path d="M10.46 7.26C10.2 5.88 9.17 4.24 8 3h5.8a2 2 0 0 1 1.98 1.67l.23 1.4"/><path d="m16.01 17.93-.23 1.4A2 2 0 0 1 13.8 21H9.5a5.96 5.96 0 0 0 1.49-3.98"/>', opts(o))
201
+ export const iconCherry = (o) => s('<path d="M2 17a5 5 0 0 0 10 0c0-2.76-2.5-5-5-3-2.5-2-5 .24-5 3Z"/><path d="M12 17a5 5 0 0 0 10 0c0-2.76-2.5-5-5-3-2.5-2-5 .24-5 3Z"/><path d="M7 14c3.22-2.91 4.29-8.75 5-12 1.66 2.38 4.94 9 5 12"/><path d="M22 9c-4.29 0-7.14-2.33-10-7 5.71 0 10 4.67 10 7Z"/>', opts(o))
202
+ export const iconEgg = (o) => s('<path d="M12 2C8 2 4 8 4 14a8 8 0 0 0 16 0c0-6-4-12-8-12"/>', opts(o))
203
+ export const iconCookie = (o) => s('<path d="M12 2a10 10 0 1 0 10 10 4 4 0 0 1-5-5 4 4 0 0 1-5-5"/><path d="M8.5 8.5v.01"/><path d="M16 15.5v.01"/><path d="M12 12v.01"/><path d="M11 17v.01"/><path d="M7 14v.01"/>', opts(o))
204
+ export const iconIceCream = (o) => s('<path d="m7 11 4.08 10.35a1 1 0 0 0 1.84 0L17 11"/><path d="M17 7A5 5 0 0 0 7 7"/><path d="M17 7a2 2 0 0 1 0 4H7a2 2 0 0 1 0-4"/>', opts(o))
205
+ export const iconCroissant = (o) => s('<path d="M10.2 18H4.774a1.5 1.5 0 0 1-1.352-.97 11 11 0 0 1 .132-6.487"/><path d="M18 10.2V4.774a1.5 1.5 0 0 0-.97-1.352 11 11 0 0 0-6.486.132"/><path d="M18 5a4 3 0 0 1 4 3 2 2 0 0 1-2 2 10 10 0 0 0-5.139 1.42"/><path d="M5 18a3 4 0 0 0 3 4 2 2 0 0 0 2-2 10 10 0 0 1 1.42-5.14"/><path d="M8.709 2.554a10 10 0 0 0-6.155 6.155 1.5 1.5 0 0 0 .676 1.626l9.807 5.42a2 2 0 0 0 2.718-2.718l-5.42-9.807a1.5 1.5 0 0 0-1.626-.676"/>', opts(o))
206
+ export const iconSalad = (o) => s('<path d="M7 21h10"/><path d="M12 21a9 9 0 0 0 9-9H3a9 9 0 0 0 9 9Z"/><path d="M11.38 12a2.4 2.4 0 0 1-.4-4.77 2.4 2.4 0 0 1 3.2-2.77 2.4 2.4 0 0 1 3.47-.63 2.4 2.4 0 0 1 3.37 3.37 2.4 2.4 0 0 1-1.1 3.7 2.51 2.51 0 0 1 .03 1.1"/><path d="m13 12 4-4"/><path d="M10.9 7.25A3.99 3.99 0 0 0 4 10c0 .73.2 1.41.54 2"/>', opts(o))
207
+ export const iconWheat = (o) => s('<path d="M2 22 16 8"/><path d="M3.47 12.53 5 11l1.53 1.53a3.5 3.5 0 0 1 0 4.94L5 19l-1.53-1.53a3.5 3.5 0 0 1 0-4.94Z"/><path d="M7.47 8.53 9 7l1.53 1.53a3.5 3.5 0 0 1 0 4.94L9 15l-1.53-1.53a3.5 3.5 0 0 1 0-4.94Z"/><path d="M11.47 4.53 13 3l1.53 1.53a3.5 3.5 0 0 1 0 4.94L13 11l-1.53-1.53a3.5 3.5 0 0 1 0-4.94Z"/><path d="M20 2h2v2a4 4 0 0 1-4 4h-2V6a4 4 0 0 1 4-4Z"/><path d="M11.47 17.47 13 19l-1.53 1.53a3.5 3.5 0 0 1-4.94 0L5 19l1.53-1.53a3.5 3.5 0 0 1 4.94 0Z"/><path d="M15.47 13.47 17 15l-1.53 1.53a3.5 3.5 0 0 1-4.94 0L9 15l1.53-1.53a3.5 3.5 0 0 1 4.94 0Z"/><path d="M19.47 9.47 21 11l-1.53 1.53a3.5 3.5 0 0 1-4.94 0L13 11l1.53-1.53a3.5 3.5 0 0 1 4.94 0Z"/>', opts(o))
package/src/ui/index.js CHANGED
@@ -86,4 +86,9 @@ export {
86
86
  iconHandPointUp, iconHandPointDown, iconHandPointLeft, iconHandPointRight,
87
87
  iconGlobe, iconShield, iconZap, iconTrendingUp, iconTrendingDown, iconLoader, iconGrid, iconBug, iconMapPin,
88
88
  iconSun, iconMoon,
89
+ iconShoppingCart, iconShoppingBag, iconCreditCard, iconPackage, iconGift,
90
+ iconWallet, iconTruck, iconReceipt, iconStore, iconPercent, iconTicket, iconBanknote,
91
+ iconUtensils, iconCoffee, iconPizza, iconApple, iconCarrot,
92
+ iconWine, iconCakeSlice, iconFish, iconCherry, iconEgg,
93
+ iconCookie, iconIceCream, iconCroissant, iconSalad, iconWheat,
89
94
  } from './icons.js'