@invisibleloop/pulse 0.12.3 → 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 +31 -17
- package/.claude/pulse-checklist.md +21 -1
- package/.github/workflows/publish.yml +1 -0
- package/.pulse-verified +1 -0
- package/CLAUDE.md +56 -15
- package/docs/.claude/commands/verify.md +4 -0
- package/docs/.claude/pulse-checklist.md +20 -2
- package/docs/public/.pulse-ui-version +1 -1
- package/docs/public/andypixel.css +237 -0
- package/docs/public/bookworms.css +422 -0
- package/docs/public/coutumes.css +584 -0
- package/docs/public/images/bookworms/hero-books.jpg +0 -0
- package/docs/public/images/bookworms/how-we-read-now.jpg +0 -0
- package/docs/public/images/bookworms/lantern-keepers.jpg +0 -0
- package/docs/public/images/bookworms/provincial-bookseller.jpg +0 -0
- package/docs/public/images/journey/178.jpg +0 -0
- package/docs/public/images/journey/29.jpg +0 -0
- package/docs/public/images/retrowheels/hero.jpg +0 -0
- package/docs/public/images/shop/devbook-pro.jpg +0 -0
- package/docs/public/images/suzukibento/1015.jpg +0 -0
- package/docs/public/images/suzukibento/1025.jpg +0 -0
- package/docs/public/images/suzukibento/1043.jpg +0 -0
- package/docs/public/images/suzukibento/1059.jpg +0 -0
- package/docs/public/images/suzukibento/1062.jpg +0 -0
- package/docs/public/images/suzukibento/1084.jpg +0 -0
- package/docs/public/images/swish/1469334031218-e382a71b716b.jpg +0 -0
- package/docs/public/images/swish/1483985988355-763728e1935b.jpg +0 -0
- package/docs/public/images/swish/1485518882345-15568b007407.jpg +0 -0
- package/docs/public/images/swish/1490481651871-ab68de25d43d.jpg +0 -0
- package/docs/public/images/swish/1496747611176-843222e1e57c.jpg +0 -0
- package/docs/public/images/swish/1502716119720-b23a93e5fe1b.jpg +0 -0
- package/docs/public/images/swish/1539109136881-3be0616acf4b.jpg +0 -0
- package/docs/public/images/swish/1551803091-e20673f15770.jpg +0 -0
- package/docs/public/images/swish/1558769132-cb1aea458c5e.jpg +0 -0
- package/docs/public/images/swish/1572804013309-59a88b7e92f1.jpg +0 -0
- package/docs/public/journey.css +528 -0
- package/docs/public/pulse-ui.css +1036 -22
- package/docs/public/retrowheels.css +402 -0
- package/docs/public/shop.css +235 -0
- package/docs/public/sparkys.css +349 -0
- package/docs/public/suzukibento.css +517 -0
- package/docs/public/swish.css +394 -0
- package/docs/server.js +19 -4
- package/docs/src/lib/nav.js +14 -3
- package/docs/src/pages/components/decorate.js +74 -0
- package/docs/src/pages/components/gallery.js +102 -0
- package/docs/src/pages/components/hero.js +1 -0
- package/docs/src/pages/components/marquee.js +76 -0
- package/docs/src/pages/components/photo-card.js +74 -0
- package/docs/src/pages/components/section.js +40 -1
- package/docs/src/pages/meta.js +26 -0
- package/docs/src/pages/templates/andypixel.js +260 -0
- package/docs/src/pages/templates/bookworms.js +227 -0
- package/docs/src/pages/templates/coutumes.js +226 -0
- package/docs/src/pages/templates/journey.js +186 -0
- package/docs/src/pages/templates/retrowheels.js +342 -0
- package/docs/src/pages/templates/shop.js +257 -0
- package/docs/src/pages/templates/sparkys.js +249 -0
- package/docs/src/pages/templates/suzukibento.js +200 -0
- package/docs/src/pages/templates/swish.js +313 -0
- package/docs/src/pages/templates.js +313 -2
- package/docs/src/pages/testing.js +2 -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/andypixel.css +237 -0
- package/examples/andypixel.js +258 -0
- package/examples/bookworms.css +422 -0
- package/examples/bookworms.js +225 -0
- package/examples/contact.js +1 -0
- package/examples/counter.js +1 -0
- package/examples/dev.server.js +17 -0
- package/examples/journey.css +528 -0
- package/examples/journey.js +186 -0
- package/examples/pricing.js +1 -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/examples/quiz.js +1 -0
- package/examples/retrowheels.css +402 -0
- package/examples/retrowheels.js +340 -0
- package/examples/shared.js +6 -0
- package/examples/suzukibento.css +513 -0
- package/examples/suzukibento.js +200 -0
- package/examples/swish.css +394 -0
- package/examples/swish.js +311 -0
- package/examples/todos.js +1 -0
- package/package.json +2 -3
- package/public/.pulse-ui-version +1 -1
- package/public/images/bookworms/hero-books.jpg +0 -0
- package/public/images/bookworms/how-we-read-now.jpg +0 -0
- package/public/images/bookworms/lantern-keepers.jpg +0 -0
- package/public/images/bookworms/provincial-bookseller.jpg +0 -0
- package/public/images/journey/178.jpg +0 -0
- package/public/images/journey/29.jpg +0 -0
- package/public/images/retrowheels/hero.jpg +0 -0
- package/public/images/shop/devbook-pro.jpg +0 -0
- package/public/images/suzukibento/1015.jpg +0 -0
- package/public/images/suzukibento/1025.jpg +0 -0
- package/public/images/suzukibento/1043.jpg +0 -0
- package/public/images/suzukibento/1059.jpg +0 -0
- package/public/images/suzukibento/1062.jpg +0 -0
- package/public/images/suzukibento/1084.jpg +0 -0
- package/public/images/swish/1469334031218-e382a71b716b.jpg +0 -0
- package/public/images/swish/1483985988355-763728e1935b.jpg +0 -0
- package/public/images/swish/1485518882345-15568b007407.jpg +0 -0
- package/public/images/swish/1490481651871-ab68de25d43d.jpg +0 -0
- package/public/images/swish/1496747611176-843222e1e57c.jpg +0 -0
- package/public/images/swish/1502716119720-b23a93e5fe1b.jpg +0 -0
- package/public/images/swish/1539109136881-3be0616acf4b.jpg +0 -0
- package/public/images/swish/1551803091-e20673f15770.jpg +0 -0
- package/public/images/swish/1558769132-cb1aea458c5e.jpg +0 -0
- package/public/images/swish/1572804013309-59a88b7e92f1.jpg +0 -0
- package/public/pulse-ui.css +1080 -22
- package/scripts/build.js +72 -18
- package/src/agent/checklist.md +22 -2
- package/src/agent/commands/verify.md +4 -0
- package/src/agent/guide-components.md +146 -8
- package/src/agent/guide-design-gallery.md +288 -0
- package/src/agent/guide-design-references.md +172 -0
- package/src/agent/guide-explore.md +313 -0
- package/src/agent/guide-styles.md +212 -11
- package/src/agent/guide-templates.md +223 -10
- package/src/agent/guide.md +9 -4
- package/src/agent/identity.md +2 -1
- package/src/agent/skills/verify/SKILL.md +64 -28
- package/src/agent/workflow.md +247 -61
- package/src/cli/dev.js +39 -14
- package/src/cli/fmt.js +113 -0
- package/src/cli/index.js +60 -33
- package/src/cli/logger.js +145 -0
- package/src/cli/scaffold.js +57 -3
- package/src/html.js +6 -1
- package/src/mcp/server.js +2561 -99
- package/src/mcp/validate-worker.js +49 -5
- package/src/runtime/ssr.js +4 -1
- package/src/server/index.js +29 -5
- package/src/spec/schema.js +21 -0
- package/src/spec/schema.test.js +28 -0
- package/src/testing/html.js +130 -4
- package/src/testing/testing.test.js +58 -0
- package/src/ui/banner.js +24 -9
- package/src/ui/brand-statement.js +58 -0
- package/src/ui/button.js +13 -2
- package/src/ui/card.js +20 -1
- package/src/ui/colophon.js +29 -0
- package/src/ui/contact-row.js +41 -0
- package/src/ui/cta.js +18 -4
- package/src/ui/decorate.js +55 -0
- package/src/ui/display-heading.js +44 -0
- package/src/ui/drop-cap.js +29 -0
- package/src/ui/editorial-hero.js +63 -0
- package/src/ui/entry-list.js +36 -0
- package/src/ui/feature.js +20 -3
- package/src/ui/footer.js +78 -9
- package/src/ui/gallery.js +56 -0
- package/src/ui/hero.js +68 -18
- package/src/ui/image-pair.js +72 -0
- package/src/ui/index.js +20 -0
- package/src/ui/input.js +1 -0
- package/src/ui/marquee.js +48 -0
- package/src/ui/nav.js +19 -9
- package/src/ui/photo-card.js +44 -0
- package/src/ui/product-card.js +65 -0
- package/src/ui/pullquote.js +14 -3
- package/src/ui/search.js +1 -0
- package/src/ui/section-label.js +38 -0
- package/src/ui/section.js +1 -1
- package/src/ui/service-card.js +47 -0
- package/src/ui/stat-grid.js +40 -0
- package/src/ui/stat.js +16 -2
- package/src/ui/testimonial.js +17 -6
- package/src/ui/textarea.js +1 -0
- package/src/ui/top-strip.js +47 -0
- package/src/ui/trust-list.js +46 -0
- package/src/ui/ui.test.js +35 -0
|
@@ -18,7 +18,23 @@ let mod
|
|
|
18
18
|
try {
|
|
19
19
|
mod = await import(tmpFile)
|
|
20
20
|
} catch (err) {
|
|
21
|
-
|
|
21
|
+
// Give a targeted message for common import-resolution failures
|
|
22
|
+
const msg = err.message || ''
|
|
23
|
+
if (msg.includes('Cannot find package') || msg.includes('Cannot find module') || msg.includes('ERR_MODULE_NOT_FOUND')) {
|
|
24
|
+
const bare = msg.match(/Cannot find (?:package|module) '([^']+)'/)?.[1]
|
|
25
|
+
if (bare && !bare.startsWith('.') && !bare.startsWith('/') && !bare.startsWith('@invisibleloop/pulse')) {
|
|
26
|
+
process.stdout.write(
|
|
27
|
+
`Invalid: spec has an unresolvable import — \`${bare}\` is not a valid module specifier.\n` +
|
|
28
|
+
` • Use relative paths for project-local files: \`'../components/nav.js'\`\n` +
|
|
29
|
+
` • Use \`'@invisibleloop/pulse/ui'\` for Pulse components\n` +
|
|
30
|
+
` • Bare paths like \`'src/...' \` or \`'components/...'\` are not valid — they are not packages`
|
|
31
|
+
)
|
|
32
|
+
} else {
|
|
33
|
+
process.stdout.write(`Invalid: could not import spec — ${msg.split('\n')[0]}`)
|
|
34
|
+
}
|
|
35
|
+
} else {
|
|
36
|
+
process.stdout.write(`Invalid: could not parse — ${err.message}`)
|
|
37
|
+
}
|
|
22
38
|
process.exit(0)
|
|
23
39
|
}
|
|
24
40
|
|
|
@@ -71,6 +87,18 @@ try {
|
|
|
71
87
|
warnings.push(`Heading order: page starts with h${levels[0]}, expected h1`)
|
|
72
88
|
}
|
|
73
89
|
|
|
90
|
+
// Duplicate h2s inside a single section — inner item headings should be h3
|
|
91
|
+
// A section with an h2 title and child items that also use h2 creates a flat
|
|
92
|
+
// outline that is semantically incorrect. Flag when a <section> contains 2+ h2s.
|
|
93
|
+
const sectionBlocks = [...html.matchAll(/<section[\s>][^]*?<\/section>/gi)]
|
|
94
|
+
for (const [sectionHtml] of sectionBlocks) {
|
|
95
|
+
const h2s = [...sectionHtml.matchAll(/<h2[\s>]/gi)]
|
|
96
|
+
if (h2s.length > 1) {
|
|
97
|
+
warnings.push(`Multiple h2 headings inside a single <section> — the first h2 should be the section title; inner item headings should use h3 (or deeper). A flat outline of h2s makes navigation harder for screen reader users.`)
|
|
98
|
+
break // one warning is enough
|
|
99
|
+
}
|
|
100
|
+
}
|
|
101
|
+
|
|
74
102
|
// Missing main landmark
|
|
75
103
|
if (!/id=["']main-content["']/.test(html)) {
|
|
76
104
|
warnings.push('Missing <main id="main-content"> — required for accessibility and Lighthouse landmark audit')
|
|
@@ -98,7 +126,7 @@ try {
|
|
|
98
126
|
}
|
|
99
127
|
|
|
100
128
|
// Unknown u-* utility classes — catch invented classes that have no CSS definition
|
|
101
|
-
const KNOWN_UTILS = new Set(['u-absolute','u-bg-accent','u-bg-surface','u-bg-surface2','u-block','u-border','u-border-b','u-border-t','u-flex','u-flex-1','u-flex-col','u-flex-wrap','u-font-bold','u-font-medium','u-font-normal','u-font-semibold','u-gap-1','u-gap-2','u-gap-3','u-gap-4','u-gap-5','u-gap-6','u-gap-8','u-hidden','u-inline','u-inline-block','u-items-center','u-items-end','u-items-start','u-items-stretch','u-justify-between','u-justify-center','u-justify-end','u-justify-start','u-leading-loose','u-leading-normal','u-leading-relaxed','u-leading-snug','u-leading-tight','u-max-w-lg','u-max-w-md','u-max-w-prose','u-max-w-sm','u-max-w-xl','u-max-w-xs','u-mb-0','u-mb-1','u-mb-10','u-mb-12','u-mb-16','u-mb-2','u-mb-3','u-mb-4','u-mb-5','u-mb-6','u-mb-8','u-ml-auto','u-mr-auto','u-mt-0','u-mt-1','u-mt-10','u-mt-12','u-mt-16','u-mt-2','u-mt-3','u-mt-4','u-mt-5','u-mt-6','u-mt-8','u-mx-auto','u-opacity-50','u-opacity-75','u-overflow-auto','u-overflow-hidden','u-p-0','u-p-1','u-p-2','u-p-3','u-p-4','u-p-5','u-p-6','u-p-8','u-px-0','u-px-2','u-px-3','u-px-4','u-px-5','u-px-6','u-px-8','u-py-0','u-py-2','u-py-3','u-py-4','u-py-5','u-py-6','u-py-8','u-relative','u-rounded','u-rounded-full','u-rounded-lg','u-rounded-md','u-rounded-xl','u-shrink-0','u-text-2xl','u-text-3xl','u-text-4xl','u-text-accent','u-text-balance','u-text-base','u-text-blue','u-text-center','u-text-default','u-text-green','u-text-left','u-text-lg','u-text-muted','u-text-red','u-text-right','u-text-sm','u-text-xl','u-text-xs','u-text-yellow','u-w-auto','u-w-full'])
|
|
129
|
+
const KNOWN_UTILS = new Set(['u-absolute','u-bg-accent','u-bg-surface','u-bg-surface2','u-block','u-border','u-border-b','u-border-t','u-flex','u-flex-1','u-flex-col','u-flex-wrap','u-font-bold','u-font-medium','u-font-normal','u-font-semibold','u-gap-1','u-gap-2','u-gap-3','u-gap-4','u-gap-5','u-gap-6','u-gap-8','u-hidden','u-inline','u-inline-block','u-items-center','u-items-end','u-items-start','u-items-stretch','u-justify-between','u-justify-center','u-justify-end','u-justify-start','u-leading-loose','u-leading-normal','u-leading-relaxed','u-leading-snug','u-leading-tight','u-max-w-lg','u-max-w-md','u-max-w-prose','u-max-w-sm','u-max-w-xl','u-max-w-xs','u-mb-0','u-mb-1','u-mb-10','u-mb-12','u-mb-16','u-mb-2','u-mb-3','u-mb-4','u-mb-5','u-mb-6','u-mb-8','u-ml-auto','u-mr-auto','u-mt-0','u-mt-1','u-mt-10','u-mt-12','u-mt-16','u-mt-2','u-mt-3','u-mt-4','u-mt-5','u-mt-6','u-mt-8','u-mx-auto','u-opacity-50','u-opacity-75','u-overflow-auto','u-overflow-hidden','u-p-0','u-p-1','u-p-2','u-p-3','u-p-4','u-p-5','u-p-6','u-p-8','u-px-0','u-px-2','u-px-3','u-px-4','u-px-5','u-px-6','u-px-8','u-py-0','u-py-2','u-py-3','u-py-4','u-py-5','u-py-6','u-py-8','u-relative','u-rounded','u-rounded-full','u-rounded-lg','u-rounded-md','u-rounded-xl','u-shrink-0','u-tabular-nums','u-text-2xl','u-text-3xl','u-text-4xl','u-text-accent','u-text-balance','u-text-base','u-text-blue','u-text-center','u-text-default','u-text-green','u-text-left','u-text-lg','u-text-muted','u-text-red','u-text-right','u-text-sm','u-text-xl','u-text-xs','u-text-yellow','u-w-auto','u-w-full'])
|
|
102
130
|
const usedUtils = new Set([...html.matchAll(/\bu-([\w-]+)/g)].map(m => 'u-' + m[1]))
|
|
103
131
|
const unknownUtils = [...usedUtils].filter(c => !KNOWN_UTILS.has(c))
|
|
104
132
|
if (unknownUtils.length) {
|
|
@@ -110,9 +138,25 @@ try {
|
|
|
110
138
|
warnings.push('input[type="file"] detected — use the fileUpload() component instead. It provides the drag-and-drop zone, correct styling, and pulse-ui.js integration. import { fileUpload } from \'@invisibleloop/pulse/ui\'')
|
|
111
139
|
}
|
|
112
140
|
|
|
113
|
-
// Unescaped apostrophes
|
|
114
|
-
|
|
115
|
-
|
|
141
|
+
// Unescaped apostrophes — only flag inside attribute values (attr='it's wrong'),
|
|
142
|
+
// not in text content (it's fine in body text). Match: attr='...apostrophe...'
|
|
143
|
+
// Pattern: an attribute assignment with single-quote delimiter containing a second single quote
|
|
144
|
+
if (/\s[\w-]+=\s*'[^'<>]*'[^']*'/.test(html)) {
|
|
145
|
+
warnings.push('Possible unescaped apostrophe in an HTML attribute value — use ' or ' or switch to double-quoted attributes')
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
// Inline <style> blocks in view — blocked by Pulse CSP nonce policy
|
|
149
|
+
if (/<style[\s>]/i.test(html)) {
|
|
150
|
+
warnings.push('Inline <style> block detected in view — Pulse\'s CSP (style-src with nonces) will silently block these styles at runtime. Move styles to public/app.css instead.')
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
// Hardcoded hex colours in view HTML — must use var(--ui-*) tokens instead.
|
|
154
|
+
// Matches: style="...color:#hex..." or style="...background:#hex..."
|
|
155
|
+
// Allows: href="#section-id" (anchor links are not colours)
|
|
156
|
+
const hexInStyle = [...html.matchAll(/style="[^"]*#[0-9a-fA-F]{3,6}[^"]*"/g)]
|
|
157
|
+
if (hexInStyle.length) {
|
|
158
|
+
const samples = [...new Set(hexInStyle.flatMap(m => [...m[0].matchAll(/#[0-9a-fA-F]{3,6}/g)].map(h => h[0])))].slice(0, 3)
|
|
159
|
+
warnings.push(`Hardcoded hex colour${samples.length > 1 ? 's' : ''} in view HTML (${samples.join(', ')}) — use var(--ui-*) CSS tokens instead. Move the hex value to public/theme.css as a named token, then reference it via var() in public/app.css or as a CSS class on the element.`)
|
|
116
160
|
}
|
|
117
161
|
} catch { /* view may depend on server data — skip HTML checks */ }
|
|
118
162
|
|
package/src/runtime/ssr.js
CHANGED
|
@@ -314,7 +314,10 @@ export function wrapDocument({ content, spec = {}, serverState = {}, storeState
|
|
|
314
314
|
const meta = spec.meta || {}
|
|
315
315
|
const title = meta.title || 'Pulse'
|
|
316
316
|
|
|
317
|
-
const bodyAttr =
|
|
317
|
+
const bodyAttr = [
|
|
318
|
+
meta.theme ? `data-theme="${esc(meta.theme)}"` : '',
|
|
319
|
+
meta.vibe ? `data-vibe="${esc(meta.vibe)}"` : '',
|
|
320
|
+
].filter(Boolean).map(a => ` ${a}`).join('')
|
|
318
321
|
|
|
319
322
|
const metaTags = [
|
|
320
323
|
meta.description ? `<meta name="description" content="${esc(meta.description)}">` : '',
|
package/src/server/index.js
CHANGED
|
@@ -39,6 +39,7 @@ const SECURITY_HEADERS = {
|
|
|
39
39
|
'Permissions-Policy': 'camera=(), microphone=(), geolocation=()',
|
|
40
40
|
'Cross-Origin-Opener-Policy': 'same-origin',
|
|
41
41
|
'Cross-Origin-Resource-Policy': 'same-origin',
|
|
42
|
+
'X-Pulse': version,
|
|
42
43
|
}
|
|
43
44
|
|
|
44
45
|
/**
|
|
@@ -498,7 +499,9 @@ export async function createServer(entries, options = {}) {
|
|
|
498
499
|
healthCheck = '/healthz', // path for health check endpoint, or false to disable
|
|
499
500
|
csp = {}, // extra CSP sources: { 'style-src': ['https://fonts.googleapis.com'] }
|
|
500
501
|
onError = (err, req, res) => defaultErrorHandler(err, req, res, dev),
|
|
501
|
-
onRequest
|
|
502
|
+
onRequest,
|
|
503
|
+
agentMode = false, // show agent-active indicator in banner
|
|
504
|
+
quiet = false, // suppress request logging
|
|
502
505
|
} = options
|
|
503
506
|
|
|
504
507
|
// Resolve URL entries → spec objects, auto-setting hydrate where needed
|
|
@@ -552,6 +555,15 @@ export async function createServer(entries, options = {}) {
|
|
|
552
555
|
let router = buildRouter(specs)
|
|
553
556
|
|
|
554
557
|
const server = http.createServer(async (req, res) => {
|
|
558
|
+
const reqStart = Date.now()
|
|
559
|
+
res.on('finish', () => {
|
|
560
|
+
if (!quiet) {
|
|
561
|
+
import('../cli/logger.js').then(({ request: logRequest }) => {
|
|
562
|
+
const url = new URL(req.url, `http://localhost:${port}`)
|
|
563
|
+
logRequest({ method: req.method, pathname: url.pathname, status: res.statusCode, ms: Date.now() - reqStart })
|
|
564
|
+
}).catch(() => {})
|
|
565
|
+
}
|
|
566
|
+
})
|
|
555
567
|
try {
|
|
556
568
|
// Parse URL — needed before health check and routing
|
|
557
569
|
const url = new URL(req.url, `http://localhost:${port}`)
|
|
@@ -753,7 +765,7 @@ export async function createServer(entries, options = {}) {
|
|
|
753
765
|
if (draining) return
|
|
754
766
|
draining = true
|
|
755
767
|
|
|
756
|
-
|
|
768
|
+
import('../cli/logger.js').then(({ shutdown: logShutdown }) => logShutdown()).catch(() => {})
|
|
757
769
|
|
|
758
770
|
// Stop background cache eviction timers
|
|
759
771
|
serverDataCache.stopEviction()
|
|
@@ -770,7 +782,7 @@ export async function createServer(entries, options = {}) {
|
|
|
770
782
|
|
|
771
783
|
// Force-exit after shutdownTimeout so a stuck request can't block a deploy
|
|
772
784
|
setTimeout(() => {
|
|
773
|
-
|
|
785
|
+
import('../cli/logger.js').then(({ shutdown: logShutdown }) => logShutdown(true)).catch(() => {})
|
|
774
786
|
process.exit(1)
|
|
775
787
|
}, shutdownTimeout).unref()
|
|
776
788
|
}
|
|
@@ -779,7 +791,16 @@ export async function createServer(entries, options = {}) {
|
|
|
779
791
|
process.on('SIGINT', shutdown)
|
|
780
792
|
|
|
781
793
|
server.listen(port, () => {
|
|
782
|
-
|
|
794
|
+
import('../cli/logger.js').then(({ banner }) => {
|
|
795
|
+
banner({
|
|
796
|
+
url: `http://localhost:${port}`,
|
|
797
|
+
version,
|
|
798
|
+
specs,
|
|
799
|
+
agentMode,
|
|
800
|
+
})
|
|
801
|
+
}).catch(() => {
|
|
802
|
+
console.log(`⚡ Pulse running at http://localhost:${port}`)
|
|
803
|
+
})
|
|
783
804
|
})
|
|
784
805
|
|
|
785
806
|
return {
|
|
@@ -981,7 +1002,10 @@ async function handleStreamResponse(spec, ctx, req, res, extraBody = '', dev = f
|
|
|
981
1002
|
meta.schema ? ` <script type="application/ld+json">${JSON.stringify(meta.schema)}</script>` : '',
|
|
982
1003
|
].filter(Boolean).join('\n')
|
|
983
1004
|
|
|
984
|
-
const bodyAttr =
|
|
1005
|
+
const bodyAttr = [
|
|
1006
|
+
meta.theme ? `data-theme="${escHtml(meta.theme)}"` : '',
|
|
1007
|
+
meta.vibe ? `data-vibe="${escHtml(meta.vibe)}"` : '',
|
|
1008
|
+
].filter(Boolean).map(a => ` ${a}`).join('')
|
|
985
1009
|
|
|
986
1010
|
const docOpen = `<!DOCTYPE html>
|
|
987
1011
|
<html lang="en">
|
package/src/spec/schema.js
CHANGED
|
@@ -376,6 +376,22 @@ export function validateSpec(spec) {
|
|
|
376
376
|
warnings.push('spec.hydrate is set manually — it is auto-derived by the framework from the URL entry passed to createServer. Remove it from the spec.')
|
|
377
377
|
}
|
|
378
378
|
|
|
379
|
+
// meta — warn on unrecognised fields (e.g. meta.vibe is silently ignored; the correct
|
|
380
|
+
// field is top-level spec.meta.vibe which IS supported, but an agent might write
|
|
381
|
+
// meta: { vibe: 'bold' } expecting it to work when the real effect comes from
|
|
382
|
+
// the framework reading spec.meta.vibe directly — this check catches unknown keys)
|
|
383
|
+
if (spec.meta && typeof spec.meta === 'object' && !Array.isArray(spec.meta)) {
|
|
384
|
+
const KNOWN_META_KEYS = new Set([
|
|
385
|
+
'title', 'description', 'styles', 'scripts', 'theme', 'vibe',
|
|
386
|
+
'ogTitle', 'ogDescription', 'ogImage', 'canonical', 'schema',
|
|
387
|
+
'robots', 'viewport', 'charset', 'lang',
|
|
388
|
+
])
|
|
389
|
+
const unknown = Object.keys(spec.meta).filter(k => typeof spec.meta[k] !== 'function' && !KNOWN_META_KEYS.has(k))
|
|
390
|
+
if (unknown.length) {
|
|
391
|
+
warnings.push(`spec.meta has unrecognised field${unknown.length > 1 ? 's' : ''}: ${unknown.map(k => `"${k}"`).join(', ')} — these are silently ignored by the framework. Known fields: ${[...KNOWN_META_KEYS].join(', ')}.`)
|
|
392
|
+
}
|
|
393
|
+
}
|
|
394
|
+
|
|
379
395
|
// meta quality
|
|
380
396
|
if (!spec.meta) {
|
|
381
397
|
warnings.push('spec.meta is missing — add at minimum title and description for SEO')
|
|
@@ -389,6 +405,11 @@ export function validateSpec(spec) {
|
|
|
389
405
|
} else if (typeof desc === 'string' && desc.trim() === 'Built with Pulse') {
|
|
390
406
|
warnings.push('spec.meta.description is still the default "Built with Pulse" — replace it with a real description')
|
|
391
407
|
}
|
|
408
|
+
// If meta.theme is not set (and not a function), Pulse defaults to dark — warn so agents
|
|
409
|
+
// don't accidentally ship a dark-themed page when they intended light.
|
|
410
|
+
if (typeof spec.meta.theme !== 'string' && typeof spec.meta.theme !== 'function') {
|
|
411
|
+
warnings.push('spec.meta.theme is not set — Pulse defaults to dark. Add meta.theme: \'dark\' (or \'light\') to make the intent explicit and suppress this warning.')
|
|
412
|
+
}
|
|
392
413
|
}
|
|
393
414
|
|
|
394
415
|
return {
|
package/src/spec/schema.test.js
CHANGED
|
@@ -384,6 +384,34 @@ test('validateSpec still warns when hydrate is set manually in source', () => {
|
|
|
384
384
|
assert(warnings.some(w => w.includes('hydrate')), 'Expected hydrate warning from validateSpec')
|
|
385
385
|
})
|
|
386
386
|
|
|
387
|
+
// meta.theme warnings
|
|
388
|
+
test('warns when meta.theme is not set', () => {
|
|
389
|
+
const { warnings } = validateSpec({
|
|
390
|
+
route: '/entry',
|
|
391
|
+
view: () => '<main id="main-content"></main>',
|
|
392
|
+
meta: { title: 'Entry', description: 'A page' },
|
|
393
|
+
})
|
|
394
|
+
assert(warnings.some(w => w.includes('meta.theme')), 'Expected meta.theme warning')
|
|
395
|
+
})
|
|
396
|
+
|
|
397
|
+
test('does not warn when meta.theme is a string', () => {
|
|
398
|
+
const { warnings } = validateSpec({
|
|
399
|
+
route: '/entry',
|
|
400
|
+
view: () => '<main id="main-content"></main>',
|
|
401
|
+
meta: { title: 'Entry', description: 'A page', theme: 'dark' },
|
|
402
|
+
})
|
|
403
|
+
assert(!warnings.some(w => w.includes('meta.theme')), 'Unexpected meta.theme warning when theme is set')
|
|
404
|
+
})
|
|
405
|
+
|
|
406
|
+
test('does not warn when meta.theme is a function', () => {
|
|
407
|
+
const { warnings } = validateSpec({
|
|
408
|
+
route: '/entry',
|
|
409
|
+
view: () => '<main id="main-content"></main>',
|
|
410
|
+
meta: { title: 'Entry', description: 'A page', theme: (ctx) => ctx.brand?.theme ?? 'dark' },
|
|
411
|
+
})
|
|
412
|
+
assert(!warnings.some(w => w.includes('meta.theme')), 'Unexpected meta.theme warning when theme is a function')
|
|
413
|
+
})
|
|
414
|
+
|
|
387
415
|
// ---------------------------------------------------------------------------
|
|
388
416
|
|
|
389
417
|
console.log(`\n${passed + failed} tests: ${passed} passed, ${failed} failed\n`)
|
package/src/testing/html.js
CHANGED
|
@@ -3,7 +3,7 @@
|
|
|
3
3
|
*
|
|
4
4
|
* No dependencies. Handles common HTML patterns well enough for test assertions.
|
|
5
5
|
* Supports: tag, .class, #id, [attr], [attr="value"] and combinations thereof.
|
|
6
|
-
*
|
|
6
|
+
* Supports descendant combinators (div p, .parent span) — findFirst/findAll walk the ancestor stack.
|
|
7
7
|
*/
|
|
8
8
|
|
|
9
9
|
// ---------------------------------------------------------------------------
|
|
@@ -152,14 +152,64 @@ export function tokenize(html) {
|
|
|
152
152
|
// Selector parsing
|
|
153
153
|
// ---------------------------------------------------------------------------
|
|
154
154
|
|
|
155
|
+
/**
|
|
156
|
+
* Split a selector on whitespace that is NOT inside [...] attribute brackets.
|
|
157
|
+
* "span[aria-label="foo bar"]" → ["span[aria-label="foo bar"]"] (single part)
|
|
158
|
+
* "div p span" → ["div", "p", "span"]
|
|
159
|
+
*
|
|
160
|
+
* @param {string} selector
|
|
161
|
+
* @returns {string[]}
|
|
162
|
+
*/
|
|
163
|
+
function splitDescendant(selector) {
|
|
164
|
+
const parts = []
|
|
165
|
+
let current = ''
|
|
166
|
+
let inBracket = false
|
|
167
|
+
let inQuote = false
|
|
168
|
+
let quoteChar = ''
|
|
169
|
+
|
|
170
|
+
for (let i = 0; i < selector.length; i++) {
|
|
171
|
+
const c = selector[i]
|
|
172
|
+
if (inQuote) {
|
|
173
|
+
current += c
|
|
174
|
+
if (c === quoteChar) inQuote = false
|
|
175
|
+
} else if (inBracket) {
|
|
176
|
+
current += c
|
|
177
|
+
if (c === '"' || c === "'") { inQuote = true; quoteChar = c }
|
|
178
|
+
else if (c === ']') inBracket = false
|
|
179
|
+
} else if (c === '[') {
|
|
180
|
+
inBracket = true
|
|
181
|
+
current += c
|
|
182
|
+
} else if (c === ' ') {
|
|
183
|
+
if (current.trim()) parts.push(current.trim())
|
|
184
|
+
current = ''
|
|
185
|
+
} else {
|
|
186
|
+
current += c
|
|
187
|
+
}
|
|
188
|
+
}
|
|
189
|
+
if (current.trim()) parts.push(current.trim())
|
|
190
|
+
return parts
|
|
191
|
+
}
|
|
192
|
+
|
|
155
193
|
/**
|
|
156
194
|
* Parse a simple CSS selector string.
|
|
157
195
|
* Supports: tag .class #id [attr] [attr="value"] and combinations.
|
|
196
|
+
* Also supports descendant combinators: parent child grandchild
|
|
158
197
|
*
|
|
159
198
|
* @param {string} selector
|
|
160
|
-
* @returns {{ tag: string|null, id: string|null, classes: string[], attrs: Array<{name,value}> }}
|
|
199
|
+
* @returns {{ tag: string|null, id: string|null, classes: string[], attrs: Array<{name,value}> }} | Array of same
|
|
161
200
|
*/
|
|
162
201
|
export function parseSelector(selector) {
|
|
202
|
+
const trimmed = selector.trim()
|
|
203
|
+
const parts = splitDescendant(trimmed)
|
|
204
|
+
|
|
205
|
+
if (parts.length > 1) {
|
|
206
|
+
return parts.map(parseSingleSelector)
|
|
207
|
+
}
|
|
208
|
+
|
|
209
|
+
return parseSingleSelector(trimmed)
|
|
210
|
+
}
|
|
211
|
+
|
|
212
|
+
function parseSingleSelector(selector) {
|
|
163
213
|
const result = { tag: null, id: null, classes: [], attrs: [] }
|
|
164
214
|
let s = selector.trim()
|
|
165
215
|
|
|
@@ -255,29 +305,105 @@ export function extractText(tokens) {
|
|
|
255
305
|
|
|
256
306
|
/**
|
|
257
307
|
* Find the first token matching selector.
|
|
308
|
+
* Supports descendant combinators: parent child
|
|
258
309
|
* @param {Array} tokens
|
|
259
310
|
* @param {string} selector
|
|
260
311
|
* @returns {{ token, index } | null}
|
|
261
312
|
*/
|
|
262
313
|
export function findFirst(tokens, selector) {
|
|
263
314
|
const sel = parseSelector(selector)
|
|
315
|
+
|
|
316
|
+
// Simple selector (no spaces)
|
|
317
|
+
if (!Array.isArray(sel)) {
|
|
318
|
+
for (let i = 0; i < tokens.length; i++) {
|
|
319
|
+
if (matchesToken(tokens[i], sel)) return { token: tokens[i], index: i }
|
|
320
|
+
}
|
|
321
|
+
return null
|
|
322
|
+
}
|
|
323
|
+
|
|
324
|
+
// Descendant selector (array of selectors)
|
|
325
|
+
const target = sel[sel.length - 1] // Last selector is the element we're looking for
|
|
326
|
+
const ancestors = sel.slice(0, -1) // Earlier selectors are ancestor requirements
|
|
327
|
+
|
|
264
328
|
for (let i = 0; i < tokens.length; i++) {
|
|
265
|
-
if (matchesToken(tokens[i],
|
|
329
|
+
if (matchesToken(tokens[i], target)) {
|
|
330
|
+
// Check if all ancestor requirements are met
|
|
331
|
+
if (hasAncestors(tokens, i, ancestors)) {
|
|
332
|
+
return { token: tokens[i], index: i }
|
|
333
|
+
}
|
|
334
|
+
}
|
|
266
335
|
}
|
|
267
336
|
return null
|
|
268
337
|
}
|
|
269
338
|
|
|
339
|
+
/**
|
|
340
|
+
* Check if a token at index has all required ancestors.
|
|
341
|
+
* @param {Array} tokens
|
|
342
|
+
* @param {number} index - index of the target token
|
|
343
|
+
* @param {Array} ancestorSels - array of ancestor selectors to match
|
|
344
|
+
*/
|
|
345
|
+
function hasAncestors(tokens, index, ancestorSels) {
|
|
346
|
+
if (ancestorSels.length === 0) return true
|
|
347
|
+
|
|
348
|
+
// Build a stack of open tags from start to index
|
|
349
|
+
const stack = []
|
|
350
|
+
for (let i = 0; i < index; i++) {
|
|
351
|
+
if (tokens[i].type === 'open') {
|
|
352
|
+
stack.push(tokens[i])
|
|
353
|
+
} else if (tokens[i].type === 'close') {
|
|
354
|
+
stack.pop()
|
|
355
|
+
}
|
|
356
|
+
}
|
|
357
|
+
|
|
358
|
+
// Now check if the stack contains all required ancestors in order
|
|
359
|
+
// We need to find each ancestor selector somewhere in the stack, in order
|
|
360
|
+
let stackIdx = 0
|
|
361
|
+
for (const ancestorSel of ancestorSels) {
|
|
362
|
+
let found = false
|
|
363
|
+
while (stackIdx < stack.length) {
|
|
364
|
+
if (matchesToken(stack[stackIdx], ancestorSel)) {
|
|
365
|
+
found = true
|
|
366
|
+
stackIdx++ // Move past this one for the next ancestor search
|
|
367
|
+
break
|
|
368
|
+
}
|
|
369
|
+
stackIdx++
|
|
370
|
+
}
|
|
371
|
+
if (!found) return false
|
|
372
|
+
}
|
|
373
|
+
|
|
374
|
+
return true
|
|
375
|
+
}
|
|
376
|
+
|
|
270
377
|
/**
|
|
271
378
|
* Find all tokens matching selector.
|
|
379
|
+
* Supports descendant combinators: parent child
|
|
272
380
|
* @param {Array} tokens
|
|
273
381
|
* @param {string} selector
|
|
274
382
|
* @returns {Array<{ token, index }>}
|
|
275
383
|
*/
|
|
276
384
|
export function findAll(tokens, selector) {
|
|
277
385
|
const sel = parseSelector(selector)
|
|
386
|
+
|
|
387
|
+
// Simple selector (no spaces)
|
|
388
|
+
if (!Array.isArray(sel)) {
|
|
389
|
+
const results = []
|
|
390
|
+
for (let i = 0; i < tokens.length; i++) {
|
|
391
|
+
if (matchesToken(tokens[i], sel)) results.push({ token: tokens[i], index: i })
|
|
392
|
+
}
|
|
393
|
+
return results
|
|
394
|
+
}
|
|
395
|
+
|
|
396
|
+
// Descendant selector (array of selectors)
|
|
397
|
+
const target = sel[sel.length - 1]
|
|
398
|
+
const ancestors = sel.slice(0, -1)
|
|
399
|
+
|
|
278
400
|
const results = []
|
|
279
401
|
for (let i = 0; i < tokens.length; i++) {
|
|
280
|
-
if (matchesToken(tokens[i],
|
|
402
|
+
if (matchesToken(tokens[i], target)) {
|
|
403
|
+
if (hasAncestors(tokens, i, ancestors)) {
|
|
404
|
+
results.push({ token: tokens[i], index: i })
|
|
405
|
+
}
|
|
406
|
+
}
|
|
281
407
|
}
|
|
282
408
|
return results
|
|
283
409
|
}
|
|
@@ -214,6 +214,24 @@ await test('matches attribute selector [attr="value"] (negative)', async () => {
|
|
|
214
214
|
assert(!result.has('input[type="password"]'), 'Expected no password input')
|
|
215
215
|
})
|
|
216
216
|
|
|
217
|
+
await test('matches attribute selector [attr="value with spaces"]', async () => {
|
|
218
|
+
const spec = {
|
|
219
|
+
route: '/test-attr-spaces',
|
|
220
|
+
view: () => `<div><span aria-label="3 items in cart">3</span></div>`,
|
|
221
|
+
}
|
|
222
|
+
const result = renderSync(spec)
|
|
223
|
+
assert(result.has('span[aria-label="3 items in cart"]'), 'Expected span with aria-label containing spaces')
|
|
224
|
+
})
|
|
225
|
+
|
|
226
|
+
await test('descendant selector not confused by spaces in attr value', async () => {
|
|
227
|
+
const spec = {
|
|
228
|
+
route: '/test-desc-attr',
|
|
229
|
+
view: () => `<nav><a href="/cart" aria-label="3 items in cart">Cart</a></nav>`,
|
|
230
|
+
}
|
|
231
|
+
const result = renderSync(spec)
|
|
232
|
+
assert(result.has('nav a[aria-label="3 items in cart"]'), 'Expected descendant with spaced attr value')
|
|
233
|
+
})
|
|
234
|
+
|
|
217
235
|
// ---------------------------------------------------------------------------
|
|
218
236
|
|
|
219
237
|
console.log('\nresult.find() and result.get()\n')
|
|
@@ -444,6 +462,46 @@ await test('renderSync returns raw html string', async () => {
|
|
|
444
462
|
assert(typeof result.html === 'string', 'Expected html to be a string')
|
|
445
463
|
})
|
|
446
464
|
|
|
465
|
+
console.log('\nDescendant selectors\n')
|
|
466
|
+
|
|
467
|
+
await test('descendant selector finds nested elements', async () => {
|
|
468
|
+
const spec = {
|
|
469
|
+
route: '/list',
|
|
470
|
+
state: {},
|
|
471
|
+
view: () => `<div><ul class="items"><li>A</li><li>B</li></ul><ul class="other"><li>X</li></ul></div>`,
|
|
472
|
+
}
|
|
473
|
+
const result = renderSync(spec)
|
|
474
|
+
assertEqual(result.count('.items li'), 2)
|
|
475
|
+
assertEqual(result.count('.other li'), 1)
|
|
476
|
+
assertEqual(result.count('ul li'), 3)
|
|
477
|
+
})
|
|
478
|
+
|
|
479
|
+
await test('descendant selector with multiple levels', async () => {
|
|
480
|
+
const spec = {
|
|
481
|
+
route: '/table',
|
|
482
|
+
state: {},
|
|
483
|
+
view: () => `<table><thead><tr><th>Name</th></tr></thead><tbody><tr><td>Alice</td></tr><tr><td>Bob</td></tr></tbody></table>`,
|
|
484
|
+
}
|
|
485
|
+
const result = renderSync(spec)
|
|
486
|
+
assertEqual(result.count('tbody tr'), 2)
|
|
487
|
+
assertEqual(result.count('thead tr'), 1)
|
|
488
|
+
const cells = result.findAll('tbody td')
|
|
489
|
+
assertEqual(cells.length, 2)
|
|
490
|
+
assertEqual(cells[0].text, 'Alice')
|
|
491
|
+
assertEqual(cells[1].text, 'Bob')
|
|
492
|
+
})
|
|
493
|
+
|
|
494
|
+
await test('descendant selector returns empty for no matches', async () => {
|
|
495
|
+
const spec = {
|
|
496
|
+
route: '/empty',
|
|
497
|
+
state: {},
|
|
498
|
+
view: () => `<div class="container"><span>text</span></div>`,
|
|
499
|
+
}
|
|
500
|
+
const result = renderSync(spec)
|
|
501
|
+
assertEqual(result.count('.container li'), 0)
|
|
502
|
+
assertEqual(result.find('.container li'), null)
|
|
503
|
+
})
|
|
504
|
+
|
|
447
505
|
// ---------------------------------------------------------------------------
|
|
448
506
|
|
|
449
507
|
console.log(`\n${passed + failed} tests: ${passed} passed, ${failed} failed\n`)
|
package/src/ui/banner.js
CHANGED
|
@@ -4,10 +4,13 @@
|
|
|
4
4
|
* Full-width announcement bar — sits above the nav for promotions,
|
|
5
5
|
* notices, or launch announcements.
|
|
6
6
|
*
|
|
7
|
-
* @param {object}
|
|
8
|
-
* @param {string}
|
|
9
|
-
* @param {'info'|'promo'|'warning'} opts.variant
|
|
10
|
-
* @param {
|
|
7
|
+
* @param {object} opts
|
|
8
|
+
* @param {string} opts.content - Raw HTML slot
|
|
9
|
+
* @param {'info'|'promo'|'warning'} opts.variant - Visual style (default: 'info')
|
|
10
|
+
* @param {'top'|'bottom'} opts.position - 'bottom' fixes the bar to the bottom of the viewport
|
|
11
|
+
* @param {boolean} opts.dismissible - Show a close (×) button
|
|
12
|
+
* @param {string} opts.dismissEvent - Mutation name fired on close (default: 'dismissBanner')
|
|
13
|
+
* @param {string} opts.class
|
|
11
14
|
*/
|
|
12
15
|
|
|
13
16
|
import { escHtml as e } from '../html.js'
|
|
@@ -15,12 +18,24 @@ import { escHtml as e } from '../html.js'
|
|
|
15
18
|
const VARIANTS = new Set(['info', 'promo', 'warning'])
|
|
16
19
|
|
|
17
20
|
export function banner({
|
|
18
|
-
content
|
|
19
|
-
variant
|
|
20
|
-
|
|
21
|
+
content = '',
|
|
22
|
+
variant = 'info',
|
|
23
|
+
position = 'top',
|
|
24
|
+
dismissible = false,
|
|
25
|
+
dismissEvent = 'dismissBanner',
|
|
26
|
+
class: cls = '',
|
|
21
27
|
} = {}) {
|
|
22
28
|
if (!VARIANTS.has(variant)) variant = 'info'
|
|
23
|
-
const classes = [
|
|
29
|
+
const classes = [
|
|
30
|
+
'ui-banner',
|
|
31
|
+
`ui-banner--${variant}`,
|
|
32
|
+
position === 'bottom' && 'ui-banner--bottom',
|
|
33
|
+
cls,
|
|
34
|
+
].filter(Boolean).join(' ')
|
|
24
35
|
|
|
25
|
-
|
|
36
|
+
const closeBtn = dismissible
|
|
37
|
+
? `<button class="ui-banner-close" data-event="${e(dismissEvent)}" aria-label="Close announcement">×</button>`
|
|
38
|
+
: ''
|
|
39
|
+
|
|
40
|
+
return `<div class="${e(classes)}" role="banner">${content}${closeBtn}</div>`
|
|
26
41
|
}
|
|
@@ -0,0 +1,58 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Pulse UI — Brand Statement
|
|
3
|
+
*
|
|
4
|
+
* Full-width centred editorial statement — large serif or display type used
|
|
5
|
+
* for brand voice paragraphs between visual sections. This is not a pull-quote
|
|
6
|
+
* (no attribution border) — it is a first-person brand declaration.
|
|
7
|
+
*
|
|
8
|
+
* Typical use: "pieces that are both eclectic and everyday. Subtly precious,
|
|
9
|
+
* expressive, and refined…"
|
|
10
|
+
*
|
|
11
|
+
* @param {object} opts
|
|
12
|
+
* @param {string} opts.text - Statement text (can include basic HTML like <em>, <br>)
|
|
13
|
+
* @param {string} opts.attribution - Optional small attribution or tagline below
|
|
14
|
+
* @param {'serif'|'display'|'sans'} opts.font - Font style (default: 'serif')
|
|
15
|
+
* @param {'sm'|'md'|'lg'} opts.size - Text size (default: 'md')
|
|
16
|
+
* @param {'left'|'center'|'right'} opts.align - Text alignment (default: 'center')
|
|
17
|
+
* @param {string} opts.maxWidth - CSS max-width value (default: '860px')
|
|
18
|
+
* @param {string} opts.class
|
|
19
|
+
*/
|
|
20
|
+
|
|
21
|
+
import { escHtml as e } from '../html.js'
|
|
22
|
+
|
|
23
|
+
const FONTS = new Set(['serif', 'display', 'sans'])
|
|
24
|
+
const SIZES = new Set(['sm', 'md', 'lg'])
|
|
25
|
+
const ALIGNS = new Set(['left', 'center', 'right'])
|
|
26
|
+
|
|
27
|
+
export function brandStatement({
|
|
28
|
+
text = '',
|
|
29
|
+
attribution = '',
|
|
30
|
+
font = 'serif',
|
|
31
|
+
size = 'md',
|
|
32
|
+
align = 'center',
|
|
33
|
+
maxWidth = '',
|
|
34
|
+
class: cls = '',
|
|
35
|
+
} = {}) {
|
|
36
|
+
if (!FONTS.has(font)) font = 'serif'
|
|
37
|
+
if (!SIZES.has(size)) size = 'md'
|
|
38
|
+
if (!ALIGNS.has(align)) align = 'center'
|
|
39
|
+
|
|
40
|
+
const classes = [
|
|
41
|
+
'ui-brand-statement',
|
|
42
|
+
font !== 'serif' && `ui-brand-statement--${font}`,
|
|
43
|
+
size !== 'md' && `ui-brand-statement--${size}`,
|
|
44
|
+
align !== 'center' && `ui-brand-statement--${align}`,
|
|
45
|
+
cls,
|
|
46
|
+
].filter(Boolean).join(' ')
|
|
47
|
+
|
|
48
|
+
const innerStyle = maxWidth
|
|
49
|
+
? ` style="max-width:${e(typeof maxWidth === 'number' ? `${maxWidth}px` : maxWidth)}"`
|
|
50
|
+
: ''
|
|
51
|
+
|
|
52
|
+
return `<div class="${e(classes)}">
|
|
53
|
+
<div class="ui-brand-statement-inner"${innerStyle}>
|
|
54
|
+
<p class="ui-brand-statement-text">${text}</p>
|
|
55
|
+
${attribution ? `<p class="ui-brand-statement-attr">${e(attribution)}</p>` : ''}
|
|
56
|
+
</div>
|
|
57
|
+
</div>`
|
|
58
|
+
}
|
package/src/ui/button.js
CHANGED
|
@@ -5,7 +5,7 @@
|
|
|
5
5
|
* All visual variation goes through CSS modifier classes — never inline styles.
|
|
6
6
|
*
|
|
7
7
|
* @param {object} opts
|
|
8
|
-
* @param {string} opts.label - Visible text
|
|
8
|
+
* @param {string} opts.label - Visible text — HTML is escaped. For SVG/icon content use opts.icon or opts.iconAfter.
|
|
9
9
|
* @param {'primary'|'secondary'|'ghost'|'danger'} opts.variant
|
|
10
10
|
* @param {'sm'|'md'|'lg'} opts.size
|
|
11
11
|
* @param {string} opts.href - Renders as <a> when set
|
|
@@ -20,7 +20,7 @@
|
|
|
20
20
|
|
|
21
21
|
import { escHtml as e } from '../html.js'
|
|
22
22
|
|
|
23
|
-
const VARIANTS = new Set(['primary', 'secondary', 'ghost', 'danger'])
|
|
23
|
+
const VARIANTS = new Set(['primary', 'secondary', 'ghost', 'ghost-light', 'danger'])
|
|
24
24
|
const SIZES = new Set(['sm', 'md', 'lg'])
|
|
25
25
|
|
|
26
26
|
export function button({
|
|
@@ -39,6 +39,16 @@ export function button({
|
|
|
39
39
|
if (!VARIANTS.has(variant)) variant = 'primary'
|
|
40
40
|
if (!SIZES.has(size)) size = 'md'
|
|
41
41
|
|
|
42
|
+
// Warn if href passed via attrs instead of the href prop (produces invalid <button href>)
|
|
43
|
+
if (typeof window === 'undefined' && !href && attrs.href) {
|
|
44
|
+
console.warn('[Pulse button] href passed via attrs produces invalid HTML (<button href>). Use button({ href: "..." }) instead — it renders an <a> automatically.')
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
// Warn if label contains HTML — it will be escaped. Use icon/iconAfter for SVG content.
|
|
48
|
+
if (typeof window === 'undefined' && label && label.includes('<')) {
|
|
49
|
+
console.warn('[Pulse button] label is HTML-escaped — SVG/HTML passed as label will render as text. Use icon: svgString or iconAfter: svgString instead.')
|
|
50
|
+
}
|
|
51
|
+
|
|
42
52
|
const classes = [
|
|
43
53
|
'ui-btn',
|
|
44
54
|
`ui-btn--${variant}`,
|
|
@@ -59,6 +69,7 @@ export function button({
|
|
|
59
69
|
}
|
|
60
70
|
|
|
61
71
|
const attrsStr = Object.entries(attrs)
|
|
72
|
+
.filter(([, v]) => v !== undefined)
|
|
62
73
|
.map(([k, v]) => ` ${e(k)}="${e(String(v))}"`)
|
|
63
74
|
.join('')
|
|
64
75
|
|