@invisibleloop/pulse 0.12.3 → 0.14.2

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 (181) hide show
  1. package/.claude/commands/build-page.md +5 -1
  2. package/.claude/commands/verify.md +31 -17
  3. package/.claude/pulse-checklist.md +21 -1
  4. package/.github/workflows/publish.yml +1 -0
  5. package/.pulse-verified +1 -0
  6. package/CLAUDE.md +56 -15
  7. package/docs/.claude/commands/verify.md +4 -0
  8. package/docs/.claude/pulse-checklist.md +20 -2
  9. package/docs/public/.pulse-ui-version +1 -1
  10. package/docs/public/andypixel.css +237 -0
  11. package/docs/public/bookworms.css +422 -0
  12. package/docs/public/coutumes.css +584 -0
  13. package/docs/public/images/bookworms/hero-books.jpg +0 -0
  14. package/docs/public/images/bookworms/how-we-read-now.jpg +0 -0
  15. package/docs/public/images/bookworms/lantern-keepers.jpg +0 -0
  16. package/docs/public/images/bookworms/provincial-bookseller.jpg +0 -0
  17. package/docs/public/images/journey/178.jpg +0 -0
  18. package/docs/public/images/journey/29.jpg +0 -0
  19. package/docs/public/images/retrowheels/hero.jpg +0 -0
  20. package/docs/public/images/shop/devbook-pro.jpg +0 -0
  21. package/docs/public/images/suzukibento/1015.jpg +0 -0
  22. package/docs/public/images/suzukibento/1025.jpg +0 -0
  23. package/docs/public/images/suzukibento/1043.jpg +0 -0
  24. package/docs/public/images/suzukibento/1059.jpg +0 -0
  25. package/docs/public/images/suzukibento/1062.jpg +0 -0
  26. package/docs/public/images/suzukibento/1084.jpg +0 -0
  27. package/docs/public/images/swish/1469334031218-e382a71b716b.jpg +0 -0
  28. package/docs/public/images/swish/1483985988355-763728e1935b.jpg +0 -0
  29. package/docs/public/images/swish/1485518882345-15568b007407.jpg +0 -0
  30. package/docs/public/images/swish/1490481651871-ab68de25d43d.jpg +0 -0
  31. package/docs/public/images/swish/1496747611176-843222e1e57c.jpg +0 -0
  32. package/docs/public/images/swish/1502716119720-b23a93e5fe1b.jpg +0 -0
  33. package/docs/public/images/swish/1539109136881-3be0616acf4b.jpg +0 -0
  34. package/docs/public/images/swish/1551803091-e20673f15770.jpg +0 -0
  35. package/docs/public/images/swish/1558769132-cb1aea458c5e.jpg +0 -0
  36. package/docs/public/images/swish/1572804013309-59a88b7e92f1.jpg +0 -0
  37. package/docs/public/journey.css +528 -0
  38. package/docs/public/pulse-ui.css +1036 -22
  39. package/docs/public/retrowheels.css +402 -0
  40. package/docs/public/shop.css +235 -0
  41. package/docs/public/sparkys.css +349 -0
  42. package/docs/public/suzukibento.css +517 -0
  43. package/docs/public/swish.css +394 -0
  44. package/docs/server.js +19 -4
  45. package/docs/src/lib/nav.js +14 -3
  46. package/docs/src/pages/components/decorate.js +74 -0
  47. package/docs/src/pages/components/gallery.js +102 -0
  48. package/docs/src/pages/components/hero.js +1 -0
  49. package/docs/src/pages/components/marquee.js +76 -0
  50. package/docs/src/pages/components/photo-card.js +74 -0
  51. package/docs/src/pages/components/section.js +40 -1
  52. package/docs/src/pages/meta.js +26 -0
  53. package/docs/src/pages/templates/andypixel.js +260 -0
  54. package/docs/src/pages/templates/bookworms.js +227 -0
  55. package/docs/src/pages/templates/coutumes.js +226 -0
  56. package/docs/src/pages/templates/journey.js +186 -0
  57. package/docs/src/pages/templates/retrowheels.js +342 -0
  58. package/docs/src/pages/templates/shop.js +257 -0
  59. package/docs/src/pages/templates/sparkys.js +249 -0
  60. package/docs/src/pages/templates/suzukibento.js +200 -0
  61. package/docs/src/pages/templates/swish.js +313 -0
  62. package/docs/src/pages/templates.js +313 -2
  63. package/docs/src/pages/testing.js +2 -1
  64. package/examples/.claude/commands/pulse-build.md +7 -0
  65. package/examples/.claude/commands/pulse-dev.md +7 -0
  66. package/examples/.claude/commands/pulse-start.md +9 -0
  67. package/examples/.claude/commands/pulse-stop.md +5 -0
  68. package/examples/.claude/commands/verify.md +84 -0
  69. package/examples/.claude/coverage-check.js +127 -0
  70. package/examples/.claude/pulse-checklist.md +177 -0
  71. package/examples/andypixel.css +237 -0
  72. package/examples/andypixel.js +258 -0
  73. package/examples/bookworms.css +422 -0
  74. package/examples/bookworms.js +225 -0
  75. package/examples/contact.js +1 -0
  76. package/examples/counter.js +1 -0
  77. package/examples/dev.server.js +17 -0
  78. package/examples/journey.css +528 -0
  79. package/examples/journey.js +186 -0
  80. package/examples/pricing.js +1 -0
  81. package/examples/public/.pulse-ui-version +1 -0
  82. package/examples/public/pulse-ui.css +4031 -0
  83. package/examples/public/pulse-ui.js +257 -0
  84. package/examples/quiz.js +1 -0
  85. package/examples/retrowheels.css +402 -0
  86. package/examples/retrowheels.js +340 -0
  87. package/examples/shared.js +6 -0
  88. package/examples/suzukibento.css +513 -0
  89. package/examples/suzukibento.js +200 -0
  90. package/examples/swish.css +394 -0
  91. package/examples/swish.js +311 -0
  92. package/examples/todos.js +1 -0
  93. package/package.json +2 -3
  94. package/public/.pulse-ui-version +1 -1
  95. package/public/images/bookworms/hero-books.jpg +0 -0
  96. package/public/images/bookworms/how-we-read-now.jpg +0 -0
  97. package/public/images/bookworms/lantern-keepers.jpg +0 -0
  98. package/public/images/bookworms/provincial-bookseller.jpg +0 -0
  99. package/public/images/journey/178.jpg +0 -0
  100. package/public/images/journey/29.jpg +0 -0
  101. package/public/images/retrowheels/hero.jpg +0 -0
  102. package/public/images/shop/devbook-pro.jpg +0 -0
  103. package/public/images/suzukibento/1015.jpg +0 -0
  104. package/public/images/suzukibento/1025.jpg +0 -0
  105. package/public/images/suzukibento/1043.jpg +0 -0
  106. package/public/images/suzukibento/1059.jpg +0 -0
  107. package/public/images/suzukibento/1062.jpg +0 -0
  108. package/public/images/suzukibento/1084.jpg +0 -0
  109. package/public/images/swish/1469334031218-e382a71b716b.jpg +0 -0
  110. package/public/images/swish/1483985988355-763728e1935b.jpg +0 -0
  111. package/public/images/swish/1485518882345-15568b007407.jpg +0 -0
  112. package/public/images/swish/1490481651871-ab68de25d43d.jpg +0 -0
  113. package/public/images/swish/1496747611176-843222e1e57c.jpg +0 -0
  114. package/public/images/swish/1502716119720-b23a93e5fe1b.jpg +0 -0
  115. package/public/images/swish/1539109136881-3be0616acf4b.jpg +0 -0
  116. package/public/images/swish/1551803091-e20673f15770.jpg +0 -0
  117. package/public/images/swish/1558769132-cb1aea458c5e.jpg +0 -0
  118. package/public/images/swish/1572804013309-59a88b7e92f1.jpg +0 -0
  119. package/public/pulse-ui.css +1080 -22
  120. package/scripts/build.js +72 -18
  121. package/src/agent/checklist.md +22 -2
  122. package/src/agent/commands/verify.md +4 -0
  123. package/src/agent/guide-components.md +146 -8
  124. package/src/agent/guide-design-gallery.md +288 -0
  125. package/src/agent/guide-design-references.md +172 -0
  126. package/src/agent/guide-explore.md +313 -0
  127. package/src/agent/guide-styles.md +212 -11
  128. package/src/agent/guide-templates.md +223 -10
  129. package/src/agent/guide.md +9 -4
  130. package/src/agent/identity.md +2 -1
  131. package/src/agent/skills/verify/SKILL.md +64 -28
  132. package/src/agent/workflow.md +247 -61
  133. package/src/cli/dev.js +39 -14
  134. package/src/cli/fmt.js +113 -0
  135. package/src/cli/index.js +60 -33
  136. package/src/cli/logger.js +145 -0
  137. package/src/cli/scaffold.js +57 -3
  138. package/src/html.js +6 -1
  139. package/src/mcp/server.js +2561 -99
  140. package/src/mcp/validate-worker.js +49 -5
  141. package/src/runtime/ssr.js +4 -1
  142. package/src/server/index.js +29 -5
  143. package/src/spec/schema.js +21 -0
  144. package/src/spec/schema.test.js +28 -0
  145. package/src/testing/html.js +130 -4
  146. package/src/testing/testing.test.js +58 -0
  147. package/src/ui/banner.js +24 -9
  148. package/src/ui/brand-statement.js +58 -0
  149. package/src/ui/button.js +13 -2
  150. package/src/ui/card.js +20 -1
  151. package/src/ui/colophon.js +29 -0
  152. package/src/ui/contact-row.js +41 -0
  153. package/src/ui/cta.js +18 -4
  154. package/src/ui/decorate.js +55 -0
  155. package/src/ui/display-heading.js +44 -0
  156. package/src/ui/drop-cap.js +29 -0
  157. package/src/ui/editorial-hero.js +63 -0
  158. package/src/ui/entry-list.js +36 -0
  159. package/src/ui/feature.js +20 -3
  160. package/src/ui/footer.js +78 -9
  161. package/src/ui/gallery.js +56 -0
  162. package/src/ui/hero.js +68 -18
  163. package/src/ui/image-pair.js +72 -0
  164. package/src/ui/index.js +20 -0
  165. package/src/ui/input.js +1 -0
  166. package/src/ui/marquee.js +48 -0
  167. package/src/ui/nav.js +19 -9
  168. package/src/ui/photo-card.js +44 -0
  169. package/src/ui/product-card.js +65 -0
  170. package/src/ui/pullquote.js +14 -3
  171. package/src/ui/search.js +1 -0
  172. package/src/ui/section-label.js +38 -0
  173. package/src/ui/section.js +1 -1
  174. package/src/ui/service-card.js +47 -0
  175. package/src/ui/stat-grid.js +40 -0
  176. package/src/ui/stat.js +16 -2
  177. package/src/ui/testimonial.js +17 -6
  178. package/src/ui/textarea.js +1 -0
  179. package/src/ui/top-strip.js +47 -0
  180. package/src/ui/trust-list.js +46 -0
  181. package/src/ui/ui.test.js +35 -0
package/scripts/build.js CHANGED
@@ -19,6 +19,7 @@ import path from 'path'
19
19
  import { createHash } from 'crypto'
20
20
  import { discoverPages } from '../src/cli/discover.js'
21
21
  import { renderToString } from '../src/runtime/ssr.js'
22
+ import { c, header, table, ok, fail, info, elapsed, icon } from '../src/cli/fmt.js'
22
23
 
23
24
  // ---------------------------------------------------------------------------
24
25
  // Server-only property stripping
@@ -386,10 +387,16 @@ const TMP_DIR = path.join(ROOT, '.pulse-build')
386
387
  const pages = discoverPages(ROOT)
387
388
 
388
389
  if (pages.length === 0) {
389
- console.error('No pages found in src/pages/. Nothing to build.')
390
+ console.error(fail('No pages found in src/pages/ — nothing to build.'))
390
391
  process.exit(1)
391
392
  }
392
393
 
394
+ // ---------------------------------------------------------------------------
395
+ // Timing
396
+ // ---------------------------------------------------------------------------
397
+
398
+ const BUILD_START = Date.now()
399
+
393
400
  // ---------------------------------------------------------------------------
394
401
  // Clean output directories
395
402
  // ---------------------------------------------------------------------------
@@ -399,7 +406,7 @@ for (const dir of [OUT_DIR, TMP_DIR]) {
399
406
  fs.mkdirSync(dir, { recursive: true })
400
407
  }
401
408
 
402
- console.log('⚡ Building Pulse client bundles...\n')
409
+ console.log(header(`Building ${pages.length} page${pages.length === 1 ? '' : 's'}`))
403
410
 
404
411
  // ---------------------------------------------------------------------------
405
412
  // Generate bootstrap entry points
@@ -518,20 +525,66 @@ await purgeCssStep(pages, manifest, ROOT, OUT_DIR)
518
525
  await jsAssetsStep(ROOT, OUT_DIR, manifest)
519
526
 
520
527
  // ---------------------------------------------------------------------------
521
- // Report
528
+ // Summary table
522
529
  // ---------------------------------------------------------------------------
523
530
 
524
- console.log('Bundles:\n')
525
- for (const [src, bundle] of Object.entries(manifest)) {
526
- if (bundle.startsWith('/dist/')) {
531
+ {
532
+ const rows = []
533
+
534
+ for (const [src, bundle] of Object.entries(manifest)) {
535
+ // Skip runtime chunk and static assets — only show per-page bundles + CSS
536
+ if (!bundle.startsWith('/dist/')) continue
527
537
  const filePath = path.join(ROOT, 'public', bundle)
528
- if (fs.existsSync(filePath)) {
529
- const size = fs.statSync(filePath).size
530
- console.log(` ${src.padEnd(36)} → ${bundle} (${(size / 1024).toFixed(1)} kB)`)
538
+ if (!fs.existsSync(filePath)) continue
539
+
540
+ const sizeBytes = fs.statSync(filePath).size
541
+ const sizeKb = (sizeBytes / 1024).toFixed(1)
542
+ const ext = path.extname(bundle)
543
+
544
+ if (ext === '.js' && src.startsWith('/src/pages/')) {
545
+ // Per-page JS bundle — find if it's hydrated
546
+ const srcPath = path.join(ROOT, src.replace(/^\//, ''))
547
+ let route = src.replace('/src/pages', '').replace(/\.js$/, '')
548
+ // Try to extract route from the spec
549
+ try {
550
+ const specSrc = fs.readFileSync(srcPath, 'utf8')
551
+ const m = specSrc.match(/route\s*:\s*['"`]([^'"`]+)['"`]/)
552
+ if (m) route = m[1]
553
+ } catch { /* use filename-derived route */ }
554
+ rows.push([
555
+ c.cyan(route),
556
+ c.dim(bundle.replace('/dist/', '')),
557
+ `${sizeKb} kB`,
558
+ c.dim('js'),
559
+ ])
560
+ } else if (ext === '.css') {
561
+ rows.push([
562
+ c.dim(src),
563
+ c.dim(bundle.replace('/dist/', '')),
564
+ `${sizeKb} kB`,
565
+ c.dim('css'),
566
+ ])
531
567
  }
532
568
  }
569
+
570
+ // Sort: pages first, then CSS, then other
571
+ rows.sort((a, b) => {
572
+ const aIsPage = a[3].includes('js')
573
+ const bIsPage = b[3].includes('js')
574
+ if (aIsPage && !bIsPage) return -1
575
+ if (!aIsPage && bIsPage) return 1
576
+ return 0
577
+ })
578
+
579
+ console.log('\n' + table(
580
+ ['Route / Asset', 'Bundle', 'Size', 'Type'],
581
+ rows,
582
+ { align: ['left', 'left', 'right', 'left'] }
583
+ ))
584
+
585
+ const totalMs = Date.now() - BUILD_START
586
+ console.log(`\n${ok(`Build complete`)} ${c.dim(elapsed(totalMs))} ${c.dim('→')} ${c.dim('public/dist/')}\n`)
533
587
  }
534
- console.log('\n✓ manifest written to public/dist/manifest.json\n')
535
588
 
536
589
  // ---------------------------------------------------------------------------
537
590
  // JS static assets implementation
@@ -544,7 +597,7 @@ async function jsAssetsStep(root, outDir, manifest) {
544
597
  const jsFiles = fs.readdirSync(publicDir).filter(f => f.endsWith('.js'))
545
598
  if (jsFiles.length === 0) return
546
599
 
547
- console.log('⚡ Hashing static JS assets...\n')
600
+ console.log(info('Hashing static JS assets…'))
548
601
 
549
602
  for (const file of jsFiles) {
550
603
  const srcPath = path.join(publicDir, file)
@@ -559,11 +612,11 @@ async function jsAssetsStep(root, outDir, manifest) {
559
612
 
560
613
  const origKb = (Buffer.byteLength(source) / 1024).toFixed(1)
561
614
  const minKb = (Buffer.byteLength(code) / 1024).toFixed(1)
562
- console.log(` /${file.padEnd(24)} → /dist/${outName} (${minKb} kB from ${origKb} kB)`)
615
+ // silent — summary table covers this
563
616
  }
564
617
 
565
618
  fs.writeFileSync(path.join(outDir, 'manifest.json'), JSON.stringify(manifest, null, 2))
566
- console.log('\n✓ JS asset entries added to manifest\n')
619
+ // silent — summary table covers this
567
620
  }
568
621
 
569
622
  // ---------------------------------------------------------------------------
@@ -571,7 +624,7 @@ async function jsAssetsStep(root, outDir, manifest) {
571
624
  // ---------------------------------------------------------------------------
572
625
 
573
626
  async function purgeCssStep(pages, manifest, root, outDir) {
574
- console.log('⚡ Purging CSS...\n')
627
+ console.log(info('Purging CSS…'))
575
628
 
576
629
  const htmlContents = []
577
630
  const jsContents = []
@@ -633,7 +686,7 @@ async function purgeCssStep(pages, manifest, root, outDir) {
633
686
  }
634
687
 
635
688
  if (cssFiles.size === 0) {
636
- console.log(' No CSS files referenced — skipping.\n')
689
+ console.log(info('No CSS files referenced — skipping.'))
637
690
  return
638
691
  }
639
692
 
@@ -658,14 +711,14 @@ async function purgeCssStep(pages, manifest, root, outDir) {
658
711
  const purgKb = (Buffer.byteLength(purged) / 1024).toFixed(1)
659
712
  const pct = Math.round((1 - Buffer.byteLength(purged) / Buffer.byteLength(original)) * 100)
660
713
 
661
- console.log(` ${cssHref.padEnd(28)} → /dist/${outName} (${purgKb} kB, ${pct}% removed from ${origKb} kB)`)
714
+ // silent — summary table covers this
662
715
 
663
716
  manifest[cssHref] = `/dist/${outName}`
664
717
  }
665
718
 
666
719
  // Re-write manifest with CSS entries added
667
720
  fs.writeFileSync(path.join(outDir, 'manifest.json'), JSON.stringify(manifest, null, 2))
668
- console.log('\n✓ CSS entries added to manifest\n')
721
+ // silent — summary table covers this
669
722
  }
670
723
 
671
724
  // ---------------------------------------------------------------------------
@@ -798,7 +851,8 @@ function minifyCss(css) {
798
851
  return css
799
852
  .replace(/\/\*[\s\S]*?\*\//g, '') // strip comments
800
853
  .replace(/\s+/g, ' ') // collapse whitespace
801
- .replace(/\s*([{}:;,>~+])\s*/g, '$1') // remove spaces around punctuation
854
+ // Remove spaces around safe punctuation — NOT + or - (would break calc() expressions)
855
+ .replace(/\s*([{}:;,>~])\s*/g, '$1')
802
856
  .replace(/;}/g, '}') // remove trailing semicolons
803
857
  .trim()
804
858
  }
@@ -22,9 +22,29 @@ Before finishing any spec, verify every point below. Fix anything that fails.
22
22
 
23
23
  - **Always restart the server and reload the browser after every file edit — before checking the result.** The dev server restarts on file changes but the browser tab stays stale. The required sequence is: `pulse_restart_server` → `navigate_page`. Never attempt to debug a visual problem without doing both steps first.
24
24
 
25
- ### Components first
25
+ ### Components first — or creative override
26
26
 
27
27
  - **Before writing any HTML by hand, check `src/ui/index.js`.** There are 50+ components. Use `button`, `card`, `alert`, `input`, `spinner`, `badge`, `modal`, `nav`, `pagination`, `table`, etc. before writing equivalent HTML from scratch.
28
+ - **Do not reinvent component patterns.** These patterns have dedicated components — never write custom HTML for them unless a creative override applies (see below):
29
+ - Hero sections → `hero({ title, subtitle, actions, image, ... })`
30
+ - Product/service cards → `card({ title, body, image, footer, ... })`
31
+ - Image + text two-column layouts → `media({ image, content, reverse })`
32
+ - Horizontal strips/banners → `banner({ content, variant })`
33
+ - Feature tiles → `feature({ icon, title, description })`
34
+ - Testimonials → `testimonial({ quote, name, role, src })`
35
+ - CTAs → `cta({ title, subtitle, actions })`
36
+ - Navigation → `nav({ logo, links, actions })`
37
+ - Footers → `footer({ logo, links, columns, legal, ... })`
38
+
39
+ - **Creative override — raw HTML throughout is permitted** when the design intent genuinely calls for it: a highly custom visual identity, an asymmetric or typographically-driven layout, a brutalist/editorial/retro/neon/paper vibe where components would constrain the expression, or a layout that requires features a component simply doesn't support (e.g. full-viewport height, custom gradient glows, clamp-scaled display type). This is a deliberate design decision, not a shortcut. When taking this path:
40
+ - **Declare it in a comment at the top of the spec** so the reviewer recognises it: `// component-free — creative override: <reason>`. This is the signal the review tool reads.
41
+ - Functional atoms (`button`, `input`, `badge`, `modal`) should still come from components unless there is a specific design reason not to.
42
+ - `/pulse-ui.css` is always required — it provides the token system your custom CSS will use.
43
+ - The quality gate replaces the component checklist: **100/100/100/100 Lighthouse on both desktop and mobile is the pass bar.** A component-free page that passes every audit is correct. A component-heavy page that fails accessibility is not.
44
+
45
+ - **When reviewing**: before flagging a class name like `.hero` as a violation, first ask whether the `hero()` component could actually reproduce the layout. If the answer is no, it is a creative override — check Lighthouse instead of refactoring. Do not flatten a good custom layout into a generic component just to clear a pattern-match.
46
+
47
+ - **The review tool auto-detects creative override** from the spec comment. When detected, component pattern checks are shown as advisory (`⚡`) not as failures, and the Lighthouse gate is the stated pass bar.
28
48
 
29
49
  - **Never write App Store or Google Play download buttons by hand.** Always use `appBadge({ store: 'apple', href })` and `appBadge({ store: 'google', href })`. Raw `<a>` tags with badge images are incorrect — `appBadge` renders the correct accessible, styled badge for each store.
30
50
 
@@ -110,7 +130,7 @@ Before finishing any spec, verify every point below. Fix anything that fails.
110
130
  assert.equal(result.get('h1').text, mockProduct.name)
111
131
  assert.equal(result.attr('img', 'src'), mockProduct.image)
112
132
  ```
113
- Supported selectors: `tag`, `.class`, `#id`, `[attr]`, `[attr="value"]`, and combinations (`button.primary[disabled]`).
133
+ Supported selectors: `tag`, `.class`, `#id`, `[attr]`, `[attr="value"]`, combinations (`button.primary[disabled]`), and **descendant combinators** (`result.count('.parent li')`, `result.find('tbody tr')`). Descendant selectors match the rightmost token within the ancestor context — they do not return 0 if the ancestor doesn't exist, they simply find no matching descendants.
114
134
 
115
135
  ### Markdown
116
136
 
@@ -16,6 +16,8 @@ Use `pulse_validate` with the spec file content. If validation fails, report eve
16
16
 
17
17
  Use `pulse_fetch_page` to confirm the server is responding for the route. If it errors, use `pulse_restart_server` and retry once.
18
18
 
19
+ **If `pulse_fetch_page` returns unexpected HTML (e.g. a different project's page title, a Next.js 404, or any non-Pulse response):** check the `X-Pulse` response header — Pulse servers always send this. If it is absent, the wrong server is running on that port. Stop and inform the user before continuing.
20
+
19
21
  ### 4. Screenshot
20
22
 
21
23
  Use `mcp__chrome-devtools__navigate_page` to load the page route, then `mcp__chrome-devtools__take_screenshot` to capture the result. Describe what you see — layout, content, any obvious rendering issues.
@@ -32,6 +34,8 @@ Run `mcp__chrome-devtools__lighthouse_audit` on the same route with `{ "strategy
32
34
 
33
35
  **Same pass bar: Accessibility, Best Practices, and SEO must all be 100.** Fix any failures and restart from step 2.
34
36
 
37
+ **If you need to visually inspect the layout at a mobile viewport width** (e.g. to debug a wrapping or overflow issue), use `mcp__chrome-devtools__emulate` with `viewport: "390x844x2,mobile,touch"` — not `resize_page`. The `emulate` tool reliably sets the viewport; `resize_page` does not reliably trigger responsive layout changes. Reset afterward with `emulate` using the desktop viewport: `"1440x900x1"`.
38
+
35
39
  ### 7. Performance
36
40
 
37
41
  Navigate to the page route, then run `mcp__chrome-devtools__performance_start_trace` with `reload: true` and `autoStop: true`. Report LCP and CLS. Flag any LCP insight that suggests a fixable problem (render-blocking resources, large image load delay, etc.).
@@ -12,6 +12,62 @@ meta: { styles: ['/pulse-ui.css', '/app.css'] }
12
12
 
13
13
  Interactive components (carousel, tooltip) also need `/pulse-ui.js` in `meta.scripts`. Modal is handled natively by the Pulse runtime — no extra script needed.
14
14
 
15
+ ### Component root CSS classes
16
+
17
+ Use these when writing tests (`.get('.ui-card')`) or custom CSS overrides. The root class is the one to target — not an invented name.
18
+
19
+ | Component fn | Root CSS class | Notes |
20
+ |---|---|---|
21
+ | `nav` | `.ui-nav` | |
22
+ | `hero` | `.ui-hero` | |
23
+ | `button` | `.ui-btn` | |
24
+ | `card` | `.ui-card` | |
25
+ | `input` / `select` / `textarea` / `search` / `slider` | `.ui-field` | Wrapper div; inner input has `.ui-input` |
26
+ | `modal` | `.ui-modal` | |
27
+ | `alert` | `.ui-alert` | |
28
+ | `badge` | `.ui-badge` | |
29
+ | `stat` | `.ui-stat` | |
30
+ | `avatar` | `.ui-avatar` | |
31
+ | `empty` | `.ui-empty` | |
32
+ | `table` | `.ui-table-wrap` | Inner `<table>` has `.ui-table` |
33
+ | `spinner` | `.ui-spinner` | |
34
+ | `section` | `.ui-section` | |
35
+ | `container` | `.ui-container` | |
36
+ | `grid` | `.ui-grid` | |
37
+ | `stack` | `.ui-stack` | |
38
+ | `cluster` | `.ui-cluster` | |
39
+ | `feature` | `.ui-feature` | |
40
+ | `testimonial` | `.ui-testimonial` | |
41
+ | `pricing` | `.ui-pricing` | |
42
+ | `accordion` | `.ui-accordion` | |
43
+ | `cta` | `.ui-cta` | |
44
+ | `footer` | `.ui-footer` | |
45
+ | `phoneFrame` | `.ui-phone` | **Not** `.ui-phone-frame` |
46
+ | `prose` | `.ui-prose` | |
47
+ | `progress` | `.ui-progress` | |
48
+ | `breadcrumbs` | `.ui-breadcrumbs` | |
49
+ | `stepper` | `.ui-stepper` | |
50
+ | `timeline` | `.ui-timeline` | |
51
+ | `carousel` | `.ui-carousel` | |
52
+ | `tooltip` | `.ui-tooltip` | |
53
+ | `appBadge` | `.ui-app-badge` | |
54
+ | `banner` | `.ui-banner` | |
55
+ | `uiImage` | `.ui-image` | |
56
+ | `gallery` | `.ui-gallery` | |
57
+ | `photoCard` | `.ui-photo-card` | |
58
+ | `marquee` | `.ui-marquee` | |
59
+ | `decorate` | `.ui-decorate` | Inline SVG — parent needs `position: relative` |
60
+ | `codeWindow` | `.ui-code-window` | |
61
+ | `segmented` | `.ui-segmented` | |
62
+ | `rating` | `.ui-rating` | |
63
+ | `entryList` | `.ui-entry-list` | Editorial — tabular list with metadata |
64
+ | `displayHeading` | `.ui-display-heading` | Editorial — large typographic heading |
65
+ | `sectionLabel` | `.ui-section-label` | Editorial — eyebrow + h2 + rule |
66
+ | `dropCap` | `.ui-drop-cap` | Editorial — first-letter treatment |
67
+ | `colophon` | `.ui-colophon` | Editorial — minimal footer |
68
+
69
+
70
+
15
71
  ### Charts
16
72
 
17
73
  Server-rendered SVG charts — no JS, no dependencies. Drop into any card, section, or grid.
@@ -92,7 +148,7 @@ To add a missing icon: copy a Lucide path (MIT) into `src/ui/icons.js` using `s(
92
148
 
93
149
  | Component | Key props |
94
150
  |-----------|-----------|
95
- | `button` | `label`, `variant` (primary/secondary/ghost/danger), `size` (sm/md/lg), `href`, `type`, `disabled`, `fullWidth`, `icon`, `iconAfter`, `attrs` |
151
+ | `button` | `label`, `variant` (primary/secondary/ghost/**ghost-light**/danger), `size` (sm/md/lg), `href`, `type`, `disabled`, `fullWidth`, `icon`, `iconAfter`, `attrs` — **use `ghost-light` when the button sits on a dark background** (hero, `section--dark`, navy wrapper); `ghost` uses `--ui-muted` text which fails contrast on dark backgrounds in light theme |
96
152
  | `input` | `name`, `label`, `type`, `placeholder`, `value`, `error`, `hint`, `required`, `disabled`, `attrs` |
97
153
  | `search` | `name`, `label`, `labelHidden`, `placeholder`, `value`, `event` (e.g. `'input:setSearch'`), `debounce` (ms, default 200), `clearEvent` (shown when value non-empty), `disabled`, `attrs` — search input with icon + clear button. **Use this instead of `input({ type: 'search' })`.** |
98
154
  | `select` | `name`, `label`, `options` (strings or `{value,label}`), `value`, `error`, `required`, `event` |
@@ -110,10 +166,10 @@ To add a missing icon: copy a Lucide path (MIT) into `src/ui/icons.js` using `s(
110
166
 
111
167
  | Component | Key props |
112
168
  |-----------|-----------|
113
- | `card` | `title`, `level` (1–6, default 3 — heading tag for title; visual style unchanged), `content` (HTML slot), `footer` (HTML slot), `flush` (full-bleed — removes body padding) |
169
+ | `card` | `title`, `level` (1–6, default 3), `content` (HTML slot), `footer` (HTML slot), `flush` (full-bleed), `variant` (default/elevated/bordered/flat/glass/tinted) |
114
170
  | `alert` | `variant` (info/success/warning/error), `title`, `content` |
115
171
  | `badge` | `label`, `variant` (default/success/warning/error/info) |
116
- | `stat` | `label`, `value`, `change`, `trend` (up/down/neutral), `center` |
172
+ | `stat` | `label`, `value`, `change`, `trend` (up/down/neutral), `size` (sm/md/lg — lg for hero KPIs), `center` |
117
173
  | `avatar` | `src`, `alt`, `size` (sm/md/lg/xl), `initials` |
118
174
  | `empty` | `title`, `description`, `action` ({label,href,variant}) |
119
175
  | `table` | `headers`, `rows` (2D array of HTML strings), `caption` |
@@ -122,6 +178,10 @@ To add a missing icon: copy a Lucide path (MIT) into `src/ui/icons.js` using `s(
122
178
  | `breadcrumbs` | `items` ([{label,href}] — last item has no href), `separator` |
123
179
  | `stepper` | `steps` (array of label strings), `current` (0-based) |
124
180
  | `uiImage` | `src`, `alt`, `caption`, `ratio`, `rounded`, `pill`, `width`, `height`, `maxWidth` |
181
+ | `gallery` | `images` ([{src,alt,caption?,href?}]), `layout` (grid/strip/masonry), `cols` (1–4), `gap` (sm/md/lg), `rounded`, `ratio` — responsive image grid |
182
+ | `photoCard` | `src`, `alt`, `caption`, `tilt` (CSS rotate degrees, e.g. 2 or -1.5), `rounded`, `ratio` — polaroid-style card with optional tilt |
183
+ | `marquee` | `items` (array of HTML strings), `speed` (seconds, default 30), `gap`, `direction` (left/right), `pause` (hover pause), `fade` — CSS-only infinite scroll strip. Ideal for logos, trust badges |
184
+ | `decorate` | `pattern` (dots/grid/lines/zigzag/cross), `color`, `opacity`, `size` — inline SVG overlay. Parent needs `position: relative` (add class `u-relative`) |
125
185
  | `pullquote` | `quote`, `cite`, `size` (md/lg) |
126
186
  | `timeline` | `direction` (vertical/horizontal), `items` ([{dot,dotColor,label,content}]) |
127
187
  | `timelineItem` | `content`, `label`, `dot`, `dotColor` (accent/success/warning/error/muted) |
@@ -142,26 +202,104 @@ To add a missing icon: copy a Lucide path (MIT) into `src/ui/icons.js` using `s(
142
202
 
143
203
  Use `prose()` for HTML from external sources (CMS, markdown). Use `heading()`/`list()` when writing content yourself.
144
204
 
205
+ ### Editorial components
206
+
207
+ Editorial/typographic primitives for non-SaaS layouts: paperback books, vintage magazines, editorial sites, portfolio pages. These complement the landing-page-focused components (hero, feature, cta) when the design needs structure that's tabular, asymmetric, or typography-led.
208
+
209
+ | Component | Key props | Use for |
210
+ |-----------|-----------|---------|
211
+ | `entryList` | `items` ([{meta, title, description}]), `metaLabel` (accessible name for meta column — e.g. "Year") | Backlists, release notes, changelogs, project archives, talk logs |
212
+ | `displayHeading` | `text`, `level` (1–6), `maxWidth` (ch units, default 20), `balance` (true), `tracking` ('tight'\|'normal'\|'wide') | Hero titles, chapter openings, large typographic moments |
213
+ | `sectionLabel` | `eyebrow`, `heading`, `level` (1–6, default 2), `rule` (true — show horizontal rule), `align` ('left'\|'center') | Section dividers, chapter labels, content blocks |
214
+ | `dropCap` | `letter` (the drop cap letter or word), `content` (rest of paragraph), `lines` (height in lines, default 3) | First-paragraph treatment, editorial openings |
215
+ | `colophon` | `content` (HTML slot), `align` ('center'\|'left') | Paperback-style footer — minimal, tight type, no columns |
216
+
217
+ #### When to use editorial components
218
+
219
+ Use these when the design direction is **editorial**, **retro**, **brutalist**, or **paper** vibe, or when the content structure is naturally tabular (list of books, releases, projects). They're shaped for asymmetry and typography-first layouts, not centred hero + three-column features.
220
+
221
+ **Example: Book backlist**
222
+ ```js
223
+ entryList({
224
+ metaLabel: 'Year',
225
+ items: [
226
+ { meta: '2023', title: 'The Argent Ghost', description: 'A tale of time-travel and lost identity.' },
227
+ { meta: '2021', title: 'Signal Drift', description: 'First contact from a dead civilization.' },
228
+ { meta: '2019', title: 'Cold Equations', description: 'Survival at the edge of known space.' },
229
+ ],
230
+ })
231
+ ```
232
+
233
+ **Example: Editorial hero**
234
+ ```js
235
+ ${displayHeading({ text: 'In Search of Lost Code', level: 1, tracking: 'tight' })}
236
+ ${dropCap({ letter: 'T', content: 'he first computers were humans, pencil-wielding calculators in windowless rooms, computing artillery trajectories by hand...' })}
237
+ ```
238
+
239
+ **Example: Section divider**
240
+ ```js
241
+ sectionLabel({ eyebrow: 'Chapter 3', heading: 'The Rise of Symbolic Logic', rule: true })
242
+ ```
243
+
244
+ **Example: Minimal footer for an editorial page**
245
+ ```js
246
+ colophon({
247
+ content: `<p>© 2025 Alden Press • Set in Lyon Text and Atlas Grotesk</p>
248
+ <p><a href="/about">About</a> • <a href="/contact">Contact</a></p>`,
249
+ })
250
+ ```
251
+
252
+ **Composition:** Editorial components work inside `section()` + `container()` like any other Pulse component. Use `prose()` for body text, `sectionLabel()` for section breaks, `entryList()` for tabular content, `displayHeading()` for large type moments, and `colophon()` instead of `footer()` for editorial pages.
253
+
254
+ #### When components feel restrictive
255
+
256
+ **Symptom:** You're fighting a component's wrapper styles (padding, max-width, centering) in your CSS overrides.
257
+
258
+ **Solutions:**
259
+ 1. **Use the right component for your design.** If the page is editorial/typographic, use the editorial components (`displayHeading`, `sectionLabel`, `entryList`, `colophon`) instead of trying to force `hero()`, `feature()`, `cta()` into an editorial shape.
260
+ 2. **Write custom markup for structural differences.** Components provide sensible defaults for common patterns. When your design structure diverges significantly (asymmetric grid, full-bleed sections, custom positioning), write the HTML directly — that's permitted for layouts. Use components for the UI primitives (`button`, `card`, `input`, `badge`), but write the outer structure yourself.
261
+ 3. **Override with higher specificity.** Component wrappers use single class names (`.ui-hero`, `.ui-cta`). Override with a more specific selector in your `app.css`:
262
+ ```css
263
+ .my-editorial-hero .ui-hero-inner {
264
+ max-width: none;
265
+ padding: 0;
266
+ }
267
+ ```
268
+
269
+ Components are opinionated by design — they enforce accessibility, consistency, and tested patterns. When the design requires breaking those patterns, prefer custom markup over fighting the component.
270
+
271
+ #### Important: Vibes affect CSS tokens, not component HTML structure
272
+
273
+ The `meta.vibe` setting (`warm`, `editorial`, `playful`, `minimal`, `bold`, `brutalist`, `retro`, `corporate`, `neon`, `paper`) applies CSS variable overrides via `data-vibe` on the `<body>` element. This changes **visual styling** — border radius, font size, letter-spacing, heading weight — but does **not** change the **HTML structure** of components.
274
+
275
+ For example:
276
+ - `hero()` with `vibe: 'editorial'` still renders eyebrow + title + subtitle + actions (SaaS structure)
277
+ - The vibe adjusts the font sizes, tracking, and spacing, but the component HTML is unchanged
278
+
279
+ **If you need different HTML structure for an editorial design** — e.g., a large display title + asymmetric layout + drop cap instead of eyebrow/subtitle/actions — use the editorial components (`displayHeading`, `dropCap`, `sectionLabel`) or write custom markup. Do not expect `hero()` to become structurally editorial when `vibe: 'editorial'` is set.
280
+
281
+ This is by design: vibes are a paint job, not a structural transformation. Components stay predictable and testable. When the design structure itself differs from the SaaS patterns (centred hero, three-column features, pricing cards), reach for the editorial components or custom layout instead of trying to force `hero()` / `feature()` / `cta()` into shapes they weren't designed for.
282
+
145
283
  ### Landing page components
146
284
 
147
285
  | Component | Key props |
148
286
  |-----------|-----------|
149
287
  | `nav` | `logo` (HTML slot), `logoHref`, `links` ([{label,href}]), `action` (HTML slot), `sticky`, `background` (any CSS value — overrides default surface colour), `color` (foreground colour — set when custom background doesn't contrast with default muted text) |
150
- | `hero` | `eyebrow`, `title`, `subtitle`, `actions` (HTML slot), `image` (HTML slot — activates split layout), `imageAlign` (right/left), `align` (center/left), `size` (md/sm), `gradient` (true/false or preset: 'purple'\|'blue'\|'green'\|'rose'\|'orange'), `background` (any CSS value — solid colour or custom gradient, overrides gradient preset), `eyebrowColor` (overrides eyebrow text colour — always set when using a custom background to ensure WCAG contrast) |
288
+ | `hero` | `eyebrow`, `title`, `subtitle`, `actions` (HTML slot), `image` (HTML slot — activates image layout), `imageAlign` (right/left), `align` (center/left), `size` (md/sm), `layout` (split/asymmetric/overlap — when image provided: 'split'=50/50, 'asymmetric'=60/40 text-heavy, 'overlap'=image fills section with text overlay), `gradient` (true/false or preset: 'purple'\|'blue'\|'green'\|'rose'\|'orange'), `background` (any CSS value), `eyebrowColor` |
151
289
  | `phoneFrame` | `content` (HTML slot — rendered inside the phone screen), `animate` (boolean, default `false` — enables a gentle 3-D tilt on hover). Pure CSS phone mockup with dynamic island, side buttons, home indicator. Use as the `image` slot in `hero()` for app landing pages. Add `animate: true` whenever the phone is a prominent hero element. |
152
- | `feature` | `icon` (HTML slot), `title`, `level` (1–6, default 3), `description`, `center` |
290
+ | `feature` | `image` (HTML slot — rendered above icon), `icon` (HTML slot), `title`, `level` (1–6, default 3), `description` (or `body` as alias), `center` |
153
291
  | `testimonial` | `quote`, `name`, `role`, `src`, `rating` (1–5) |
154
292
  | `pricing` | `name`, `level` (1–6, default 3), `price`, `period`, `features` ([strings]), `action` (HTML slot), `highlighted` |
155
293
  | `accordion` | `items` ([{question,answer}]) — native `<details>`, no JS |
156
294
  | `appBadge` | `store` (apple/google), `href` — **always use this for App Store / Google Play download buttons. Never write a raw `<a>` with an SVG badge image.** Designed to sit in a `hero()` `actions` slot or anywhere a download CTA is needed. |
157
- | `cta` | `eyebrow`, `title`, `level` (1–6, default 2), `subtitle`, `actions` (HTML slot), `align` |
295
+ | `cta` | `eyebrow`, `title`, `level` (1–6, default 2), `subtitle` (or `body` as alias), `actions` (HTML slot), `align` (center/left) |
158
296
 
159
297
  ### Layout components
160
298
 
161
299
  | Component | Key props |
162
300
  |-----------|-----------|
163
301
  | `container` | `content`, `size` (sm/md/lg/xl) |
164
- | `section` | `content`, `variant` (default/alt/dark), `padding` (sm/md/lg), `id`, `eyebrow`, `title`, `level` (1–6, default 2), `subtitle`, `align` |
302
+ | `section` | `content`, `variant` (default/alt/dark/diagonal/paper/spotlight), `padding` (sm/md/lg), `id`, `eyebrow`, `title`, `level` (1–6, default 2), `subtitle`, `align` — `diagonal`: tapered bottom edge; `paper`: dot-grid texture; `spotlight`: radial gradient focus; **`dark`: in light theme renders navy `#1a1a2e` background (not light grey) with automatic light text and ghost-button overrides; customise via `--ui-dark-bg`/`--ui-dark-text`** |
165
303
  | `grid` | `content`, `cols` (1–4), `gap` (sm/md/lg) — responsive, collapses on mobile |
166
304
  | `stack` | `content`, `gap` (xs–xl), `align` — flex column |
167
305
  | `cluster` | `content`, `gap`, `justify`, `align` — flex row with wrap |
@@ -169,7 +307,7 @@ Use `prose()` for HTML from external sources (CMS, markdown). Use `heading()`/`l
169
307
  | `banner` | `content`, `variant` |
170
308
  | `media` | `image` (HTML slot), `content` (HTML slot), `reverse` — two-column image + text |
171
309
  | `codeWindow` | `content` (highlighted code HTML), `filename`, `lang` |
172
- | `footer` | `logo`, `logoHref`, `links`, `legal` |
310
+ | `footer` | `logo`, `logoHref`, `links` (flat: `[{label,href}]`), `legal`, `background`, `color` — **Rich layout:** add `columns: [{title, links:[{label,href}]}]` to switch to multi-column mode; also accepts `subscribe` (HTML slot), `wordmark` (giant display text) |
173
311
 
174
312
  ### Modal / dialog
175
313