@invisibleloop/pulse 0.14.1 → 0.15.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (46) hide show
  1. package/.claude/commands/build-page.md +5 -1
  2. package/.claude/commands/verify.md +23 -13
  3. package/docs/.claude/pulse-checklist.md +5 -3
  4. package/docs/public/.pulse-ui-version +1 -1
  5. package/docs/public/docs.css +236 -0
  6. package/docs/public/home-brut.css +998 -0
  7. package/docs/public/menu.js +237 -6
  8. package/docs/public/pulse-ui.css +44 -0
  9. package/docs/public/theme.css +8 -0
  10. package/docs/server.js +24 -0
  11. package/docs/src/lib/layout.js +51 -3
  12. package/docs/src/pages/getting-started.js +1 -1
  13. package/docs/src/pages/home.js +231 -312
  14. package/docs/src/pages/how-it-works.js +67 -12
  15. package/examples/.claude/commands/pulse-build.md +7 -0
  16. package/examples/.claude/commands/pulse-dev.md +7 -0
  17. package/examples/.claude/commands/pulse-start.md +9 -0
  18. package/examples/.claude/commands/pulse-stop.md +5 -0
  19. package/examples/.claude/commands/verify.md +84 -0
  20. package/examples/.claude/coverage-check.js +127 -0
  21. package/examples/.claude/pulse-checklist.md +177 -0
  22. package/examples/public/.pulse-ui-version +1 -0
  23. package/examples/public/pulse-ui.css +4031 -0
  24. package/examples/public/pulse-ui.js +257 -0
  25. package/package.json +1 -1
  26. package/public/.pulse-ui-version +1 -1
  27. package/public/pulse-ui.css +44 -0
  28. package/scripts/build.js +127 -21
  29. package/src/agent/checklist.md +5 -3
  30. package/src/agent/guide-components.md +2 -2
  31. package/src/agent/guide-examples.md +310 -32
  32. package/src/agent/guide-styles.md +15 -0
  33. package/src/agent/guide-templates.md +3 -0
  34. package/src/agent/identity.md +2 -1
  35. package/src/agent/skills/verify/SKILL.md +64 -28
  36. package/src/agent/workflow.md +53 -17
  37. package/src/cli/dev.js +49 -13
  38. package/src/cli/index.js +4 -10
  39. package/src/cli/logger.js +145 -0
  40. package/src/cli/scaffold.js +55 -1
  41. package/src/mcp/server.js +174 -26
  42. package/src/mcp/validate-worker.js +21 -6
  43. package/src/server/index.js +20 -16
  44. package/src/spec/schema.js +19 -3
  45. package/src/spec/schema.test.js +28 -0
  46. package/src/ui/button.js +1 -1
@@ -0,0 +1,257 @@
1
+ /**
2
+ * pulse-ui.js — Vanilla JS for interactive Pulse UI components
3
+ *
4
+ * Handles:
5
+ * - Slider (.ui-slider — live fill + value output)
6
+ * - File Upload (.ui-upload — drag-and-drop, click-to-open)
7
+ * - Modal (data-modal-open, <dialog>, backdrop close)
8
+ * - Nav (.ui-nav — mobile burger menu)
9
+ * - Carousel (.ui-carousel — prev/next/dots)
10
+ *
11
+ * No dependencies. No build step required.
12
+ * CSP-safe: no inline handlers — all behaviour via event delegation.
13
+ * Include once per page: <script src="/pulse-ui.js"></script>
14
+ */
15
+
16
+ // ─── Slider ──────────────────────────────────────────────────────────────────
17
+ // Update --slider-fill on .ui-field wrapper + optional live value output.
18
+
19
+ document.addEventListener('input', (e) => {
20
+ const el = e.target
21
+ if (!el.classList.contains('ui-slider')) return
22
+ const field = el.closest('.ui-field')
23
+ if (!field) return
24
+ const pct = ((el.value - el.min) / (el.max - el.min) * 100).toFixed(2) + '%'
25
+ field.style.setProperty('--slider-fill', pct)
26
+ const out = field.querySelector('.ui-slider-output')
27
+ if (out) out.textContent = el.value
28
+ })
29
+
30
+ // ─── File Upload ─────────────────────────────────────────────────────────────
31
+ // Prevent browser navigating to dropped file anywhere on the page.
32
+ // Handle drag visual feedback + file assignment on drop.
33
+ // Handle click-to-open and keyboard activation.
34
+
35
+ document.addEventListener('dragover', (e) => {
36
+ e.preventDefault()
37
+ if (!(e.target instanceof Element)) return
38
+ const zone = e.target.closest('.ui-upload:not(.ui-upload--disabled)')
39
+ if (zone) zone.classList.add('ui-upload--active')
40
+ })
41
+
42
+ document.addEventListener('dragleave', (e) => {
43
+ if (!(e.target instanceof Element)) return
44
+ const zone = e.target.closest('.ui-upload')
45
+ if (zone && !zone.contains(e.relatedTarget)) {
46
+ zone.classList.remove('ui-upload--active')
47
+ }
48
+ })
49
+
50
+ document.addEventListener('drop', (e) => {
51
+ e.preventDefault()
52
+ if (!(e.target instanceof Element)) return
53
+ const zone = e.target.closest('.ui-upload:not(.ui-upload--disabled)')
54
+ if (!zone) return
55
+ zone.classList.remove('ui-upload--active')
56
+ const input = zone.querySelector('.ui-upload-input')
57
+ if (!input || !e.dataTransfer.files.length) return
58
+ const dt = new DataTransfer()
59
+ Array.from(e.dataTransfer.files).forEach(f => dt.items.add(f))
60
+ input.files = dt.files
61
+ input.dispatchEvent(new Event('change', { bubbles: true }))
62
+ })
63
+
64
+ // File Upload: show selected filename in zone after file chosen
65
+ document.addEventListener('change', (e) => {
66
+ const input = e.target
67
+ if (!input.classList.contains('ui-upload-input')) return
68
+ const zone = input.closest('.ui-upload')
69
+ if (!zone) return
70
+ const textEl = zone.querySelector('.ui-upload-text')
71
+ if (!textEl) return
72
+ if (input.files && input.files.length > 0) {
73
+ const names = Array.from(input.files).map(f => f.name).join(', ')
74
+ textEl.textContent = names
75
+ zone.classList.add('ui-upload--selected')
76
+ } else {
77
+ textEl.innerHTML = 'Drag &amp; drop or <span class="ui-upload-browse">browse</span>'
78
+ zone.classList.remove('ui-upload--selected')
79
+ }
80
+ })
81
+
82
+ // ─── Modal ──────────────────────────────────────────────────────────────────
83
+
84
+ document.addEventListener('click', (e) => {
85
+ // File upload: click zone → open file picker
86
+ // Skip synthetic clicks emitted by input.click() itself to avoid loops
87
+ if (!e.target.classList.contains('ui-upload-input')) {
88
+ const zone = e.target.closest('.ui-upload')
89
+ if (zone) {
90
+ const input = zone.querySelector('.ui-upload-input')
91
+ if (input && !input.disabled) { input.click(); return }
92
+ }
93
+ }
94
+
95
+ // Dialog open: data-dialog-open="dialogId" (first-class) or data-modal-open="dialogId" (compat)
96
+ const trigger = e.target.closest('[data-dialog-open],[data-modal-open]')
97
+ if (trigger) {
98
+ const id = trigger.dataset.dialogOpen ?? trigger.dataset.modalOpen
99
+ const dialog = document.getElementById(id)
100
+ if (dialog && typeof dialog.showModal === 'function') {
101
+ dialog.showModal()
102
+ }
103
+ return
104
+ }
105
+
106
+ // Dialog close: data-dialog-close (closes nearest ancestor <dialog>)
107
+ const closeTarget = e.target.closest('[data-dialog-close]')
108
+ if (closeTarget) {
109
+ const dialog = closeTarget.closest('dialog')
110
+ if (dialog) dialog.close()
111
+ return
112
+ }
113
+
114
+ // Backdrop close — click lands on <dialog> element itself
115
+ if (e.target.tagName === 'DIALOG') {
116
+ e.target.close()
117
+ }
118
+ })
119
+
120
+ // File upload: Enter / Space on focused zone → open picker
121
+ document.addEventListener('keydown', (e) => {
122
+ if (e.key !== 'Enter' && e.key !== ' ') return
123
+ const zone = e.target.closest('.ui-upload')
124
+ if (!zone) return
125
+ const input = zone.querySelector('.ui-upload-input')
126
+ if (input && !input.disabled) { e.preventDefault(); input.click() }
127
+ })
128
+
129
+ // ─── Nav (mobile burger) ────────────────────────────────────────────────────
130
+
131
+ function initNav(el) {
132
+ if (el.dataset.pulseNavInit) return
133
+ el.dataset.pulseNavInit = '1'
134
+
135
+ const burger = el.querySelector('.ui-nav-burger')
136
+ const mobile = el.querySelector('.ui-nav-mobile')
137
+
138
+ // Mobile burger
139
+ if (burger && mobile) {
140
+ const open = () => { el.classList.add('ui-nav--open'); burger.setAttribute('aria-expanded', 'true') }
141
+ const close = () => { el.classList.remove('ui-nav--open'); burger.setAttribute('aria-expanded', 'false') }
142
+ const toggle = () => el.classList.contains('ui-nav--open') ? close() : open()
143
+
144
+ burger.addEventListener('click', toggle)
145
+ document.addEventListener('keydown', (e) => { if (e.key === 'Escape') close() })
146
+ document.addEventListener('click', (e) => { if (!el.contains(e.target)) close() })
147
+ mobile.querySelectorAll('.ui-nav-link').forEach(a => a.addEventListener('click', close))
148
+ }
149
+
150
+ // Mega nav dropdowns
151
+ el.querySelectorAll('.ui-nav-mega-wrap').forEach(wrap => {
152
+ const trigger = wrap.querySelector('.ui-nav-mega-trigger')
153
+ const panel = wrap.querySelector('.ui-nav-mega-panel')
154
+ if (!trigger || !panel) return
155
+
156
+ const openMega = () => { panel.hidden = false; wrap.classList.add('ui-nav-mega-wrap--open'); trigger.setAttribute('aria-expanded', 'true') }
157
+ const closeMega = () => { panel.hidden = true; wrap.classList.remove('ui-nav-mega-wrap--open'); trigger.setAttribute('aria-expanded', 'false') }
158
+ const toggleMega = () => panel.hidden ? openMega() : closeMega()
159
+
160
+ trigger.addEventListener('click', (e) => { e.stopPropagation(); toggleMega() })
161
+ document.addEventListener('keydown', (e) => { if (e.key === 'Escape') closeMega() })
162
+ document.addEventListener('click', (e) => { if (!wrap.contains(e.target)) closeMega() })
163
+ panel.querySelectorAll('.ui-nav-mega-item').forEach(a => a.addEventListener('click', closeMega))
164
+ })
165
+ }
166
+
167
+ function runNavs() { document.querySelectorAll('.ui-nav').forEach(initNav) }
168
+ document.readyState === 'loading'
169
+ ? document.addEventListener('DOMContentLoaded', runNavs)
170
+ : runNavs()
171
+ document.addEventListener('pulse:navigate', runNavs)
172
+
173
+ // ─── Carousel ───────────────────────────────────────────────────────────────
174
+
175
+ function initCarousel(el) {
176
+ if (el.dataset.pulseCarouselInit) return
177
+ el.dataset.pulseCarouselInit = '1'
178
+
179
+ const track = el.querySelector('.ui-carousel-track')
180
+ const prev = el.querySelector('.ui-carousel-prev')
181
+ const next = el.querySelector('.ui-carousel-next')
182
+ const dots = Array.from(el.querySelectorAll('.ui-carousel-dot'))
183
+ const slides = Array.from(el.querySelectorAll('.ui-carousel-slide'))
184
+
185
+ if (!track || slides.length === 0) return
186
+
187
+ let current = 0
188
+
189
+ const updateArrows = () => {
190
+ if (prev) prev.hidden = current === 0
191
+ if (next) next.hidden = current === slides.length - 1
192
+ }
193
+
194
+ const goTo = (i) => {
195
+ current = Math.max(0, Math.min(i, slides.length - 1))
196
+ track.scrollTo({ left: slides[current].offsetLeft, behavior: 'smooth' })
197
+ dots.forEach((d, j) => {
198
+ const active = j === current
199
+ d.classList.toggle('active', active)
200
+ d.setAttribute('aria-selected', String(active))
201
+ d.setAttribute('tabindex', active ? '0' : '-1')
202
+ })
203
+ updateArrows()
204
+ }
205
+
206
+ prev?.addEventListener('click', () => goTo(current - 1))
207
+ next?.addEventListener('click', () => goTo(current + 1))
208
+ dots.forEach((d, i) => d.addEventListener('click', () => goTo(i)))
209
+
210
+ // Keyboard navigation — roving tabindex for tablist
211
+ el.addEventListener('keydown', (e) => {
212
+ if (!e.target.classList.contains('ui-carousel-dot')) return
213
+ let next = null
214
+ if (e.key === 'ArrowLeft' || e.key === 'ArrowUp') {
215
+ e.preventDefault()
216
+ next = current > 0 ? current - 1 : slides.length - 1
217
+ } else if (e.key === 'ArrowRight' || e.key === 'ArrowDown') {
218
+ e.preventDefault()
219
+ next = current < slides.length - 1 ? current + 1 : 0
220
+ } else if (e.key === 'Home') {
221
+ e.preventDefault()
222
+ next = 0
223
+ } else if (e.key === 'End') {
224
+ e.preventDefault()
225
+ next = slides.length - 1
226
+ }
227
+ if (next !== null) {
228
+ goTo(next)
229
+ dots[next]?.focus()
230
+ }
231
+ })
232
+
233
+ // Sync dot state and arrows when user swipes / scrolls
234
+ track.addEventListener('scroll', () => {
235
+ const trackLeft = track.getBoundingClientRect().left
236
+ const idx = slides.findIndex((s) => Math.abs(s.getBoundingClientRect().left - trackLeft) < 10)
237
+ if (idx !== -1 && idx !== current) {
238
+ current = idx
239
+ dots.forEach((d, j) => {
240
+ const active = j === current
241
+ d.classList.toggle('active', active)
242
+ d.setAttribute('aria-selected', String(active))
243
+ d.setAttribute('tabindex', active ? '0' : '-1')
244
+ })
245
+ updateArrows()
246
+ }
247
+ }, { passive: true })
248
+ }
249
+
250
+ // Initialise all carousels on the page
251
+ const run = () => document.querySelectorAll('.ui-carousel').forEach(initCarousel)
252
+ document.readyState === 'loading'
253
+ ? document.addEventListener('DOMContentLoaded', run)
254
+ : run()
255
+
256
+ // Re-initialise after Pulse client-side navigation swaps the DOM
257
+ document.addEventListener('pulse:navigate', run)
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@invisibleloop/pulse",
3
- "version": "0.14.1",
3
+ "version": "0.15.0",
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.14.1
1
+ 0.15.0
@@ -150,6 +150,20 @@ html, body {
150
150
  color: var(--ui-text);
151
151
  }
152
152
 
153
+ /* ghost-light — use on dark section backgrounds (e.g. navy hero, section--dark).
154
+ Renders with light/white text and a subtle light border so it remains legible
155
+ regardless of whether the surrounding theme is light or dark. */
156
+ .ui-btn--ghost-light {
157
+ background: transparent;
158
+ color: rgba(255, 255, 255, 0.9);
159
+ border: 1px solid rgba(255, 255, 255, 0.35);
160
+ }
161
+ .ui-btn--ghost-light:hover:not(.ui-btn--disabled):not([disabled]) {
162
+ background: rgba(255, 255, 255, 0.1);
163
+ border-color: rgba(255, 255, 255, 0.65);
164
+ color: #ffffff;
165
+ }
166
+
153
167
  .ui-btn--danger {
154
168
  background: var(--ui-red-dim);
155
169
  color: var(--ui-red);
@@ -682,6 +696,36 @@ html, body {
682
696
  .ui-section--alt { background: var(--ui-surface, var(--surface, #111116)); }
683
697
  .ui-section--dark { background: var(--ui-surface-2, var(--surface-2, #18181f)); }
684
698
 
699
+ /* In light theme, --ui-surface-2 resolves to a light grey — not "dark".
700
+ Override so section--dark always renders a genuinely dark background that
701
+ contrasts with the page, regardless of the active theme. */
702
+ [data-theme="light"] .ui-section--dark,
703
+ .ui-theme-light .ui-section--dark {
704
+ background: var(--ui-dark-bg, #1a1a2e);
705
+ color: var(--ui-dark-text, #e2e2ea);
706
+ }
707
+ /* Ensure text children inherit the light colour in the dark section */
708
+ [data-theme="light"] .ui-section--dark .ui-section-title,
709
+ [data-theme="light"] .ui-section--dark .ui-section-eyebrow,
710
+ [data-theme="light"] .ui-section--dark .ui-section-subtitle,
711
+ .ui-theme-light .ui-section--dark .ui-section-title,
712
+ .ui-theme-light .ui-section--dark .ui-section-eyebrow,
713
+ .ui-theme-light .ui-section--dark .ui-section-subtitle {
714
+ color: inherit;
715
+ }
716
+ /* Ghost button inside a dark section on a light-theme page */
717
+ [data-theme="light"] .ui-section--dark .ui-btn--ghost,
718
+ .ui-theme-light .ui-section--dark .ui-btn--ghost {
719
+ color: var(--ui-dark-text, #e2e2ea);
720
+ border-color: rgba(226, 226, 234, 0.35);
721
+ }
722
+ [data-theme="light"] .ui-section--dark .ui-btn--ghost:hover:not(.ui-btn--disabled):not([disabled]),
723
+ .ui-theme-light .ui-section--dark .ui-btn--ghost:hover:not(.ui-btn--disabled):not([disabled]) {
724
+ background: rgba(226, 226, 234, 0.1);
725
+ border-color: rgba(226, 226, 234, 0.65);
726
+ color: var(--ui-dark-text, #e2e2ea);
727
+ }
728
+
685
729
  /* diagonal — tapered bottom edge; follow with a section that starts flush */
686
730
  .ui-section--diagonal {
687
731
  background: var(--ui-surface, var(--surface, #111116));
package/scripts/build.js CHANGED
@@ -19,7 +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
+ import { c, header, table, ok, fail, info, warn, elapsed, icon } from '../src/cli/fmt.js'
23
23
 
24
24
  // ---------------------------------------------------------------------------
25
25
  // Server-only property stripping
@@ -380,6 +380,23 @@ const ROOT = rootArg !== -1
380
380
  const OUT_DIR = path.join(ROOT, 'public', 'dist')
381
381
  const TMP_DIR = path.join(ROOT, '.pulse-build')
382
382
 
383
+ // ---------------------------------------------------------------------------
384
+ // Load pulse.config.js (optional)
385
+ // ---------------------------------------------------------------------------
386
+
387
+ let _projectConfig = {}
388
+ const _configPath = path.join(ROOT, 'pulse.config.js')
389
+ if (fs.existsSync(_configPath)) {
390
+ try { _projectConfig = (await import(_configPath)).default ?? {} } catch { /* ignore */ }
391
+ }
392
+
393
+ // css.safelist — array of class name strings or RegExp patterns to always keep
394
+ // even if the purger would otherwise remove them.
395
+ // Example in pulse.config.js:
396
+ // export default { css: { safelist: ['my-class', /^js-/] } }
397
+ const CSS_SAFELIST = (_projectConfig.css?.safelist ?? [])
398
+ .map(entry => typeof entry === 'string' ? entry : entry)
399
+
383
400
  // ---------------------------------------------------------------------------
384
401
  // Discover pages
385
402
  // ---------------------------------------------------------------------------
@@ -516,7 +533,7 @@ fs.rmSync(TMP_DIR, { recursive: true })
516
533
  // CSS Purge — strip unused styles, write content-hashed CSS bundles
517
534
  // ---------------------------------------------------------------------------
518
535
 
519
- await purgeCssStep(pages, manifest, ROOT, OUT_DIR)
536
+ const cssPurgeStats = await purgeCssStep(pages, manifest, ROOT, OUT_DIR)
520
537
 
521
538
  // ---------------------------------------------------------------------------
522
539
  // JS static assets — minify + hash any .js files directly in public/
@@ -556,13 +573,17 @@ await jsAssetsStep(ROOT, OUT_DIR, manifest)
556
573
  c.dim(bundle.replace('/dist/', '')),
557
574
  `${sizeKb} kB`,
558
575
  c.dim('js'),
576
+ '',
559
577
  ])
560
578
  } else if (ext === '.css') {
579
+ const pct = cssPurgeStats?.[src]
580
+ const pctLabel = pct != null ? c.dim(`−${pct}%`) : ''
561
581
  rows.push([
562
582
  c.dim(src),
563
583
  c.dim(bundle.replace('/dist/', '')),
564
584
  `${sizeKb} kB`,
565
585
  c.dim('css'),
586
+ pctLabel,
566
587
  ])
567
588
  }
568
589
  }
@@ -577,9 +598,9 @@ await jsAssetsStep(ROOT, OUT_DIR, manifest)
577
598
  })
578
599
 
579
600
  console.log('\n' + table(
580
- ['Route / Asset', 'Bundle', 'Size', 'Type'],
601
+ ['Route / Asset', 'Bundle', 'Size', 'Type', 'Purged'],
581
602
  rows,
582
- { align: ['left', 'left', 'right', 'left'] }
603
+ { align: ['left', 'left', 'right', 'left', 'right'] }
583
604
  ))
584
605
 
585
606
  const totalMs = Date.now() - BUILD_START
@@ -599,9 +620,28 @@ async function jsAssetsStep(root, outDir, manifest) {
599
620
 
600
621
  console.log(info('Hashing static JS assets…'))
601
622
 
623
+ // Collect fetch/XHR origins used in static JS for CSP connect-src checking
624
+ const cspConnectSrc = new Set([
625
+ ...(_projectConfig.csp?.['connect-src'] ?? []),
626
+ 'self',
627
+ ])
628
+
602
629
  for (const file of jsFiles) {
603
630
  const srcPath = path.join(publicDir, file)
604
631
  const source = fs.readFileSync(srcPath, 'utf8')
632
+
633
+ // Warn if the file contains fetch/XMLHttpRequest calls to origins not in
634
+ // the project's CSP connect-src — these will silently fail in production.
635
+ const fetchUrls = [...source.matchAll(/fetch\s*\(\s*['"`](https?:\/\/[^'"`]+)['"`]/g)].map(m => m[1])
636
+ for (const url of fetchUrls) {
637
+ try {
638
+ const origin = new URL(url).origin
639
+ if (!cspConnectSrc.has(origin) && !cspConnectSrc.has('*')) {
640
+ console.log(warn(`${file}: fetch to ${origin} may be blocked — add to csp.connect-src in pulse.config.js`))
641
+ }
642
+ } catch { /* ignore malformed URLs */ }
643
+ }
644
+
605
645
  const { code } = await esbuild.transform(source, { minify: true, target: 'es2018' })
606
646
  const hash = createHash('sha256').update(code).digest('hex').slice(0, 8)
607
647
  const name = path.basename(file, '.js')
@@ -610,8 +650,6 @@ async function jsAssetsStep(root, outDir, manifest) {
610
650
  fs.writeFileSync(path.join(outDir, outName), code)
611
651
  manifest[`/${file}`] = `/dist/${outName}`
612
652
 
613
- const origKb = (Buffer.byteLength(source) / 1024).toFixed(1)
614
- const minKb = (Buffer.byteLength(code) / 1024).toFixed(1)
615
653
  // silent — summary table covers this
616
654
  }
617
655
 
@@ -629,6 +667,7 @@ async function purgeCssStep(pages, manifest, root, outDir) {
629
667
  const htmlContents = []
630
668
  const jsContents = []
631
669
  const cssFiles = new Set()
670
+ const cssStats = {}
632
671
 
633
672
  for (const { filePath } of pages) {
634
673
  try {
@@ -667,6 +706,16 @@ async function purgeCssStep(pages, manifest, root, outDir) {
667
706
  }
668
707
  }
669
708
 
709
+ // Scan public/*.js static assets — these often contain classList.toggle() or
710
+ // classList.add() calls that reference CSS classes not present in any page spec,
711
+ // which would otherwise be incorrectly purged from the built CSS.
712
+ const publicDir = path.join(root, 'public')
713
+ if (fs.existsSync(publicDir)) {
714
+ for (const f of fs.readdirSync(publicDir).filter(f => f.endsWith('.js'))) {
715
+ try { jsContents.push(fs.readFileSync(path.join(publicDir, f), 'utf8')) } catch {}
716
+ }
717
+ }
718
+
670
719
  // Scan pulse UI package source — class names generated by UI components
671
720
  // (breadcrumbs, avatar, container, etc.) only appear in the package source,
672
721
  // not in the project's own pages, so they'd otherwise be purged.
@@ -687,10 +736,10 @@ async function purgeCssStep(pages, manifest, root, outDir) {
687
736
 
688
737
  if (cssFiles.size === 0) {
689
738
  console.log(info('No CSS files referenced — skipping.'))
690
- return
739
+ return cssStats
691
740
  }
692
741
 
693
- const usedClasses = extractUsedClasses(htmlContents, jsContents)
742
+ const usedClasses = extractUsedClasses(htmlContents, jsContents, CSS_SAFELIST)
694
743
 
695
744
  for (const cssHref of cssFiles) {
696
745
  // CSS path is relative to public/ (e.g. '/pulse-ui.css' → public/pulse-ui.css)
@@ -698,6 +747,21 @@ async function purgeCssStep(pages, manifest, root, outDir) {
698
747
  if (!fs.existsSync(cssPath)) continue
699
748
 
700
749
  const original = fs.readFileSync(cssPath, 'utf8')
750
+
751
+ // Warn about classes defined in this CSS file that weren't found in any
752
+ // scanned source — these will be purged and may indicate a missing scan source
753
+ // (e.g. a JS file that toggles classes via classList but wasn't picked up).
754
+ // Only warn for project-owned CSS files (not pulse-ui.css framework styles).
755
+ if (!cssHref.includes('pulse-ui')) {
756
+ const definedClasses = [...original.matchAll(/\.([a-zA-Z][a-zA-Z0-9_-]*)\s*[{,:\[]/g)]
757
+ .map(m => m[1])
758
+ .filter((cls, i, arr) => arr.indexOf(cls) === i) // dedupe
759
+ const purgedClasses = definedClasses.filter(cls => !usedClasses.has(cls))
760
+ if (purgedClasses.length > 0) {
761
+ console.log(warn(`${cssHref}: ${purgedClasses.length} class${purgedClasses.length === 1 ? '' : 'es'} purged (not found in any scanned source): ${purgedClasses.slice(0, 5).map(c => `.${c}`).join(', ')}${purgedClasses.length > 5 ? ` … +${purgedClasses.length - 5} more` : ''}`))
762
+ }
763
+ }
764
+
701
765
  const purged = minifyCss(purgeCss(original, usedClasses))
702
766
 
703
767
  const hash = createHash('sha256').update(purged).digest('hex').slice(0, 8)
@@ -707,25 +771,24 @@ async function purgeCssStep(pages, manifest, root, outDir) {
707
771
 
708
772
  fs.writeFileSync(outPath, purged)
709
773
 
710
- const origKb = (Buffer.byteLength(original) / 1024).toFixed(1)
711
- const purgKb = (Buffer.byteLength(purged) / 1024).toFixed(1)
712
- const pct = Math.round((1 - Buffer.byteLength(purged) / Buffer.byteLength(original)) * 100)
713
-
714
- // silent — summary table covers this
774
+ const origBytes = Buffer.byteLength(original)
775
+ const purgBytes = Buffer.byteLength(purged)
776
+ const pct = Math.round((1 - purgBytes / origBytes) * 100)
777
+ cssStats[cssHref] = pct
715
778
 
716
779
  manifest[cssHref] = `/dist/${outName}`
717
780
  }
718
781
 
719
782
  // Re-write manifest with CSS entries added
720
783
  fs.writeFileSync(path.join(outDir, 'manifest.json'), JSON.stringify(manifest, null, 2))
721
- // silent — summary table covers this
784
+ return cssStats
722
785
  }
723
786
 
724
787
  // ---------------------------------------------------------------------------
725
788
  // CSS class extractor
726
789
  // ---------------------------------------------------------------------------
727
790
 
728
- function extractUsedClasses(htmlContents, jsContents) {
791
+ function extractUsedClasses(htmlContents, jsContents, safelist = []) {
729
792
  const used = new Set()
730
793
 
731
794
  // Extract from class="..." in both rendered HTML and JS template literals
@@ -751,6 +814,21 @@ function extractUsedClasses(htmlContents, jsContents) {
751
814
  }
752
815
  }
753
816
 
817
+ // Apply safelist from pulse.config.js — css.safelist accepts strings or RegExp.
818
+ // String entries are added directly; RegExp entries are matched against all
819
+ // class names defined in the CSS (resolved later in purgeCss via selectorUsed
820
+ // because we don't have the CSS text here). To handle RegExp, we mark them as
821
+ // a sentinel that selectorUsed checks separately.
822
+ for (const entry of safelist) {
823
+ if (typeof entry === 'string') {
824
+ used.add(entry)
825
+ }
826
+ // RegExp entries are stored on the Set as a symbol property for selectorUsed to check
827
+ }
828
+ if (safelist.some(e => e instanceof RegExp)) {
829
+ used._safelistPatterns = safelist.filter(e => e instanceof RegExp)
830
+ }
831
+
754
832
  return used
755
833
  }
756
834
 
@@ -820,7 +898,17 @@ function parseCssBlocks(css) {
820
898
 
821
899
  /**
822
900
  * Return true if any selector in the rule should be kept.
823
- * Keeps: element selectors, *, :root, and any rule where a used class appears.
901
+ *
902
+ * Matching strategy: check the *rightmost* class token in the selector (the
903
+ * subject of the rule). A rule like `.parent .child` should survive only when
904
+ * `.child` is used — keeping it whenever `.parent` is used would retain dead
905
+ * rules whose actual target class was never referenced.
906
+ *
907
+ * Pseudo-classes/elements and attribute selectors are stripped before checking
908
+ * so `.btn:hover` is treated as `.btn`, `.btn--active::before` as `.btn--active`.
909
+ *
910
+ * Element-only selectors (`a`, `*`, `:root`) and rules that contain no class
911
+ * tokens at all are always kept.
824
912
  */
825
913
  function selectorUsed(selector, usedClasses) {
826
914
  const selectors = selector.split(',').map(s => s.trim())
@@ -832,12 +920,21 @@ function selectorUsed(selector, usedClasses) {
832
920
  .replace(/\[[^\]]*\]/g, '')
833
921
  .trim()
834
922
 
835
- // Keep element selectors, *, :root — no class tokens
923
+ // Keep element-only selectors — no class tokens present
836
924
  if (!base.includes('.')) return true
837
925
 
838
- // Keep if any class in the selector is in the used set
839
- const classes = [...sel.matchAll(/\.([a-zA-Z][a-zA-Z0-9_-]*)/g)].map(m => m[1])
840
- if (classes.some(cls => usedClasses.has(cls))) return true
926
+ // Extract all class tokens and check the rightmost one (the rule subject).
927
+ // Using the rightmost class correctly handles descendant selectors:
928
+ // .nav .link → subject is .link
929
+ // .btn--active → subject is .btn--active
930
+ const classes = [...base.matchAll(/\.([a-zA-Z][a-zA-Z0-9_-]*)/g)].map(m => m[1])
931
+ if (classes.length === 0) return true
932
+ const subject = classes[classes.length - 1]
933
+ if (usedClasses.has(subject)) return true
934
+
935
+ // Check RegExp safelist patterns if any were provided
936
+ const patterns = usedClasses._safelistPatterns
937
+ if (patterns && patterns.some(re => re.test(subject))) return true
841
938
  }
842
939
 
843
940
  return false
@@ -868,9 +965,18 @@ function purgeCss(cssText, usedClasses) {
868
965
  const p = block.prelude.toLowerCase()
869
966
  if (/^@(?:keyframes|font-face|charset)/.test(p)) {
870
967
  kept.push(block.raw)
871
- } else if (/^@(?:media|supports|layer)/.test(p)) {
968
+ } else if (/^@(?:media|supports)/.test(p)) {
872
969
  const innerPurged = purgeCss(block.inner, usedClasses)
873
970
  if (innerPurged.trim()) kept.push(`${block.prelude} {\n${innerPurged}\n}`)
971
+ } else if (/^@layer/.test(p)) {
972
+ // Always preserve empty @layer declarations — they establish cascade order
973
+ // and removing them silently changes specificity even when no rules remain.
974
+ const innerPurged = purgeCss(block.inner, usedClasses)
975
+ if (innerPurged.trim()) {
976
+ kept.push(`${block.prelude} {\n${innerPurged}\n}`)
977
+ } else {
978
+ kept.push(`${block.prelude} {}`)
979
+ }
874
980
  } else {
875
981
  kept.push(block.raw) // unknown at-blocks — keep to be safe
876
982
  }
@@ -36,13 +36,15 @@ Before finishing any spec, verify every point below. Fix anything that fails.
36
36
  - Navigation → `nav({ logo, links, actions })`
37
37
  - Footers → `footer({ logo, links, columns, legal, ... })`
38
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."*
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
41
  - Functional atoms (`button`, `input`, `badge`, `modal`) should still come from components unless there is a specific design reason not to.
42
42
  - `/pulse-ui.css` is always required — it provides the token system your custom CSS will use.
43
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
44
 
45
- - When reviewing a spec built under creative override, do not flag raw section HTML as a violation — check the Lighthouse results instead.
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.
46
48
 
47
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.
48
50
 
@@ -148,7 +148,7 @@ To add a missing icon: copy a Lucide path (MIT) into `src/ui/icons.js` using `s(
148
148
 
149
149
  | Component | Key props |
150
150
  |-----------|-----------|
151
- | `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 |
152
152
  | `input` | `name`, `label`, `type`, `placeholder`, `value`, `error`, `hint`, `required`, `disabled`, `attrs` |
153
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' })`.** |
154
154
  | `select` | `name`, `label`, `options` (strings or `{value,label}`), `value`, `error`, `required`, `event` |
@@ -299,7 +299,7 @@ This is by design: vibes are a paint job, not a structural transformation. Compo
299
299
  | Component | Key props |
300
300
  |-----------|-----------|
301
301
  | `container` | `content`, `size` (sm/md/lg/xl) |
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 |
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`** |
303
303
  | `grid` | `content`, `cols` (1–4), `gap` (sm/md/lg) — responsive, collapses on mobile |
304
304
  | `stack` | `content`, `gap` (xs–xl), `align` — flex column |
305
305
  | `cluster` | `content`, `gap`, `justify`, `align` — flex row with wrap |