@invisibleloop/pulse 0.14.1 → 0.14.2
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/.claude/commands/build-page.md +5 -1
- package/.claude/commands/verify.md +23 -13
- package/docs/public/.pulse-ui-version +1 -1
- 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/src/agent/checklist.md +5 -3
- package/src/agent/guide-components.md +2 -2
- 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 +6 -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.14.
|
|
1
|
+
0.14.2
|
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/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 |
|
|
@@ -89,6 +89,15 @@ meta: {
|
|
|
89
89
|
|
|
90
90
|
**Colour tokens:** `--ui-bg`, `--ui-surface`, `--ui-surface-2`, `--ui-border`, `--ui-text`, `--ui-muted`, `--ui-accent`, `--ui-accent-hover`, `--ui-accent-dim`, `--ui-accent-text`, `--ui-green`, `--ui-red`, `--ui-yellow`, `--ui-blue`, `--ui-radius`, `--ui-radius-sm`, `--ui-font`, `--ui-font-display`, `--ui-mono`. Never hardcode hex values — override the tokens.
|
|
91
91
|
|
|
92
|
+
> **⚠ `--ui-muted` contrast on warm/light palettes** — The default `--ui-muted` value (`#60607a` in light theme) achieves 4.5:1 on the default white `--ui-bg`. When you set a warm or coloured background (`--bg: #f5e6d3` etc.) the luminance difference shrinks and `--ui-muted` can drop below the WCAG AA threshold of 4.5:1. **Always override `--muted` when changing `--bg`**, and run `pulse_check_contrast` immediately after writing your `theme.css`. A common failure mode: stat labels, nav links, and feature descriptions all use `--ui-muted` and will fail contrast silently if this token is not rechecked against your actual background colour.
|
|
93
|
+
|
|
94
|
+
> **⚠ `section--dark` in light theme** — `section({ variant: 'dark' })` uses `--ui-surface-2` as its background, which in the default light theme resolves to `#f0f0f8` — a light grey, not a dark background. The framework now auto-corrects this via a `[data-theme="light"] .ui-section--dark` override (navy `#1a1a2e`), and ghost buttons inside it get automatic light-text treatment. If you use a custom `--surface-2` override in your theme, set `--ui-dark-bg` and `--ui-dark-text` to pin the dark section to specific values:
|
|
95
|
+
> ```css
|
|
96
|
+
> [data-theme="light"] { --ui-dark-bg: #1a1a2e; --ui-dark-text: #e8e0d0; }
|
|
97
|
+
> ```
|
|
98
|
+
|
|
99
|
+
> **Ghost buttons on dark backgrounds** — use `variant: 'ghost-light'` instead of `variant: 'ghost'` whenever the button sits on a dark background (custom hero div, `section--dark`, navy sections). `ghost-light` renders white text with a subtle white border, maintaining 4.5:1 contrast regardless of the surrounding theme. `ghost` uses `--ui-muted` text which fails on dark backgrounds in light theme.
|
|
100
|
+
|
|
92
101
|
**Display font token:** `--ui-font-display` is used for hero titles and section headings. By default it inherits `--ui-font`. Override it separately to get a display/heading face that differs from body text:
|
|
93
102
|
```css
|
|
94
103
|
/* app.css */
|
|
@@ -219,6 +228,12 @@ createServer(specs, {
|
|
|
219
228
|
|
|
220
229
|
> **picsum CDN note:** `picsum.photos` redirects image requests through `fastly.picsum.photos`. You must whitelist **both** domains — whitelisting only `https://picsum.photos` will still block the actual image bytes and the browser error will point to the `fastly.picsum.photos` URL, which is confusing to debug.
|
|
221
230
|
|
|
231
|
+
> **⚠ Unsplash and third-party cookies — Lighthouse Best Practices failure:** `images.unsplash.com` sets tracking cookies (`azk`, `azk-ss`) on every image request. These are classified as third-party cookies and cause Lighthouse Best Practices to drop to ~77, failing the 100 pass bar. **For production builds, download images locally instead of referencing Unsplash URLs directly.** Use `curl` to save images to `public/images/`:
|
|
232
|
+
> ```bash
|
|
233
|
+
> curl -sL "https://images.unsplash.com/photo-ID?w=900&q=85" -o public/images/hero.jpg
|
|
234
|
+
> ```
|
|
235
|
+
> Then reference `/images/hero.jpg` in your spec. This also improves performance (no external DNS lookup) and avoids rate limiting. `picsum.photos` does **not** set tracking cookies and is safe to use via CDN for prototypes.
|
|
236
|
+
|
|
222
237
|
Without this, external images will be blocked in production and Lighthouse will flag a Best Practices failure. Use the same pattern for other external resource types (`media-src` for video, `connect-src` for fetch/XHR to external APIs).
|
|
223
238
|
|
|
224
239
|
For multiple weights or italic variants, separate them with a semicolon in the URL:
|
|
@@ -89,9 +89,12 @@ Run `pulse_intake` before presenting the confirmation. It collects the product d
|
|
|
89
89
|
6. "Any specific font, or system-ui?"
|
|
90
90
|
7. "Light or dark theme preference?"
|
|
91
91
|
8. "How should it feel visually? (e.g. warm and friendly, editorial and sharp, playful, minimal, bold — or describe in your own words)"
|
|
92
|
+
9. "Do you have any design inspiration — a site you love, a screenshot, a mood board image? Drop images into `public/intake/` or paste a URL and I'll extract the design intent before we start."
|
|
92
93
|
|
|
93
94
|
Ask each question and wait for the answer before asking the next. Do not present a list of preset choices for any of these — the answers are arbitrary text.
|
|
94
95
|
|
|
96
|
+
**Question 9 is not optional.** Always ask it. If the user provides an image in `public/intake/`, call `pulse_extract_inspiration` on it before calling `pulse_intake`. If they provide a URL, call `pulse_extract_inspiration` with the URL. If they say no or skip it, proceed without — but always ask.
|
|
97
|
+
|
|
95
98
|
Once you have the answers, call `pulse_intake` with the collected details. It returns a product brief + early contrast warnings.
|
|
96
99
|
|
|
97
100
|
**If `pulse_intake` is unavailable**, gather the answers via the questions above and use them directly when adapting the template — replace every placeholder with real content.
|
package/src/agent/identity.md
CHANGED
|
@@ -51,7 +51,8 @@ Every build task follows a fixed sequence of phases with explicit pass gates. Fe
|
|
|
51
51
|
2. **Plan** — present your plan, wait for user confirmation (skip only for trivially small tasks)
|
|
52
52
|
3. **Build** — write the spec and related files
|
|
53
53
|
4. **Validate** — `pulse_validate` must be clean before continuing
|
|
54
|
-
5. **
|
|
54
|
+
5. **Design approval** *(new builds only)* — take a screenshot, show the user, **wait for their go-ahead on layout and direction** before running any slow checks. For edits/fixes, skip this.
|
|
55
|
+
5. **Verify** — `pulse_design_review` → `pulse_layout_review` → Lighthouse desktop + mobile, all 100/100/100 before continuing
|
|
55
56
|
6. **Tests** — write and run tests, all must pass before continuing
|
|
56
57
|
7. **Review Agent** — invoke only after phases 4–6 all pass
|
|
57
58
|
8. **Fix** — fix every review issue, re-run any affected gates
|
|
@@ -1,12 +1,14 @@
|
|
|
1
1
|
---
|
|
2
2
|
name: verify
|
|
3
|
-
description: "Run the full verification loop on the current page or the page specified in $ARGUMENTS. If $ARGUMENTS is provided, use it as the file path or route. Otherwise, identify the most recently edited spec file from context.
|
|
3
|
+
description: "Run the full verification loop on the current page or the page specified in $ARGUMENTS. If $ARGUMENTS is provided, use it as the file path or route. Otherwise, identify the most recently edited spec file from context."
|
|
4
4
|
---
|
|
5
5
|
|
|
6
6
|
# Verify
|
|
7
7
|
|
|
8
8
|
Run the full verification loop on the current page or the page specified in $ARGUMENTS.
|
|
9
9
|
|
|
10
|
+
**Output style:** be terse throughout. One sentence per step. Do not quote raw tool output — summarise findings only. Reserve detail for failures that need explaining.
|
|
11
|
+
|
|
10
12
|
## Steps
|
|
11
13
|
|
|
12
14
|
### 1. Identify the target
|
|
@@ -15,53 +17,87 @@ If $ARGUMENTS is provided, use it as the file path or route. Otherwise, identify
|
|
|
15
17
|
|
|
16
18
|
### 2. Validate the spec
|
|
17
19
|
|
|
18
|
-
|
|
20
|
+
Run `pulse_validate` on the spec file. Report clean or list errors. Stop if there are errors — do not continue until the spec is valid.
|
|
19
21
|
|
|
20
22
|
### 3. Check the dev server
|
|
21
23
|
|
|
22
|
-
|
|
24
|
+
Run `pulse_fetch_page` to confirm the server is responding. If it errors, run `pulse_restart_server` and retry once. Report HTTP status only — do not quote the HTML body.
|
|
25
|
+
|
|
26
|
+
**If the response has no `X-Pulse` header**, the wrong server is running on that port — stop and tell the user before continuing.
|
|
23
27
|
|
|
24
28
|
### 4. Screenshot
|
|
25
29
|
|
|
26
|
-
|
|
30
|
+
Navigate to the page route with `mcp__chrome-devtools__navigate_page`, then take a screenshot with `mcp__chrome-devtools__take_screenshot`. Describe what you see in 2–3 plain sentences — layout structure, visual tone, key sections. Do not list features or code details.
|
|
31
|
+
|
|
32
|
+
### 4a. Design approval gate *(new builds only)*
|
|
33
|
+
|
|
34
|
+
**If `pulse_intake` ran earlier in this session** (i.e. this is a new page or site, not an edit or bug fix):
|
|
35
|
+
|
|
36
|
+
1. Show the screenshot to the user.
|
|
37
|
+
2. Ask: *"Happy with the layout and direction, or any changes before I run Lighthouse?"*
|
|
38
|
+
3. **Stop and wait for their response.** Do not proceed to step 4b or Lighthouse until the user explicitly approves.
|
|
39
|
+
|
|
40
|
+
If the user requests changes: make edits, restart the server, take a new screenshot, describe what changed, ask again. Repeat until approved. No Lighthouse between design rounds.
|
|
41
|
+
|
|
42
|
+
**For edits, bug fixes, or "add X to existing Y"** — skip this step and go straight to 4b.
|
|
43
|
+
|
|
44
|
+
### 4b. Design review *(if `pulse_intake` ran)*
|
|
45
|
+
|
|
46
|
+
Run `pulse_design_review`. Work through every signal it returns. Fix any Fail before continuing. Report pass/fail count only — do not quote the full output.
|
|
47
|
+
|
|
48
|
+
### 4c. Layout review
|
|
49
|
+
|
|
50
|
+
Run `pulse_layout_review` with the page URL. It checks 390/768/1280px viewports for overflow, broken images, and collapsed sections. Fix any failures before continuing. Report pass or list failures only.
|
|
51
|
+
|
|
52
|
+
### 5. Production build
|
|
53
|
+
|
|
54
|
+
Run `pulse_build` to start the production server on port 3001. Tell the user: "Building for production — ~30s…" before calling it.
|
|
55
|
+
|
|
56
|
+
### 6. Lighthouse — desktop
|
|
57
|
+
|
|
58
|
+
Navigate to `http://localhost:3001/<route>` with `mcp__chrome-devtools__navigate_page`, then run `mcp__chrome-devtools__lighthouse_audit` with `device: "desktop"`.
|
|
59
|
+
|
|
60
|
+
**Pass bar: Accessibility, Best Practices, and SEO must all be 100.** Report scores as a single line, e.g. `Accessibility 100 · Best Practices 100 · SEO 100`. If any score is below 100, identify the failing audit(s), fix, and restart from step 2.
|
|
61
|
+
|
|
62
|
+
### 7. Lighthouse — mobile
|
|
27
63
|
|
|
28
|
-
|
|
64
|
+
Run `mcp__chrome-devtools__lighthouse_audit` with `device: "mobile"` against the same production URL. Same pass bar. Report scores as a single line. Fix failures and restart from step 2 if needed.
|
|
29
65
|
|
|
30
|
-
|
|
66
|
+
**To inspect mobile layout** use `mcp__chrome-devtools__emulate` with `viewport: "390x844x2,mobile,touch"` — not `resize_page`. Reset with `"1440x900x1"` afterward.
|
|
31
67
|
|
|
32
|
-
|
|
68
|
+
### 8. Performance
|
|
33
69
|
|
|
34
|
-
|
|
70
|
+
Navigate to the page route, then run `mcp__chrome-devtools__performance_start_trace` with `reload: true` and `autoStop: true`. Report LCP and CLS as a single line, e.g. `LCP 73ms · CLS 0.00`. Flag any insight that identifies a fixable cause (render-blocking resources, large image delay). CLS must be 0.00.
|
|
35
71
|
|
|
36
|
-
|
|
72
|
+
### 9. Console errors
|
|
37
73
|
|
|
38
|
-
|
|
74
|
+
Run `mcp__chrome-devtools__list_console_messages` filtered to errors and warnings. Report count only if clean. List any errors found.
|
|
39
75
|
|
|
40
|
-
###
|
|
76
|
+
### 10. Code review
|
|
41
77
|
|
|
42
|
-
|
|
78
|
+
Run `pulse_review` with the absolute path to the spec file. Work through every item in the checklist it returns. Fix every issue before proceeding. Report pass or list what was fixed — do not quote the full review output.
|
|
43
79
|
|
|
44
|
-
###
|
|
80
|
+
### 11. Close the browser
|
|
45
81
|
|
|
46
|
-
|
|
82
|
+
Run `mcp__chrome-devtools__list_pages`, then `mcp__chrome-devtools__close_page` for every page ID returned. `pageId` must be a JSON number, not a string.
|
|
47
83
|
|
|
48
|
-
###
|
|
84
|
+
### 12. Write verification stamp
|
|
49
85
|
|
|
50
|
-
|
|
86
|
+
```bash
|
|
87
|
+
date +%s > .pulse-verified
|
|
88
|
+
```
|
|
51
89
|
|
|
52
|
-
|
|
90
|
+
**This must be the last write before stopping.** Do not edit any spec file after writing the stamp.
|
|
53
91
|
|
|
54
|
-
|
|
92
|
+
### 13. Report
|
|
55
93
|
|
|
56
|
-
|
|
94
|
+
One short paragraph covering: validation, Lighthouse desktop/mobile scores, LCP, CLS, console, review. Flag anything that needed fixing. No raw tool output.
|
|
57
95
|
|
|
58
|
-
|
|
59
|
-
- Validation:
|
|
60
|
-
-
|
|
61
|
-
- Lighthouse
|
|
62
|
-
-
|
|
63
|
-
-
|
|
64
|
-
- Console: any errors
|
|
65
|
-
- Review: pass or issues found and fixed
|
|
96
|
+
**Pass bar — all must be met:**
|
|
97
|
+
- Validation: clean
|
|
98
|
+
- Lighthouse desktop: Accessibility, Best Practices, SEO all 100
|
|
99
|
+
- Lighthouse mobile: same
|
|
100
|
+
- CLS: 0.00
|
|
101
|
+
- Console: no errors
|
|
66
102
|
|
|
67
|
-
|
|
103
|
+
If any gate fails, fix and run `/verify` again.
|