@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.
- package/.claude/commands/build-page.md +5 -1
- package/.claude/commands/verify.md +23 -13
- package/docs/.claude/pulse-checklist.md +5 -3
- package/docs/public/.pulse-ui-version +1 -1
- package/docs/public/docs.css +236 -0
- package/docs/public/home-brut.css +998 -0
- package/docs/public/menu.js +237 -6
- package/docs/public/pulse-ui.css +44 -0
- package/docs/public/theme.css +8 -0
- package/docs/server.js +24 -0
- package/docs/src/lib/layout.js +51 -3
- package/docs/src/pages/getting-started.js +1 -1
- package/docs/src/pages/home.js +231 -312
- package/docs/src/pages/how-it-works.js +67 -12
- package/examples/.claude/commands/pulse-build.md +7 -0
- package/examples/.claude/commands/pulse-dev.md +7 -0
- package/examples/.claude/commands/pulse-start.md +9 -0
- package/examples/.claude/commands/pulse-stop.md +5 -0
- package/examples/.claude/commands/verify.md +84 -0
- package/examples/.claude/coverage-check.js +127 -0
- package/examples/.claude/pulse-checklist.md +177 -0
- package/examples/public/.pulse-ui-version +1 -0
- package/examples/public/pulse-ui.css +4031 -0
- package/examples/public/pulse-ui.js +257 -0
- package/package.json +1 -1
- package/public/.pulse-ui-version +1 -1
- package/public/pulse-ui.css +44 -0
- package/scripts/build.js +127 -21
- package/src/agent/checklist.md +5 -3
- package/src/agent/guide-components.md +2 -2
- package/src/agent/guide-examples.md +310 -32
- package/src/agent/guide-styles.md +15 -0
- package/src/agent/guide-templates.md +3 -0
- package/src/agent/identity.md +2 -1
- package/src/agent/skills/verify/SKILL.md +64 -28
- package/src/agent/workflow.md +53 -17
- package/src/cli/dev.js +49 -13
- package/src/cli/index.js +4 -10
- package/src/cli/logger.js +145 -0
- package/src/cli/scaffold.js +55 -1
- package/src/mcp/server.js +174 -26
- package/src/mcp/validate-worker.js +21 -6
- package/src/server/index.js +20 -16
- package/src/spec/schema.js +19 -3
- package/src/spec/schema.test.js +28 -0
- 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 & 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
package/public/.pulse-ui-version
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
0.
|
|
1
|
+
0.15.0
|
package/public/pulse-ui.css
CHANGED
|
@@ -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
|
|
711
|
-
const
|
|
712
|
-
const pct
|
|
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
|
-
|
|
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
|
-
*
|
|
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
|
|
923
|
+
// Keep element-only selectors — no class tokens present
|
|
836
924
|
if (!base.includes('.')) return true
|
|
837
925
|
|
|
838
|
-
//
|
|
839
|
-
|
|
840
|
-
|
|
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
|
|
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
|
}
|
package/src/agent/checklist.md
CHANGED
|
@@ -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
|
-
-
|
|
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
|
|
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
|
|
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 |
|