@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
@@ -1,5 +1,5 @@
1
1
  /**
2
- * Pulse Docs — Mobile sidebar menu + demo theme toggles
2
+ * Pulse Docs — Mobile sidebar menu, search, and demo theme toggles
3
3
  * Loaded as an external script so it passes the CSP (no unsafe-inline needed).
4
4
  */
5
5
  (function () {
@@ -12,6 +12,225 @@
12
12
  sidebar.scrollTop = sidebar.scrollTop + (linkRect.top - navRect.top) - sidebar.clientHeight / 2 + link.clientHeight / 2
13
13
  }
14
14
 
15
+ // ── Sidebar nav filter ────────────────────────────────────────────────────
16
+
17
+ function initSidebarSearch(sidebar) {
18
+ var input = sidebar && sidebar.querySelector('#sidebar-search')
19
+ var nav = sidebar && sidebar.querySelector('#sidebar-nav')
20
+ var noResult = sidebar && sidebar.querySelector('.sidebar-no-results')
21
+ if (!input || !nav || input._searchBound) return
22
+ input._searchBound = true
23
+
24
+ function filter(q) {
25
+ var query = q.trim().toLowerCase()
26
+ var sections = nav.querySelectorAll('.nav-section')
27
+ var anyVisible = false
28
+
29
+ if (!query) {
30
+ sections.forEach(function (s) {
31
+ s.hidden = false
32
+ s.querySelectorAll('.nav-link').forEach(function (l) { l.hidden = false })
33
+ })
34
+ if (noResult) noResult.hidden = true
35
+ return
36
+ }
37
+
38
+ sections.forEach(function (section) {
39
+ var sectionLinks = section.querySelectorAll('.nav-link')
40
+ var sectionHasMatch = false
41
+ sectionLinks.forEach(function (link) {
42
+ var matches = link.textContent.toLowerCase().includes(query)
43
+ link.hidden = !matches
44
+ if (matches) { sectionHasMatch = true; anyVisible = true }
45
+ })
46
+ section.hidden = !sectionHasMatch
47
+ })
48
+
49
+ if (noResult) noResult.hidden = anyVisible
50
+ }
51
+
52
+ input.addEventListener('input', function () { filter(this.value) })
53
+
54
+ input.addEventListener('keydown', function (e) {
55
+ if (e.key === 'Escape') {
56
+ input.value = ''
57
+ filter('')
58
+ input.blur()
59
+ }
60
+ })
61
+ }
62
+
63
+ // ── Full-text search overlay ──────────────────────────────────────────────
64
+
65
+ var _searchIndex = null
66
+
67
+ function loadSearchIndex(cb) {
68
+ if (_searchIndex) { cb(_searchIndex); return }
69
+ fetch('/search-index.json')
70
+ .then(function (r) { return r.json() })
71
+ .then(function (data) { _searchIndex = data; cb(data) })
72
+ .catch(function () { cb([]) })
73
+ }
74
+
75
+ function searchIndex(query, index) {
76
+ var q = query.trim().toLowerCase()
77
+ if (!q) return []
78
+ return index.filter(function (item) {
79
+ return item.title.toLowerCase().includes(q) ||
80
+ item.section.toLowerCase().includes(q) ||
81
+ (item.body && item.body.toLowerCase().includes(q))
82
+ }).slice(0, 12)
83
+ }
84
+
85
+ function highlight(text, query) {
86
+ if (!query) return text
87
+ var re = new RegExp('(' + query.replace(/[.*+?^${}()|[\]\\]/g, '\\$&') + ')', 'gi')
88
+ return text.replace(re, '<mark>$1</mark>')
89
+ }
90
+
91
+ function snippet(body, query) {
92
+ if (!body || !query) return ''
93
+ var idx = body.toLowerCase().indexOf(query.toLowerCase())
94
+ if (idx === -1) return ''
95
+ var start = Math.max(0, idx - 40)
96
+ var end = Math.min(body.length, idx + query.length + 80)
97
+ var s = (start > 0 ? '…' : '') + body.slice(start, end) + (end < body.length ? '…' : '')
98
+ return highlight(s, query)
99
+ }
100
+
101
+ function renderResults(results, query, container, selectedIdx) {
102
+ while (container.firstChild) container.removeChild(container.firstChild)
103
+
104
+ if (!results.length) {
105
+ var empty = document.createElement('p')
106
+ empty.className = 'search-overlay-empty'
107
+ empty.textContent = 'No results for '
108
+ var strong = document.createElement('strong')
109
+ strong.textContent = query
110
+ empty.appendChild(strong)
111
+ container.appendChild(empty)
112
+ return
113
+ }
114
+
115
+ results.forEach(function (item, i) {
116
+ var a = document.createElement('a')
117
+ a.href = item.href
118
+ a.className = 'search-result' + (i === selectedIdx ? ' search-result--selected' : '')
119
+ a.setAttribute('role', 'option')
120
+ if (i === selectedIdx) a.setAttribute('aria-selected', 'true')
121
+
122
+ var meta = document.createElement('div')
123
+ meta.className = 'search-result-meta'
124
+ var section = document.createElement('span')
125
+ section.className = 'search-result-section'
126
+ section.textContent = item.section
127
+ meta.appendChild(section)
128
+ a.appendChild(meta)
129
+
130
+ var title = document.createElement('div')
131
+ title.className = 'search-result-title'
132
+ setHighlighted(title, item.title, query)
133
+ a.appendChild(title)
134
+
135
+ var snip = snippet(item.body, query)
136
+ if (snip) {
137
+ var snipEl = document.createElement('div')
138
+ snipEl.className = 'search-result-snip'
139
+ setHighlighted(snipEl, snip, null) // snip already has highlight markers stripped
140
+ a.appendChild(snipEl)
141
+ }
142
+
143
+ container.appendChild(a)
144
+ })
145
+ }
146
+
147
+ function setHighlighted(el, text, query) {
148
+ if (!query) { el.textContent = text; return }
149
+ var lower = text.toLowerCase()
150
+ var q = query.toLowerCase()
151
+ var idx = lower.indexOf(q)
152
+ if (idx === -1) { el.textContent = text; return }
153
+ var before = document.createTextNode(text.slice(0, idx))
154
+ var mark = document.createElement('mark')
155
+ mark.textContent = text.slice(idx, idx + query.length)
156
+ var after = document.createTextNode(text.slice(idx + query.length))
157
+ el.appendChild(before)
158
+ el.appendChild(mark)
159
+ el.appendChild(after)
160
+ }
161
+
162
+ function initSearchOverlay() {
163
+ var overlay = document.getElementById('search-overlay')
164
+ var input = document.getElementById('search-overlay-input')
165
+ var results = document.getElementById('search-overlay-results')
166
+ var fullBtns = document.querySelectorAll('.sidebar-search-full')
167
+ if (!overlay || !input || !results) return
168
+
169
+ var selectedIdx = -1
170
+ var currentResults = []
171
+ var currentQuery = ''
172
+
173
+ function open() {
174
+ overlay.hidden = false
175
+ document.body.style.overflow = 'hidden'
176
+ setTimeout(function () { input.focus() }, 50)
177
+ selectedIdx = -1
178
+ }
179
+
180
+ function close() {
181
+ overlay.hidden = true
182
+ document.body.style.overflow = ''
183
+ input.value = ''
184
+ results.innerHTML = ''
185
+ currentResults = []
186
+ currentQuery = ''
187
+ selectedIdx = -1
188
+ }
189
+
190
+ function navigate(dir) {
191
+ selectedIdx = Math.max(-1, Math.min(currentResults.length - 1, selectedIdx + dir))
192
+ renderResults(currentResults, currentQuery, results, selectedIdx)
193
+ var sel = results.querySelector('.search-result--selected')
194
+ if (sel) sel.scrollIntoView({ block: 'nearest' })
195
+ }
196
+
197
+ fullBtns.forEach(function (btn) { btn.addEventListener('click', open) })
198
+
199
+ overlay.querySelector('.search-overlay-backdrop').addEventListener('click', close)
200
+ overlay.querySelector('.search-overlay-close').addEventListener('click', close)
201
+
202
+ input.addEventListener('input', function () {
203
+ var q = this.value.trim()
204
+ currentQuery = q
205
+ selectedIdx = -1
206
+ if (!q) { results.innerHTML = ''; currentResults = []; return }
207
+ loadSearchIndex(function (index) {
208
+ currentResults = searchIndex(q, index)
209
+ renderResults(currentResults, q, results, selectedIdx)
210
+ })
211
+ })
212
+
213
+ input.addEventListener('keydown', function (e) {
214
+ if (e.key === 'Escape') { close(); return }
215
+ if (e.key === 'ArrowDown') { e.preventDefault(); navigate(1); return }
216
+ if (e.key === 'ArrowUp') { e.preventDefault(); navigate(-1); return }
217
+ if (e.key === 'Enter') {
218
+ if (selectedIdx >= 0 && currentResults[selectedIdx]) {
219
+ window.location.href = currentResults[selectedIdx].href
220
+ close()
221
+ }
222
+ }
223
+ })
224
+
225
+ document.addEventListener('keydown', function (e) {
226
+ if ((e.metaKey || e.ctrlKey) && e.key === 'k') {
227
+ e.preventDefault()
228
+ overlay.hidden ? open() : close()
229
+ }
230
+ if (e.key === 'Escape' && !overlay.hidden) close()
231
+ })
232
+ }
233
+
15
234
  // ── Bind sidebar, mobile menu, and centre the active link ─────────────────
16
235
  // Called on first load and after every client-side navigation, because
17
236
  // #pulse-root is replaced on navigate — all element references go stale.
@@ -49,13 +268,25 @@
49
268
  // Centre the active link
50
269
  var activeLink = sidebar && sidebar.querySelector('.nav-link.active')
51
270
  if (activeLink) centerNavLink(sidebar, activeLink)
271
+
272
+ // Sidebar filter
273
+ initSidebarSearch(sidebar)
274
+
275
+ // Full-text search overlay (only bind once — overlay persists across navigations)
276
+ if (!document.getElementById('search-overlay')?._searchBound) {
277
+ initSearchOverlay()
278
+ var o = document.getElementById('search-overlay')
279
+ if (o) o._searchBound = true
280
+ }
52
281
  }
53
282
 
54
- // On initial load — wait for layout paint before scrolling
55
- if (document.readyState === 'complete') {
283
+ // On initial load — defer slightly to ensure layout paint before scrolling.
284
+ // Use both paths: if load already fired (common with defer scripts), run now;
285
+ // otherwise wait for it.
286
+ if (document.readyState === 'complete' || document.readyState === 'interactive') {
56
287
  setTimeout(init, 50)
57
288
  } else {
58
- window.addEventListener('load', init)
289
+ window.addEventListener('load', function () { setTimeout(init, 50) })
59
290
  }
60
291
 
61
292
  // After client-side navigation — re-bind everything against the fresh DOM
@@ -88,9 +319,9 @@
88
319
 
89
320
  document.addEventListener('pulse:navigate', bindDemoControls)
90
321
 
91
- if (document.readyState === 'complete') {
322
+ if (document.readyState === 'complete' || document.readyState === 'interactive') {
92
323
  setTimeout(bindDemoControls, 50)
93
324
  } else {
94
- window.addEventListener('load', bindDemoControls)
325
+ window.addEventListener('load', function () { setTimeout(bindDemoControls, 50) })
95
326
  }
96
327
  })()
@@ -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));
@@ -0,0 +1,8 @@
1
+ /* Brut theme tokens */
2
+ :root {
3
+ --brut-yellow: #f0e642;
4
+ --brut-ink: #0a0a0a;
5
+ --brut-surface: #ffffff;
6
+ --brut-dark: #111114;
7
+ --brut-darker: #111214;
8
+ }
package/docs/server.js CHANGED
@@ -3,8 +3,17 @@ import { createServer } from '../src/server/index.js'
3
3
  import { initLayoutManifest } from './src/lib/layout.js'
4
4
  import { metrics } from './src/lib/stats.js'
5
5
  import { metricsStore } from './src/lib/metrics-store.js'
6
+ import { NAV } from './src/lib/nav.js'
6
7
  metricsStore.current = metrics
7
8
 
9
+ // Build search index once at startup from nav structure
10
+ const searchIndex = JSON.stringify(
11
+ NAV.flatMap(({ section, items }) =>
12
+ items.map(item => ({ title: item.label, href: item.href, section, body: '' }))
13
+ )
14
+ )
15
+ const searchIndexEtag = `"${Buffer.from(searchIndex).length}-search"`
16
+
8
17
  // Populate hashed asset paths in layout.js before any page renders
9
18
  try {
10
19
  const raw = fs.readFileSync(new URL('./public/dist/manifest.json', import.meta.url), 'utf8')
@@ -142,5 +151,20 @@ await createServer(
142
151
  'style-src': ['https://fonts.googleapis.com'],
143
152
  'font-src': ['https://fonts.gstatic.com'],
144
153
  },
154
+ onRequest(req, res) {
155
+ if (req.url !== '/search-index.json') return
156
+ if (req.headers['if-none-match'] === searchIndexEtag) {
157
+ res.writeHead(304)
158
+ res.end()
159
+ return false
160
+ }
161
+ res.writeHead(200, {
162
+ 'Content-Type': 'application/json; charset=utf-8',
163
+ 'Cache-Control': 'public, max-age=3600',
164
+ 'ETag': searchIndexEtag,
165
+ })
166
+ res.end(searchIndex)
167
+ return false
168
+ },
145
169
  }
146
170
  )
@@ -34,7 +34,7 @@ function sidebar(currentHref) {
34
34
  }).join('')
35
35
 
36
36
  return `
37
- <aside class="docs-sidebar" aria-label="Documentation navigation">
37
+ <aside class="docs-sidebar" id="docs-sidebar" aria-label="Documentation navigation">
38
38
  <div class="sidebar-logo">
39
39
  <a href="/" class="logo-link" aria-label="Pulse home">
40
40
  <svg width="24" height="24" viewBox="0 0 24 24" fill="none" aria-hidden="true">
@@ -44,10 +44,58 @@ function sidebar(currentHref) {
44
44
  </a>
45
45
  <span class="version-badge">v${version}</span>
46
46
  </div>
47
- <nav class="sidebar-nav">
47
+ <div class="sidebar-search-wrap">
48
+ <svg class="sidebar-search-icon" width="14" height="14" viewBox="0 0 20 20" fill="currentColor" aria-hidden="true">
49
+ <path fill-rule="evenodd" d="M8 4a4 4 0 100 8 4 4 0 000-8zM2 8a6 6 0 1110.89 3.476l4.817 4.817a1 1 0 01-1.414 1.414l-4.816-4.816A6 6 0 012 8z" clip-rule="evenodd"/>
50
+ </svg>
51
+ <input
52
+ type="search"
53
+ id="sidebar-search"
54
+ class="sidebar-search"
55
+ placeholder="Filter pages…"
56
+ aria-label="Filter documentation pages"
57
+ autocomplete="off"
58
+ spellcheck="false"
59
+ >
60
+ <button class="sidebar-search-full" aria-label="Search all docs content" title="Search all docs">
61
+ <svg width="14" height="14" viewBox="0 0 20 20" fill="currentColor" aria-hidden="true">
62
+ <path fill-rule="evenodd" d="M3 4a1 1 0 011-1h12a1 1 0 110 2H4a1 1 0 01-1-1zm0 4a1 1 0 011-1h12a1 1 0 110 2H4a1 1 0 01-1-1zm0 4a1 1 0 011-1h7a1 1 0 110 2H4a1 1 0 01-1-1z" clip-rule="evenodd"/>
63
+ </svg>
64
+ </button>
65
+ </div>
66
+ <nav class="sidebar-nav" id="sidebar-nav">
48
67
  ${sections}
68
+ <p class="sidebar-no-results" hidden>No pages found</p>
49
69
  </nav>
50
- </aside>`
70
+ </aside>
71
+ <div class="search-overlay" id="search-overlay" role="dialog" aria-modal="true" aria-label="Search documentation" hidden>
72
+ <div class="search-overlay-backdrop"></div>
73
+ <div class="search-overlay-panel">
74
+ <div class="search-overlay-header">
75
+ <div class="search-overlay-input-wrap">
76
+ <svg width="18" height="18" viewBox="0 0 20 20" fill="currentColor" aria-hidden="true">
77
+ <path fill-rule="evenodd" d="M8 4a4 4 0 100 8 4 4 0 000-8zM2 8a6 6 0 1110.89 3.476l4.817 4.817a1 1 0 01-1.414 1.414l-4.816-4.816A6 6 0 012 8z" clip-rule="evenodd"/>
78
+ </svg>
79
+ <input
80
+ type="search"
81
+ id="search-overlay-input"
82
+ class="search-overlay-input"
83
+ placeholder="Search all docs…"
84
+ aria-label="Search all documentation"
85
+ autocomplete="off"
86
+ spellcheck="false"
87
+ >
88
+ </div>
89
+ <button class="search-overlay-close" aria-label="Close search">ESC</button>
90
+ </div>
91
+ <div class="search-overlay-results" id="search-overlay-results" role="listbox" aria-label="Search results"></div>
92
+ <div class="search-overlay-footer">
93
+ <span><kbd>↑</kbd><kbd>↓</kbd> navigate</span>
94
+ <span><kbd>↵</kbd> open</span>
95
+ <span><kbd>ESC</kbd> close</span>
96
+ </div>
97
+ </div>
98
+ </div>`
51
99
  }
52
100
 
53
101
  function prevNextBar(prev, next) {
@@ -22,7 +22,7 @@ export default {
22
22
  ${section('requirements', 'Requirements')}
23
23
  <ul>
24
24
  <li><strong>Node.js 22 or later</strong> — <a href="https://nodejs.org" target="_blank" rel="noopener" aria-label="nodejs.org (opens in new tab)">nodejs.org</a></li>
25
- <li><strong>Google Chrome</strong> — used by the agent for screenshots and Lighthouse audits — <a href="https://www.google.com/chrome" target="_blank" rel="noopener" aria-label="Download Google Chrome (opens in new tab)">google.com/chrome</a></li>
25
+ <li><strong>Google Chrome</strong> — used by the agent for screenshots and Lighthouse audits — <a href="https://www.google.com/chrome" target="_blank" rel="noopener" aria-label="google.com/chrome (opens in new tab)">google.com/chrome</a></li>
26
26
  <li><strong>An AI agent</strong> — Claude Code or GitHub Copilot CLI (pick one):</li>
27
27
  </ul>
28
28
  <ul>