@invisibleloop/pulse 0.17.0 → 0.17.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.
@@ -152,14 +152,19 @@ const agentDocs = [
152
152
  '.claude/commands/verify.md',
153
153
  '.claude/commands/new-doc-page.md',
154
154
  'CLAUDE.md',
155
+ 'README.md',
155
156
  ]
156
157
 
157
158
  test('no agent doc states a four-score Lighthouse bar (100/100/100/100)', () => {
159
+ const fourScore = (src) =>
160
+ src.includes('100/100/100/100') ||
161
+ /all four (scores|categories)/i.test(src) ||
162
+ /Accessibility, Best Practices, SEO,? and Performance[^.\n]*must (all )?be 100/i.test(src)
158
163
  const offenders = agentDocs.filter(f =>
159
164
  fs.existsSync(path.join(ROOT, f)) &&
160
- fs.readFileSync(path.join(ROOT, f), 'utf8').includes('100/100/100/100')
165
+ fourScore(fs.readFileSync(path.join(ROOT, f), 'utf8'))
161
166
  )
162
- if (serverSrc.includes('100/100/100/100')) offenders.push('src/mcp/server.js')
167
+ if (fourScore(serverSrc)) offenders.push('src/mcp/server.js')
163
168
  assert(
164
169
  offenders.length === 0,
165
170
  `Four-score Lighthouse bar found in: ${offenders.join(', ')}. ` +
@@ -204,6 +209,115 @@ test('workflow.md Mode B instructs writing the creative-override spec comment',
204
209
  )
205
210
  })
206
211
 
212
+ test('checklist points cross-spec shared components at src/components/, not src/ui/', () => {
213
+ const checklist = fs.readFileSync(path.join(agentDir, 'checklist.md'), 'utf8')
214
+ assert(
215
+ !/shared component in `src\/ui\/`/.test(checklist),
216
+ 'checklist.md tells the agent to create shared components in src/ui/ — that is the ' +
217
+ 'framework\'s own library inside the package. Project-level shared code goes in src/components/.'
218
+ )
219
+ assert(
220
+ /shared component in `src\/components\/`/.test(checklist),
221
+ 'checklist.md must direct cross-spec shared components to src/components/.'
222
+ )
223
+ })
224
+
225
+ test('guide-spec.md states the one-spec-per-page mental model', () => {
226
+ const guideSpec = fs.readFileSync(path.join(agentDir, 'guide-spec.md'), 'utf8')
227
+ assert(
228
+ /one spec per page/i.test(guideSpec) && /dynamic route is still one spec/i.test(guideSpec),
229
+ 'guide-spec.md must state the mental model explicitly: one spec = one route = one file, ' +
230
+ 'and a dynamic route is still one spec serving many URLs.'
231
+ )
232
+ })
233
+
234
+ test('approval pause mechanism is wired end to end', () => {
235
+ // The design-approval gate requires the agent to end its turn and wait for the
236
+ // user — but the scaffolded Stop hooks block any turn ending with unverified
237
+ // edits. Without an escape hatch the hooks steamroll the approval gate (the
238
+ // agent gets blocked, reads "Run /verify", and proceeds without an answer).
239
+ const scaffoldSrc = fs.readFileSync(path.join(ROOT, 'src/cli/scaffold.js'), 'utf8')
240
+ const coverageSrc = fs.readFileSync(path.join(agentDir, 'coverage-check.js'), 'utf8')
241
+ const workflow = fs.readFileSync(path.join(agentDir, 'workflow.md'), 'utf8')
242
+
243
+ // 1. The MCP server must expose the tool that writes the marker
244
+ assert(/registerTool\(\s*'pulse_await_approval'/.test(serverSrc),
245
+ 'server.js must register pulse_await_approval')
246
+
247
+ // 2. Every inline Stop hook in the scaffold must early-exit on the marker
248
+ const stopHookCount = (scaffoldSrc.match(/decision:'block'/g) || []).length
249
+ const markerCheckCount = (scaffoldSrc.match(/\.pulse-awaiting-approval'\)\)process\.exit\(0\)/g) || []).length
250
+ assert(stopHookCount >= 2 && markerCheckCount >= stopHookCount,
251
+ `scaffold.js has ${stopHookCount} blocking Stop hooks but only ${markerCheckCount} check ` +
252
+ `.pulse-awaiting-approval — every blocking Stop hook must allow the approval pause.`)
253
+
254
+ // 3. coverage-check.js (the third Stop hook) must early-exit on the marker
255
+ assert(coverageSrc.includes('.pulse-awaiting-approval'),
256
+ 'coverage-check.js must early-exit when .pulse-awaiting-approval exists')
257
+
258
+ // 4. The marker must be consumed when the user replies — one turn-end only
259
+ assert(/UserPromptSubmit/.test(scaffoldSrc) && /unlinkSync\('\.pulse-awaiting-approval'\)/.test(scaffoldSrc),
260
+ 'scaffold.js must register a UserPromptSubmit hook that deletes .pulse-awaiting-approval')
261
+
262
+ // 5. The workflow must teach the mechanism at the design-approval gate
263
+ assert(workflow.includes('pulse_await_approval'),
264
+ 'workflow.md Phase 5a must explain how to pause: always pulse_await_approval before asking')
265
+
266
+ // 6. No doc may claim a host question tool avoids ending the turn — field
267
+ // testing showed AskUserQuestion in Claude Code still ends the turn and
268
+ // fires the Stop hooks. The safe instruction is: always write the marker
269
+ // first, regardless of how the question is asked.
270
+ const claimDocs = agentDocs.filter(f =>
271
+ fs.existsSync(path.join(ROOT, f)) &&
272
+ /without ending the turn/i.test(fs.readFileSync(path.join(ROOT, f), 'utf8'))
273
+ )
274
+ if (/without ending the turn.*Stop hooks never fire/is.test(serverSrc)) claimDocs.push('src/mcp/server.js')
275
+ assert(claimDocs.length === 0,
276
+ `These docs claim a question tool avoids ending the turn (empirically false in Claude Code): ${claimDocs.join(', ')}. ` +
277
+ `Instruct: always call pulse_await_approval before asking, whichever way the question is asked.`)
278
+ })
279
+
280
+ test('pulse_check_contrast extracts variables from [data-theme="light"] blocks', () => {
281
+ // Regression: the light-theme block pattern was an ungrouped alternation
282
+ // (a|b) + '\\s*\\{([^}]+)\\}' — the body matcher bound only to the second
283
+ // alternative, so [data-theme="light"] blocks matched with no body capture
284
+ // and the checker silently reported nothing for the light theme.
285
+ // Recreate extractVars with the exact pattern strings from server.js source.
286
+ const patterns = [...serverSrc.matchAll(/extractVars\(source,\s*'((?:[^'\\]|\\.)*)'\)/g)].map(m =>
287
+ m[1].replace(/\\\\/g, '\\').replace(/\\'/g, "'")
288
+ )
289
+ assert(patterns.length === 2, `Expected 2 extractVars call patterns in server.js, found ${patterns.length}`)
290
+ const lightPattern = patterns.find(p => p.includes('data-theme'))
291
+ assert(lightPattern, 'No data-theme pattern found in extractVars calls')
292
+
293
+ const sampleCss = `:root { --accent: #112233; }\n[data-theme="light"] { --accent: #445566; --bg: #ffffff; }`
294
+ const blockRegex = new RegExp(lightPattern + '\\s*\\{([^}]+)\\}', 'gi')
295
+ const m = blockRegex.exec(sampleCss)
296
+ assert(m && m[1] && m[1].includes('--accent'),
297
+ `The light-theme block pattern in pulse_check_contrast fails to capture the block body. ` +
298
+ `Pattern: ${lightPattern} — alternations must be wrapped in (?:...).`)
299
+ })
300
+
301
+ test('dark theme default is declared at every spec-writing entry point', () => {
302
+ // Agents repeatedly assumed the default theme is light, built the page, then
303
+ // discovered dark at the screenshot — costing an edit → restart → re-approval
304
+ // cycle. The dark default must be stated wherever a spec is first written:
305
+ // the spec skeleton, the plan/build brief, and the checklist.
306
+ const guideSpec = fs.readFileSync(path.join(agentDir, 'guide-spec.md'), 'utf8')
307
+ const workflow = fs.readFileSync(path.join(agentDir, 'workflow.md'), 'utf8')
308
+ const checklist = fs.readFileSync(path.join(agentDir, 'checklist.md'), 'utf8')
309
+ const claudeMd = fs.readFileSync(path.join(ROOT, 'CLAUDE.md'), 'utf8')
310
+
311
+ assert(/theme:/.test(guideSpec) && /DEFAULT IS DARK/i.test(guideSpec),
312
+ 'guide-spec.md meta skeleton must include the theme field with the dark-default warning')
313
+ assert(/Theme:\s+light \| dark/.test(workflow),
314
+ 'workflow.md build brief template must include a Theme line')
315
+ assert(/default theme is DARK/i.test(checklist),
316
+ 'checklist.md Critical section must state the dark default')
317
+ assert(/theme:\s+'light'/.test(claudeMd) && /default is DARK/i.test(claudeMd),
318
+ 'CLAUDE.md spec meta block must show theme with the dark-default warning')
319
+ })
320
+
207
321
  test('identity.md declares the creative override (Design Freedom) carve-out', () => {
208
322
  const identity = fs.readFileSync(path.join(agentDir, 'identity.md'), 'utf8')
209
323
  assert(
package/src/mcp/server.js CHANGED
@@ -1203,6 +1203,33 @@ The stop hook compares each changed spec's mtime against this stamp. Any spec ne
1203
1203
  }
1204
1204
  )
1205
1205
 
1206
+ // ---------------------------------------------------------------------------
1207
+ // pulse_await_approval
1208
+ // ---------------------------------------------------------------------------
1209
+
1210
+ server.registerTool(
1211
+ 'pulse_await_approval',
1212
+ {
1213
+ description: `Pause the workflow to wait for the user's answer — call this immediately BEFORE asking a blocking question (e.g. the design-approval gate: "Happy with the layout and direction…?").
1214
+
1215
+ It writes a .pulse-awaiting-approval marker that tells the Stop hooks (missing tests, coverage, verify stamp) to let the turn end without demanding /verify first. The marker is deleted automatically when the user sends their next message — the gates return in full force for your following turn.
1216
+
1217
+ **Always call this before the approval question, regardless of how you ask.** In some hosts (including Claude Code) even a dedicated question tool like AskUserQuestion ends the turn, which fires the Stop hooks. The marker is harmless if the turn does not end — it is simply consumed when the user replies. Calling it first works in every host; relying on the question tool to keep the turn open does not.
1218
+
1219
+ Do NOT use this to skip verification — it buys exactly one turn-end, paired with a question the user must answer. Calling it without asking anything just delays the gates by one message.`,
1220
+ inputSchema: {},
1221
+ },
1222
+ () => {
1223
+ const markerPath = path.join(ROOT, '.pulse-awaiting-approval')
1224
+ try {
1225
+ fs.writeFileSync(markerPath, String(Math.floor(Date.now() / 1000)), 'utf8')
1226
+ return text('✓ Awaiting-approval marker written. Ask the user your question and end your turn — the Stop hooks will allow it. The marker is cleared when the user replies.')
1227
+ } catch (err) {
1228
+ return text(`Error writing .pulse-awaiting-approval: ${err.message}\nFallback: run \`touch .pulse-awaiting-approval\` in Bash, then ask your question.`)
1229
+ }
1230
+ }
1231
+ )
1232
+
1206
1233
  // ---------------------------------------------------------------------------
1207
1234
  // pulse_run_tests
1208
1235
  // ---------------------------------------------------------------------------
@@ -2072,6 +2099,7 @@ Returns observations grouped by category: completeness, data flow, components, s
2072
2099
  if (!content.includes('title:')) quickWins.push('`meta.title` is missing — every page needs a unique title.')
2073
2100
  if (!content.includes('description:')) quickWins.push('`meta.description` is missing — used for SEO and social previews.')
2074
2101
  if (!content.includes('styles:')) quickWins.push('`meta.styles` not set — add `[\'/pulse-ui.css\']` if you are using any UI components.')
2102
+ if (!content.includes('theme:')) quickWins.push('`meta.theme` not set — Pulse renders DARK by default. If this design is light, add `theme: \'light\'` now rather than discovering a dark page at the screenshot.')
2075
2103
  }
2076
2104
 
2077
2105
  // --- Export ---
@@ -2405,7 +2433,7 @@ Accepts antiStyle ("what should this NOT look like?") and inspiration (a site or
2405
2433
  targetUser: z.string().optional().describe('Who this is for — shapes copy tone. E.g. "busy professionals", "indie developers", "home cooks"'),
2406
2434
  palette: z.string().optional().describe('Brand colours as hex values, comma-separated. E.g. "#6366f1, #f8fafc, #1e1b4b". Used for early contrast check.'),
2407
2435
  font: z.string().optional().describe('Brand font name, or omit for system-ui'),
2408
- theme: z.enum(['light', 'dark']).optional().describe('Colour theme preference'),
2436
+ theme: z.enum(['light', 'dark']).optional().describe('Colour theme — ALWAYS ask the user for this; Pulse renders DARK when meta.theme is unset, so an unstated light design ships dark and costs a rework cycle. Pass the answer through to meta.theme in the spec.'),
2409
2437
  vibe: z.enum(['warm', 'editorial', 'playful', 'minimal', 'bold', 'brutalist', 'retro', 'corporate', 'neon', 'paper']).optional().describe('Visual personality — see pulse://guide/design-references for full descriptions. warm (rounded, inviting), editorial (serif, sharp), playful (very rounded), minimal (clean), bold (impact headings), brutalist (zero radius, raw), retro (slab serif, nostalgic), corporate (conservative), neon (monospace, futuristic), paper (organic, serif, journal)'),
2410
2438
  styleNotes: z.string().optional().describe('Free-form style direction — e.g. "like a print poster", "market stall chalkboard feel", "clinical and professional"'),
2411
2439
  antiStyle: z.string().optional().describe('"What should this NOT look like?" — negative aesthetic constraint. E.g. "not corporate SaaS", "not another Vercel dark card page", "not a startup landing page". Used to steer component and layout choices away from over-used patterns.'),
@@ -2970,6 +2998,78 @@ Returns tokens grouped by category.`,
2970
2998
  }
2971
2999
  )
2972
3000
 
3001
+ server.registerTool(
3002
+ 'pulse_check_csp',
3003
+ {
3004
+ description: `Resolve the redirect chains of external asset URLs (images, fonts, stylesheets) and return the complete set of origins your CSP must allow — as ready-to-paste config.
3005
+
3006
+ Many asset hosts redirect to a CDN origin (picsum.photos → fastly.picsum.photos): allowing only the URL you wrote still blocks the actual bytes, and the browser error names the CDN origin, which is confusing to debug. This tool follows each redirect chain (up to 5 hops) and reports every origin involved.
3007
+
3008
+ Call it with the external URLs you plan to use, before your first Lighthouse run. Suggested directive: img-src for images, font-src for font files, style-src for stylesheets.`,
3009
+ inputSchema: {
3010
+ urls: z.array(z.string()).describe('External asset URLs to check, e.g. ["https://picsum.photos/id/10/1200/600"]'),
3011
+ directive: z.string().optional().describe("CSP directive these assets belong to (default: 'img-src')"),
3012
+ },
3013
+ },
3014
+ async ({ urls, directive = 'img-src' }) => {
3015
+ const MAX_HOPS = 5
3016
+ const origins = new Set()
3017
+ const lines = []
3018
+
3019
+ for (const rawUrl of urls.slice(0, 10)) {
3020
+ let url
3021
+ try { url = new URL(rawUrl) } catch { lines.push(`✗ ${rawUrl} — not a valid URL`); continue }
3022
+
3023
+ const chain = [url.origin]
3024
+ let blocked = null
3025
+ try {
3026
+ for (let hop = 0; hop < MAX_HOPS; hop++) {
3027
+ const res = await fetch(url, { method: 'HEAD', redirect: 'manual', signal: AbortSignal.timeout(5000) })
3028
+ if (res.status >= 300 && res.status < 400 && res.headers.get('location')) {
3029
+ url = new URL(res.headers.get('location'), url)
3030
+ chain.push(url.origin)
3031
+ continue
3032
+ }
3033
+ if (res.status === 405) {
3034
+ // Host rejects HEAD — confirm with a ranged GET so we still see redirects
3035
+ const get = await fetch(url, { method: 'GET', redirect: 'manual', headers: { Range: 'bytes=0-0' }, signal: AbortSignal.timeout(5000) })
3036
+ if (get.status >= 300 && get.status < 400 && get.headers.get('location')) {
3037
+ url = new URL(get.headers.get('location'), url)
3038
+ chain.push(url.origin)
3039
+ continue
3040
+ }
3041
+ }
3042
+ break
3043
+ }
3044
+ } catch (err) {
3045
+ blocked = err?.name === 'TimeoutError' ? 'timed out' : (err?.message || 'request failed')
3046
+ }
3047
+
3048
+ const uniqueChain = [...new Set(chain)]
3049
+ for (const o of uniqueChain) origins.add(o)
3050
+ lines.push(
3051
+ blocked
3052
+ ? `⚠ ${rawUrl} — ${blocked}; allowing origin from the URL only: ${uniqueChain.join(' → ')}`
3053
+ : uniqueChain.length > 1
3054
+ ? `↪ ${rawUrl} — redirects: ${uniqueChain.join(' → ')} (allow ALL of these)`
3055
+ : `✓ ${rawUrl} — no redirect, origin: ${uniqueChain[0]}`
3056
+ )
3057
+ }
3058
+
3059
+ const config = [...origins].map(o => `'${o}'`).join(', ')
3060
+ lines.push('')
3061
+ lines.push('Ready to paste:')
3062
+ lines.push('```js')
3063
+ lines.push(`csp: {`)
3064
+ lines.push(` '${directive}': [${[...origins].map(o => `'${o}'`).join(', ')}],`)
3065
+ lines.push(`}`)
3066
+ lines.push('```')
3067
+ if (config.includes('http://')) lines.push('⚠ An origin resolved to plain http: — use https URLs in production.')
3068
+
3069
+ return text(lines.join('\n'))
3070
+ }
3071
+ )
3072
+
2973
3073
  server.registerTool(
2974
3074
  'pulse_check_contrast',
2975
3075
  {
@@ -3009,8 +3109,12 @@ Run this immediately after writing a theme file — before production build and
3009
3109
  return vars
3010
3110
  }
3011
3111
 
3112
+ // NOTE: alternations must be wrapped in a non-capturing group. An ungrouped
3113
+ // pattern (a|b) followed by '\\s*\\{...' binds the body matcher to the second
3114
+ // alternative only — [data-theme="light"] blocks then match the selector
3115
+ // alone with no body capture, silently returning zero variables.
3012
3116
  const rootVars = extractVars(source, ':root')
3013
- const lightVars = extractVars(source, '\\[data-theme=["\']light["\']\\]|[.]ui-theme-light')
3117
+ const lightVars = extractVars(source, '(?:\\[data-theme=["\']light["\']\\]|[.]ui-theme-light)')
3014
3118
 
3015
3119
  // pulse-ui.css maps --NAME → --ui-NAME (e.g. --accent → --ui-accent).
3016
3120
  // Theme files written per the guide define bare --* tokens. Synthesize the
@@ -24,6 +24,6 @@ export function dropCap({
24
24
  const classes = ['ui-drop-cap', cls].filter(Boolean).join(' ')
25
25
 
26
26
  return `<p class="${e(classes)}">
27
- <span class="ui-drop-cap-letter" style="float:left;font-size:${lines * 1.2}em;line-height:${lines * 0.8};margin-right:0.1em" aria-hidden="true">${e(letter)}</span>${e(content)}
27
+ <span class="ui-drop-cap-letter" style="font-size:${+(lines * 1.2).toFixed(2)}em" aria-hidden="true">${e(letter)}</span>${e(content)}
28
28
  </p>`
29
29
  }
package/src/ui/gallery.js CHANGED
@@ -11,6 +11,9 @@
11
11
  * @param {'sm'|'md'|'lg'} opts.gap - Gap between images (default: 'md')
12
12
  * @param {boolean} opts.rounded - Rounded corners on images
13
13
  * @param {string} opts.ratio - CSS aspect-ratio for grid/strip cells e.g. '4/3', '1/1', '16/9'
14
+ * @param {boolean} opts.lazy - loading="lazy" on images (default: true). Set false for
15
+ * above-the-fold galleries or when full-page screenshots
16
+ * during development need every image rendered eagerly.
14
17
  * @param {string} opts.class
15
18
  */
16
19
 
@@ -26,6 +29,7 @@ export function gallery({
26
29
  gap = 'md',
27
30
  rounded = false,
28
31
  ratio = '4/3',
32
+ lazy = true,
29
33
  class: cls = '',
30
34
  } = {}) {
31
35
  if (!LAYOUTS.has(layout)) layout = 'grid'
@@ -42,7 +46,7 @@ export function gallery({
42
46
  ].filter(Boolean).join(' ')
43
47
 
44
48
  const items = images.map(img => {
45
- const imgTag = `<img src="${e(img.src)}" alt="${e(img.alt || '')}" class="ui-gallery-img" loading="lazy" decoding="async">`
49
+ const imgTag = `<img src="${e(img.src)}" alt="${e(img.alt || '')}" class="ui-gallery-img"${lazy ? ' loading="lazy"' : ''} decoding="async">`
46
50
  const cap = img.caption ? `<figcaption class="ui-gallery-caption">${e(img.caption)}</figcaption>` : ''
47
51
  const inner = img.href
48
52
  ? `<a href="${e(img.href)}" class="ui-gallery-link">${imgTag}</a>`
package/src/ui/ui.test.js CHANGED
@@ -25,6 +25,8 @@ import { pricing } from './pricing.js'
25
25
  import { accordion } from './accordion.js'
26
26
  import { nav } from './nav.js'
27
27
  import { appBadge } from './app-badge.js'
28
+ import { dropCap } from './drop-cap.js'
29
+ import { gallery } from './gallery.js'
28
30
  import { container } from './container.js'
29
31
  import { section } from './section.js'
30
32
  import { grid } from './grid.js'
@@ -1319,4 +1321,36 @@ test('uiImage: escapes src to prevent XSS', () => {
1319
1321
  assert.doesNotMatch(html, /<script>/)
1320
1322
  })
1321
1323
 
1324
+ test('dropCap: inline style carries only the dynamic font-size — alignment lives in CSS', () => {
1325
+ // Regression: a relative inline line-height (lines * 0.8 → 2.4 for 3 lines)
1326
+ // made the float box ~3x the glyph height, so serif caps floated below the
1327
+ // opening line instead of top-aligning. line-height/float/margin now live in
1328
+ // .ui-drop-cap-letter (pulse-ui.css) with a fixed ~cap-height value.
1329
+ const html = dropCap({ letter: 'O', content: 'nce upon a time.' })
1330
+ assert.match(html, /class="ui-drop-cap-letter"/)
1331
+ assert.match(html, /style="font-size:3\.6em"/)
1332
+ assert.doesNotMatch(html, /line-height/)
1333
+ assert.doesNotMatch(html, /float:left/)
1334
+ })
1335
+
1336
+ test('dropCap: lines option scales the font-size', () => {
1337
+ const html = dropCap({ letter: 'O', content: 'nce.', lines: 5 })
1338
+ assert.match(html, /style="font-size:6em"/)
1339
+ })
1340
+
1341
+ test('gallery: images are lazy by default', () => {
1342
+ const html = gallery({ images: [{ src: '/a.jpg', alt: 'A' }] })
1343
+ assert.match(html, /loading="lazy"/)
1344
+ })
1345
+
1346
+ test('gallery: lazy:false renders eager images', () => {
1347
+ const html = gallery({ images: [{ src: '/a.jpg', alt: 'A' }], lazy: false })
1348
+ assert.doesNotMatch(html, /loading="lazy"/)
1349
+ })
1350
+
1351
+ test('dropCap: escapes letter and content', () => {
1352
+ const html = dropCap({ letter: '<', content: '<script>alert(1)</script>' })
1353
+ assert.doesNotMatch(html, /<script>/)
1354
+ })
1355
+
1322
1356
  console.log('✓ All UI component tests passed')