@invisibleloop/pulse 0.12.2 → 0.14.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.
- package/.claude/commands/verify.md +9 -5
- package/.claude/pulse-checklist.md +21 -1
- package/.github/workflows/publish.yml +1 -0
- package/.pulse-verified +1 -0
- package/CLAUDE.md +56 -15
- package/docs/.claude/commands/verify.md +4 -0
- package/docs/.claude/pulse-checklist.md +36 -2
- package/docs/public/.pulse-ui-version +1 -1
- package/docs/public/andypixel.css +237 -0
- package/docs/public/bookworms.css +422 -0
- package/docs/public/coutumes.css +584 -0
- package/docs/public/images/bookworms/hero-books.jpg +0 -0
- package/docs/public/images/bookworms/how-we-read-now.jpg +0 -0
- package/docs/public/images/bookworms/lantern-keepers.jpg +0 -0
- package/docs/public/images/bookworms/provincial-bookseller.jpg +0 -0
- package/docs/public/images/journey/178.jpg +0 -0
- package/docs/public/images/journey/29.jpg +0 -0
- package/docs/public/images/retrowheels/hero.jpg +0 -0
- package/docs/public/images/shop/devbook-pro.jpg +0 -0
- package/docs/public/images/suzukibento/1015.jpg +0 -0
- package/docs/public/images/suzukibento/1025.jpg +0 -0
- package/docs/public/images/suzukibento/1043.jpg +0 -0
- package/docs/public/images/suzukibento/1059.jpg +0 -0
- package/docs/public/images/suzukibento/1062.jpg +0 -0
- package/docs/public/images/suzukibento/1084.jpg +0 -0
- package/docs/public/images/swish/1469334031218-e382a71b716b.jpg +0 -0
- package/docs/public/images/swish/1483985988355-763728e1935b.jpg +0 -0
- package/docs/public/images/swish/1485518882345-15568b007407.jpg +0 -0
- package/docs/public/images/swish/1490481651871-ab68de25d43d.jpg +0 -0
- package/docs/public/images/swish/1496747611176-843222e1e57c.jpg +0 -0
- package/docs/public/images/swish/1502716119720-b23a93e5fe1b.jpg +0 -0
- package/docs/public/images/swish/1539109136881-3be0616acf4b.jpg +0 -0
- package/docs/public/images/swish/1551803091-e20673f15770.jpg +0 -0
- package/docs/public/images/swish/1558769132-cb1aea458c5e.jpg +0 -0
- package/docs/public/images/swish/1572804013309-59a88b7e92f1.jpg +0 -0
- package/docs/public/journey.css +528 -0
- package/docs/public/pulse-ui.css +1051 -25
- package/docs/public/pulse-ui.js +6 -0
- package/docs/public/retrowheels.css +402 -0
- package/docs/public/shop.css +235 -0
- package/docs/public/sparkys.css +349 -0
- package/docs/public/suzukibento.css +517 -0
- package/docs/public/swish.css +394 -0
- package/docs/server.js +19 -4
- package/docs/src/lib/nav.js +14 -3
- package/docs/src/pages/components/decorate.js +74 -0
- package/docs/src/pages/components/gallery.js +102 -0
- package/docs/src/pages/components/hero.js +1 -0
- package/docs/src/pages/components/marquee.js +76 -0
- package/docs/src/pages/components/phone-frame.js +26 -7
- package/docs/src/pages/components/photo-card.js +74 -0
- package/docs/src/pages/components/section.js +40 -1
- package/docs/src/pages/meta.js +26 -0
- package/docs/src/pages/templates/andypixel.js +260 -0
- package/docs/src/pages/templates/bookworms.js +227 -0
- package/docs/src/pages/templates/coutumes.js +226 -0
- package/docs/src/pages/templates/journey.js +186 -0
- package/docs/src/pages/templates/retrowheels.js +342 -0
- package/docs/src/pages/templates/shop.js +257 -0
- package/docs/src/pages/templates/sparkys.js +249 -0
- package/docs/src/pages/templates/suzukibento.js +200 -0
- package/docs/src/pages/templates/swish.js +313 -0
- package/docs/src/pages/templates.js +313 -2
- package/docs/src/pages/testing.js +2 -1
- package/examples/andypixel.css +237 -0
- package/examples/andypixel.js +258 -0
- package/examples/bookworms.css +422 -0
- package/examples/bookworms.js +225 -0
- package/examples/contact.js +1 -0
- package/examples/counter.js +1 -0
- package/examples/dev.server.js +17 -0
- package/examples/journey.css +528 -0
- package/examples/journey.js +186 -0
- package/examples/pricing.js +1 -0
- package/examples/quiz.js +1 -0
- package/examples/retrowheels.css +402 -0
- package/examples/retrowheels.js +340 -0
- package/examples/shared.js +6 -0
- package/examples/suzukibento.css +513 -0
- package/examples/suzukibento.js +200 -0
- package/examples/swish.css +394 -0
- package/examples/swish.js +311 -0
- package/examples/todos.js +1 -0
- package/package.json +1 -1
- package/public/.pulse-ui-version +1 -1
- package/public/images/bookworms/hero-books.jpg +0 -0
- package/public/images/bookworms/how-we-read-now.jpg +0 -0
- package/public/images/bookworms/lantern-keepers.jpg +0 -0
- package/public/images/bookworms/provincial-bookseller.jpg +0 -0
- package/public/images/journey/178.jpg +0 -0
- package/public/images/journey/29.jpg +0 -0
- package/public/images/retrowheels/hero.jpg +0 -0
- package/public/images/shop/devbook-pro.jpg +0 -0
- package/public/images/suzukibento/1015.jpg +0 -0
- package/public/images/suzukibento/1025.jpg +0 -0
- package/public/images/suzukibento/1043.jpg +0 -0
- package/public/images/suzukibento/1059.jpg +0 -0
- package/public/images/suzukibento/1062.jpg +0 -0
- package/public/images/suzukibento/1084.jpg +0 -0
- package/public/images/swish/1469334031218-e382a71b716b.jpg +0 -0
- package/public/images/swish/1483985988355-763728e1935b.jpg +0 -0
- package/public/images/swish/1485518882345-15568b007407.jpg +0 -0
- package/public/images/swish/1490481651871-ab68de25d43d.jpg +0 -0
- package/public/images/swish/1496747611176-843222e1e57c.jpg +0 -0
- package/public/images/swish/1502716119720-b23a93e5fe1b.jpg +0 -0
- package/public/images/swish/1539109136881-3be0616acf4b.jpg +0 -0
- package/public/images/swish/1551803091-e20673f15770.jpg +0 -0
- package/public/images/swish/1558769132-cb1aea458c5e.jpg +0 -0
- package/public/images/swish/1572804013309-59a88b7e92f1.jpg +0 -0
- package/public/pulse-ui.css +1051 -22
- package/public/pulse-ui.js +6 -0
- package/scripts/build.js +72 -18
- package/src/agent/checklist.md +20 -2
- package/src/agent/commands/verify.md +4 -0
- package/src/agent/guide-components.md +146 -8
- package/src/agent/guide-design-gallery.md +288 -0
- package/src/agent/guide-design-references.md +172 -0
- package/src/agent/guide-explore.md +313 -0
- package/src/agent/guide-styles.md +197 -11
- package/src/agent/guide-templates.md +220 -10
- package/src/agent/guide.md +9 -4
- package/src/agent/workflow.md +208 -58
- package/src/cli/dev.js +39 -7
- package/src/cli/fmt.js +113 -0
- package/src/cli/index.js +63 -30
- package/src/cli/scaffold.js +2 -2
- package/src/html.js +6 -1
- package/src/mcp/server.js +2415 -101
- package/src/mcp/validate-worker.js +34 -5
- package/src/runtime/ssr.js +4 -1
- package/src/server/index.js +22 -2
- package/src/spec/schema.js +5 -0
- package/src/testing/html.js +130 -4
- package/src/testing/testing.test.js +58 -0
- package/src/ui/banner.js +24 -9
- package/src/ui/brand-statement.js +58 -0
- package/src/ui/button.js +12 -1
- package/src/ui/card.js +20 -1
- package/src/ui/colophon.js +29 -0
- package/src/ui/contact-row.js +41 -0
- package/src/ui/cta.js +18 -4
- package/src/ui/decorate.js +55 -0
- package/src/ui/display-heading.js +44 -0
- package/src/ui/drop-cap.js +29 -0
- package/src/ui/editorial-hero.js +63 -0
- package/src/ui/entry-list.js +36 -0
- package/src/ui/feature.js +20 -3
- package/src/ui/footer.js +78 -9
- package/src/ui/gallery.js +56 -0
- package/src/ui/hero.js +68 -18
- package/src/ui/image-pair.js +72 -0
- package/src/ui/index.js +20 -0
- package/src/ui/input.js +1 -0
- package/src/ui/marquee.js +48 -0
- package/src/ui/nav.js +19 -9
- package/src/ui/phone-frame.js +6 -5
- package/src/ui/photo-card.js +44 -0
- package/src/ui/product-card.js +65 -0
- package/src/ui/pullquote.js +14 -3
- package/src/ui/search.js +1 -0
- package/src/ui/section-label.js +38 -0
- package/src/ui/section.js +1 -1
- package/src/ui/service-card.js +47 -0
- package/src/ui/stat-grid.js +40 -0
- package/src/ui/stat.js +16 -2
- package/src/ui/testimonial.js +17 -6
- package/src/ui/textarea.js +1 -0
- package/src/ui/top-strip.js +47 -0
- package/src/ui/trust-list.js +46 -0
- package/src/ui/ui.test.js +35 -0
package/public/pulse-ui.js
CHANGED
|
@@ -129,6 +129,9 @@ document.addEventListener('keydown', (e) => {
|
|
|
129
129
|
// ─── Nav (mobile burger) ────────────────────────────────────────────────────
|
|
130
130
|
|
|
131
131
|
function initNav(el) {
|
|
132
|
+
if (el.dataset.pulseNavInit) return
|
|
133
|
+
el.dataset.pulseNavInit = '1'
|
|
134
|
+
|
|
132
135
|
const burger = el.querySelector('.ui-nav-burger')
|
|
133
136
|
const mobile = el.querySelector('.ui-nav-mobile')
|
|
134
137
|
|
|
@@ -170,6 +173,9 @@ document.addEventListener('pulse:navigate', runNavs)
|
|
|
170
173
|
// ─── Carousel ───────────────────────────────────────────────────────────────
|
|
171
174
|
|
|
172
175
|
function initCarousel(el) {
|
|
176
|
+
if (el.dataset.pulseCarouselInit) return
|
|
177
|
+
el.dataset.pulseCarouselInit = '1'
|
|
178
|
+
|
|
173
179
|
const track = el.querySelector('.ui-carousel-track')
|
|
174
180
|
const prev = el.querySelector('.ui-carousel-prev')
|
|
175
181
|
const next = el.querySelector('.ui-carousel-next')
|
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
|
|
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(
|
|
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
|
-
//
|
|
528
|
+
// Summary table
|
|
522
529
|
// ---------------------------------------------------------------------------
|
|
523
530
|
|
|
524
|
-
|
|
525
|
-
|
|
526
|
-
|
|
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
|
-
|
|
530
|
-
|
|
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('
|
|
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
|
-
|
|
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
|
-
|
|
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('
|
|
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('
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
}
|
package/src/agent/checklist.md
CHANGED
|
@@ -22,9 +22,27 @@ 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. This is a deliberate design decision, not a shortcut. When taking this path:
|
|
40
|
+
- State it explicitly in the build brief: *"Building component-free — raw HTML throughout for creative control."*
|
|
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 a spec built under creative override, do not flag raw section HTML as a violation — check the Lighthouse results instead.
|
|
28
46
|
|
|
29
47
|
- **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
48
|
|
|
@@ -110,7 +128,7 @@ Before finishing any spec, verify every point below. Fix anything that fails.
|
|
|
110
128
|
assert.equal(result.get('h1').text, mockProduct.name)
|
|
111
129
|
assert.equal(result.attr('img', 'src'), mockProduct.image)
|
|
112
130
|
```
|
|
113
|
-
Supported selectors: `tag`, `.class`, `#id`, `[attr]`, `[attr="value"]`,
|
|
131
|
+
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
132
|
|
|
115
133
|
### Markdown
|
|
116
134
|
|
|
@@ -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.
|
|
@@ -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
|
|
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
|
|
151
|
-
| `phoneFrame` | `content` (HTML slot — rendered inside the phone screen). Pure CSS phone mockup with dynamic island, side buttons, home indicator. Use as the `image` slot in `hero()` for app landing pages. |
|
|
152
|
-
| `feature` | `icon` (HTML slot), `title`, `level` (1–6, default 3), `description
|
|
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` |
|
|
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. |
|
|
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
|
|
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 |
|
|
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
|
|
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
|
|