@invisibleloop/pulse 0.6.2 → 0.7.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/package.json +1 -1
- package/public/.pulse-ui-version +1 -1
- package/scripts/build.js +42 -3
- package/scripts/strip-server.test.js +27 -0
- package/src/cli/scaffold.js +16 -0
- package/src/md/index.js +1 -1
- package/src/md/md.test.js +13 -5
package/package.json
CHANGED
package/public/.pulse-ui-version
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
0.
|
|
1
|
+
0.7.0
|
package/scripts/build.js
CHANGED
|
@@ -145,7 +145,38 @@ function isInsideString(source, pos) {
|
|
|
145
145
|
if (c === '"' || c === "'" || c === '`') { stack.push(c); i++; continue }
|
|
146
146
|
i++; continue
|
|
147
147
|
}
|
|
148
|
-
//
|
|
148
|
+
// Top level — handle comments and regex literals before string delimiters.
|
|
149
|
+
// Without this, a `"` inside a regex literal (e.g. /"/g) or a comment would
|
|
150
|
+
// be mistaken for an opening string delimiter, causing all subsequent keys to
|
|
151
|
+
// be treated as "inside a string" and never stripped.
|
|
152
|
+
if (c === '/') {
|
|
153
|
+
if (source[i + 1] === '/') { // line comment — skip to end of line
|
|
154
|
+
while (i < pos && source[i] !== '\n') i++
|
|
155
|
+
continue
|
|
156
|
+
}
|
|
157
|
+
if (source[i + 1] === '*') { // block comment — skip to */
|
|
158
|
+
const end = source.indexOf('*/', i + 2)
|
|
159
|
+
i = end < 0 ? pos : end + 2
|
|
160
|
+
continue
|
|
161
|
+
}
|
|
162
|
+
// Regex literal heuristic: / is a regex when not preceded by ) ] or alnum.
|
|
163
|
+
// Division (a / b) always follows one of those; regex follows operators, keywords, etc.
|
|
164
|
+
let prev = ''
|
|
165
|
+
for (let k = i - 1; k >= 0; k--) {
|
|
166
|
+
if (source[k] !== ' ' && source[k] !== '\t') { prev = source[k]; break }
|
|
167
|
+
}
|
|
168
|
+
if (!/[a-zA-Z0-9_$)\]]/.test(prev)) {
|
|
169
|
+
i++ // skip opening /
|
|
170
|
+
while (i < pos) {
|
|
171
|
+
if (source[i] === '\\') { i += 2; continue }
|
|
172
|
+
if (source[i] === '/') { i++; break } // closing /
|
|
173
|
+
if (source[i] === '\n') break // regex can't span lines
|
|
174
|
+
i++
|
|
175
|
+
}
|
|
176
|
+
while (i < pos && /[gimsuy]/.test(source[i])) i++ // skip flags
|
|
177
|
+
continue
|
|
178
|
+
}
|
|
179
|
+
}
|
|
149
180
|
if (c === '"' || c === "'" || c === '`') { stack.push(c) }
|
|
150
181
|
i++
|
|
151
182
|
}
|
|
@@ -379,7 +410,15 @@ const NAVIGATE_PATH = new URL('../src/runtime/navigate.js', import.meta.url).pat
|
|
|
379
410
|
|
|
380
411
|
const PAGES_DIR = path.join(ROOT, 'src', 'pages')
|
|
381
412
|
|
|
382
|
-
const bootstrapFiles = pages.
|
|
413
|
+
const bootstrapFiles = pages.flatMap(({ filePath }) => {
|
|
414
|
+
// Strip server-only keys/imports to check what's left for the client.
|
|
415
|
+
// Skip specs with no view — these are API routes or raw content specs
|
|
416
|
+
// (using render: instead of view:) that have no client-side code. Including
|
|
417
|
+
// them in the bundle fragments the shared runtime chunk unnecessarily.
|
|
418
|
+
const source = fs.readFileSync(filePath, 'utf8')
|
|
419
|
+
const stripped = stripServerOnlyImports(stripServerOnlyKeys(source))
|
|
420
|
+
if (!/\bview\s*:/.test(stripped)) return []
|
|
421
|
+
|
|
383
422
|
// Use the path relative to src/pages/ so nested pages get unique names.
|
|
384
423
|
// e.g. src/pages/api/products.js → 'api--products' (not 'products')
|
|
385
424
|
const relToPages = path.relative(PAGES_DIR, filePath)
|
|
@@ -404,7 +443,7 @@ if (root && !root.dataset.pulseMounted) {
|
|
|
404
443
|
export default spec
|
|
405
444
|
`)
|
|
406
445
|
|
|
407
|
-
return { filePath, bootstrapPath, name }
|
|
446
|
+
return [{ filePath, bootstrapPath, name }]
|
|
408
447
|
})
|
|
409
448
|
|
|
410
449
|
// ---------------------------------------------------------------------------
|
|
@@ -73,6 +73,18 @@ function isInsideString(source, pos) {
|
|
|
73
73
|
if (c === '"' || c === "'" || c === '`') { stack.push(c); i++; continue }
|
|
74
74
|
i++; continue
|
|
75
75
|
}
|
|
76
|
+
if (c === '/') {
|
|
77
|
+
if (source[i + 1] === '/') { while (i < pos && source[i] !== '\n') i++; continue }
|
|
78
|
+
if (source[i + 1] === '*') { const end = source.indexOf('*/', i + 2); i = end < 0 ? pos : end + 2; continue }
|
|
79
|
+
let prev = ''
|
|
80
|
+
for (let k = i - 1; k >= 0; k--) { if (source[k] !== ' ' && source[k] !== '\t') { prev = source[k]; break } }
|
|
81
|
+
if (!/[a-zA-Z0-9_$)\]]/.test(prev)) {
|
|
82
|
+
i++
|
|
83
|
+
while (i < pos) { if (source[i] === '\\') { i += 2; continue } if (source[i] === '/') { i++; break } if (source[i] === '\n') break; i++ }
|
|
84
|
+
while (i < pos && /[gimsuy]/.test(source[i])) i++
|
|
85
|
+
continue
|
|
86
|
+
}
|
|
87
|
+
}
|
|
76
88
|
if (c === '"' || c === "'" || c === '`') { stack.push(c) }
|
|
77
89
|
i++
|
|
78
90
|
}
|
|
@@ -257,6 +269,21 @@ test('server: inside a double-quoted string is not stripped',
|
|
|
257
269
|
'export default {\n route: \'/docs\',\n view: () => "<pre>server: { data: async () => {} }</pre>"\n}'
|
|
258
270
|
)
|
|
259
271
|
|
|
272
|
+
test('server/meta not fooled by regex literal containing double quote (/"/g)',
|
|
273
|
+
'const esc = s => s.replace(/"/g, \'"\')\nexport default {\n meta: { title: \'Page\', description: \'Desc\' },\n server: { data: async () => [] },\n view: () => \'\'\n}',
|
|
274
|
+
'const esc = s => s.replace(/"/g, \'"\')\nexport default {\n view: () => \'\'\n}'
|
|
275
|
+
)
|
|
276
|
+
|
|
277
|
+
test('server not fooled by line comment containing double quote',
|
|
278
|
+
'export default {\n // server: "comment"\n server: { data: async () => [] },\n view: () => \'\'\n}',
|
|
279
|
+
'export default {\n // server: "comment"\n view: () => \'\'\n}'
|
|
280
|
+
)
|
|
281
|
+
|
|
282
|
+
test('server not fooled by block comment containing double quote',
|
|
283
|
+
'export default {\n /* server: "comment" */\n server: { data: async () => [] },\n view: () => \'\'\n}',
|
|
284
|
+
'export default {\n /* server: "comment" */\n view: () => \'\'\n}'
|
|
285
|
+
)
|
|
286
|
+
|
|
260
287
|
test('meta object is stripped (client runtime never reads spec.meta)',
|
|
261
288
|
`export default {\n route: '/blog',\n meta: {\n title: async (ctx) => fetchPost(ctx).frontmatter.title,\n styles: ['/pulse-ui.css'],\n },\n view: () => ''\n}`,
|
|
262
289
|
`export default {\n route: '/blog',\n view: () => ''\n}`
|
package/src/cli/scaffold.js
CHANGED
|
@@ -95,6 +95,9 @@ ${port !== 3000 ? ` port: ${port},\n` : ''}}
|
|
|
95
95
|
|
|
96
96
|
// settings.json — hooks that enforce correct agent behaviour
|
|
97
97
|
write(targetDir, '.claude/settings.json', JSON.stringify({
|
|
98
|
+
enabledPlugins: {
|
|
99
|
+
'frontend-design@claude-plugins-official': true,
|
|
100
|
+
},
|
|
98
101
|
hooks: {
|
|
99
102
|
SessionStart: [
|
|
100
103
|
{
|
|
@@ -346,6 +349,19 @@ public/app.css ← global stylesheet
|
|
|
346
349
|
|
|
347
350
|
The MCP guide is the single source of truth. Follow it for all technical decisions, component usage, and the mandatory verification workflow.
|
|
348
351
|
|
|
352
|
+
## Design — do this before writing any HTML
|
|
353
|
+
|
|
354
|
+
Before planning any new page or component, use the \`frontend-design\` plugin to establish the visual direction:
|
|
355
|
+
|
|
356
|
+
1. Call the plugin's design tools with the page brief (what it does, who it's for, the tone)
|
|
357
|
+
2. The plugin will return decisions — colour palette, typography, spacing scale, component variants, layout approach
|
|
358
|
+
3. **Apply those decisions as CSS custom property overrides in \`public/app.css\`** — not as inline styles or class hacks
|
|
359
|
+
4. Document the chosen tokens at the top of \`public/app.css\` so every page stays consistent
|
|
360
|
+
|
|
361
|
+
The plugin informs *what* to build visually. The Pulse component library is *how* you build it. Use both — the plugin gives direction, the components implement it.
|
|
362
|
+
|
|
363
|
+
**Do not default to stock component styles.** If the design plugin gives you a colour, use it. If it suggests a layout pattern, follow it. Every page should reflect deliberate design decisions, not the component library defaults.
|
|
364
|
+
|
|
349
365
|
## Before writing any code
|
|
350
366
|
|
|
351
367
|
**Always present a plan and wait for the user to confirm before writing a single line of code.**
|
package/src/md/index.js
CHANGED
|
@@ -251,7 +251,7 @@ function renderBlocks(blocks) {
|
|
|
251
251
|
const task = item.match(/^\[([ xX])\] (.*)/)
|
|
252
252
|
if (task) {
|
|
253
253
|
const checked = task[1].toLowerCase() === 'x' ? ' checked' : ''
|
|
254
|
-
return `<li class="task-list-item"><input type="checkbox" disabled${checked}
|
|
254
|
+
return `<li class="task-list-item"><label class="ui-checkbox ui-checkbox--disabled"><input type="checkbox" class="ui-checkbox-input" disabled${checked}><span class="ui-checkbox-box" aria-hidden="true"></span><span class="ui-checkbox-label">${renderListItem(task[2])}</span></label></li>`
|
|
255
255
|
}
|
|
256
256
|
return `<li>${renderListItem(item)}</li>`
|
|
257
257
|
})
|
package/src/md/md.test.js
CHANGED
|
@@ -30,18 +30,18 @@ console.log('\nTask list (checkbox) rendering\n')
|
|
|
30
30
|
|
|
31
31
|
test('unchecked item renders disabled checkbox', () => {
|
|
32
32
|
const { html } = parseMd('- [ ] Buy milk')
|
|
33
|
-
assert(html.includes('<input type="checkbox" disabled>'), `Got: ${html}`)
|
|
33
|
+
assert(html.includes('<input type="checkbox" class="ui-checkbox-input" disabled>'), `Got: ${html}`)
|
|
34
34
|
assert(html.includes('Buy milk'), `Got: ${html}`)
|
|
35
35
|
})
|
|
36
36
|
|
|
37
37
|
test('checked item renders checked disabled checkbox', () => {
|
|
38
38
|
const { html } = parseMd('- [x] Done')
|
|
39
|
-
assert(html.includes('<input type="checkbox" disabled checked>'), `Got: ${html}`)
|
|
39
|
+
assert(html.includes('<input type="checkbox" class="ui-checkbox-input" disabled checked>'), `Got: ${html}`)
|
|
40
40
|
})
|
|
41
41
|
|
|
42
42
|
test('uppercase X is also treated as checked', () => {
|
|
43
43
|
const { html } = parseMd('- [X] Done')
|
|
44
|
-
assert(html.includes('<input type="checkbox" disabled checked>'), `Got: ${html}`)
|
|
44
|
+
assert(html.includes('<input type="checkbox" class="ui-checkbox-input" disabled checked>'), `Got: ${html}`)
|
|
45
45
|
})
|
|
46
46
|
|
|
47
47
|
test('task list items get task-list-item class', () => {
|
|
@@ -49,6 +49,14 @@ test('task list items get task-list-item class', () => {
|
|
|
49
49
|
assert(html.includes('class="task-list-item"'), `Got: ${html}`)
|
|
50
50
|
})
|
|
51
51
|
|
|
52
|
+
test('task item checkbox is wrapped in a label for accessibility', () => {
|
|
53
|
+
const { html } = parseMd('- [ ] Buy milk')
|
|
54
|
+
assert(html.includes('class="ui-checkbox ui-checkbox--disabled"'), `Got: ${html}`)
|
|
55
|
+
assert(html.includes('<input type="checkbox" class="ui-checkbox-input" disabled>'), `Got: ${html}`)
|
|
56
|
+
assert(html.includes('<span class="ui-checkbox-box" aria-hidden="true">'), `Got: ${html}`)
|
|
57
|
+
assert(html.includes('<span class="ui-checkbox-label">Buy milk</span>'), `Got: ${html}`)
|
|
58
|
+
})
|
|
59
|
+
|
|
52
60
|
test('task list ul gets contains-task-list class', () => {
|
|
53
61
|
const { html } = parseMd('- [ ] Item one\n- [x] Item two')
|
|
54
62
|
assert(html.includes('class="contains-task-list"'), `Got: ${html}`)
|
|
@@ -73,8 +81,8 @@ test('task item label text is rendered as inline markdown', () => {
|
|
|
73
81
|
|
|
74
82
|
test('multiple items preserve checked state independently', () => {
|
|
75
83
|
const { html } = parseMd('- [ ] Unchecked\n- [x] Checked\n- [ ] Also unchecked')
|
|
76
|
-
const checkedCount = (html.match(/checked>/g) || []).length
|
|
77
|
-
const uncheckedCount = (html.match(
|
|
84
|
+
const checkedCount = (html.match(/disabled checked>/g) || []).length
|
|
85
|
+
const uncheckedCount = (html.match(/disabled>/g) || []).length
|
|
78
86
|
assert(checkedCount === 1, `Expected 1 checked, got ${checkedCount}`)
|
|
79
87
|
assert(uncheckedCount === 2, `Expected 2 unchecked, got ${uncheckedCount}`)
|
|
80
88
|
})
|