@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
package/src/ui/card.js
CHANGED
|
@@ -11,20 +11,39 @@
|
|
|
11
11
|
* @param {string} opts.content - HTML string for the card body
|
|
12
12
|
* @param {string} opts.footer - HTML string for the card footer (optional)
|
|
13
13
|
* @param {boolean} opts.flush - Remove internal padding (for full-bleed content)
|
|
14
|
+
* @param {'default'|'elevated'|'bordered'|'flat'|'glass'|'tinted'} opts.variant
|
|
15
|
+
* Visual style:
|
|
16
|
+
* - 'default' — standard surface with subtle shadow (same as no variant)
|
|
17
|
+
* - 'elevated' — stronger shadow, lifts above the page
|
|
18
|
+
* - 'bordered' — no shadow, prominent border
|
|
19
|
+
* - 'flat' — no shadow, no border, background only
|
|
20
|
+
* - 'glass' — frosted-glass effect with backdrop blur
|
|
21
|
+
* - 'tinted' — accent-tinted background, no shadow
|
|
14
22
|
* @param {string} opts.class
|
|
15
23
|
*/
|
|
16
24
|
|
|
17
25
|
import { escHtml as e } from '../html.js'
|
|
18
26
|
|
|
27
|
+
const VARIANTS = new Set(['default', 'elevated', 'bordered', 'flat', 'glass', 'tinted'])
|
|
28
|
+
|
|
19
29
|
export function card({
|
|
20
30
|
title = '',
|
|
21
31
|
level = 3,
|
|
22
32
|
content = '',
|
|
23
33
|
footer = '',
|
|
24
34
|
flush = false,
|
|
35
|
+
variant = 'default',
|
|
25
36
|
class: cls = '',
|
|
26
37
|
} = {}) {
|
|
27
|
-
|
|
38
|
+
if (!VARIANTS.has(variant)) variant = 'default'
|
|
39
|
+
|
|
40
|
+
const classes = [
|
|
41
|
+
'ui-card',
|
|
42
|
+
variant !== 'default' ? `ui-card--${variant}` : '',
|
|
43
|
+
flush ? 'ui-card--flush' : '',
|
|
44
|
+
cls,
|
|
45
|
+
].filter(Boolean).join(' ')
|
|
46
|
+
|
|
28
47
|
const tag = `h${Math.min(Math.max(Math.floor(level), 1), 6)}`
|
|
29
48
|
|
|
30
49
|
const titleHtml = title ? `<div class="ui-card-header"><${tag} class="ui-card-title">${e(title)}</${tag}></div>` : ''
|
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Pulse UI — Colophon
|
|
3
|
+
*
|
|
4
|
+
* Paperback-style end-of-page metadata block. A minimal footer
|
|
5
|
+
* variant with tight type, no columns, centered layout.
|
|
6
|
+
*
|
|
7
|
+
* @param {object} opts
|
|
8
|
+
* @param {string} opts.content - Raw HTML content slot
|
|
9
|
+
* @param {'left'|'center'} opts.align - Text alignment (default 'center')
|
|
10
|
+
* @param {string} opts.class
|
|
11
|
+
*/
|
|
12
|
+
|
|
13
|
+
import { escHtml as e } from '../html.js'
|
|
14
|
+
|
|
15
|
+
export function colophon({
|
|
16
|
+
content = '',
|
|
17
|
+
align = 'center',
|
|
18
|
+
class: cls = '',
|
|
19
|
+
} = {}) {
|
|
20
|
+
const classes = [
|
|
21
|
+
'ui-colophon',
|
|
22
|
+
align === 'left' && 'ui-colophon--left',
|
|
23
|
+
cls,
|
|
24
|
+
].filter(Boolean).join(' ')
|
|
25
|
+
|
|
26
|
+
return `<footer class="${e(classes)}">
|
|
27
|
+
<div class="ui-colophon-inner">${content}</div>
|
|
28
|
+
</footer>`
|
|
29
|
+
}
|
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Pulse UI — ContactRow
|
|
3
|
+
*
|
|
4
|
+
* A horizontal strip of contact items — phone, email, address, hours.
|
|
5
|
+
* Universal on local-business and trades sites. Renders as a <ul> of
|
|
6
|
+
* icon + label pairs, each optionally linked.
|
|
7
|
+
*
|
|
8
|
+
* @param {object} opts
|
|
9
|
+
* @param {Array<{icon?: string, label: string, href?: string}>} opts.items
|
|
10
|
+
* @param {'sm'|'md'} opts.size - Text size (default: 'md')
|
|
11
|
+
* @param {string} opts.class
|
|
12
|
+
*/
|
|
13
|
+
|
|
14
|
+
import { escHtml as e } from '../html.js'
|
|
15
|
+
|
|
16
|
+
const SIZES = new Set(['sm', 'md'])
|
|
17
|
+
|
|
18
|
+
export function contactRow({
|
|
19
|
+
items = [],
|
|
20
|
+
size = 'md',
|
|
21
|
+
class: cls = '',
|
|
22
|
+
} = {}) {
|
|
23
|
+
if (!SIZES.has(size)) size = 'md'
|
|
24
|
+
|
|
25
|
+
const classes = [
|
|
26
|
+
'ui-contact-row',
|
|
27
|
+
size !== 'md' && `ui-contact-row--${size}`,
|
|
28
|
+
cls,
|
|
29
|
+
].filter(Boolean).join(' ')
|
|
30
|
+
|
|
31
|
+
const itemsHtml = items.map(({ icon = '', label = '', href = '' }) => {
|
|
32
|
+
const iconHtml = icon ? `<span class="ui-contact-row-icon" aria-hidden="true">${icon}</span>` : ''
|
|
33
|
+
const inner = `${iconHtml}<span>${e(label)}</span>`
|
|
34
|
+
const content = href
|
|
35
|
+
? `<a href="${e(href)}">${inner}</a>`
|
|
36
|
+
: inner
|
|
37
|
+
return `<li class="ui-contact-row-item">${content}</li>`
|
|
38
|
+
}).join('')
|
|
39
|
+
|
|
40
|
+
return `<ul class="${e(classes)}">${itemsHtml}</ul>`
|
|
41
|
+
}
|
package/src/ui/cta.js
CHANGED
|
@@ -21,17 +21,31 @@ export function cta({
|
|
|
21
21
|
title = '',
|
|
22
22
|
level = 2,
|
|
23
23
|
subtitle = '',
|
|
24
|
+
body, // alias for subtitle
|
|
24
25
|
actions = '',
|
|
25
26
|
align = 'center',
|
|
26
27
|
class: cls = '',
|
|
28
|
+
...rest
|
|
27
29
|
} = {}) {
|
|
30
|
+
// Accept 'body' as alias for 'subtitle'
|
|
31
|
+
const sub = subtitle || body || ''
|
|
32
|
+
|
|
33
|
+
// Warn about unknown props on the server
|
|
34
|
+
if (typeof window === 'undefined') {
|
|
35
|
+
const knownProps = new Set(['eyebrow', 'title', 'level', 'subtitle', 'body', 'actions', 'align', 'class'])
|
|
36
|
+
const unknownProps = Object.keys(rest).filter(k => !knownProps.has(k))
|
|
37
|
+
if (unknownProps.length > 0) {
|
|
38
|
+
console.warn(`[Pulse cta] Unknown prop${unknownProps.length > 1 ? 's' : ''}: ${unknownProps.join(', ')}. Did you mean 'subtitle' instead of '${unknownProps[0]}'?`)
|
|
39
|
+
}
|
|
40
|
+
}
|
|
41
|
+
|
|
28
42
|
const classes = ['ui-cta', align === 'left' && 'ui-cta--left', cls].filter(Boolean).join(' ')
|
|
29
43
|
const tag = `h${Math.min(Math.max(Math.floor(level), 1), 6)}`
|
|
30
44
|
|
|
31
45
|
return `<div class="${e(classes)}">
|
|
32
|
-
${eyebrow
|
|
33
|
-
${title
|
|
34
|
-
${
|
|
35
|
-
${actions
|
|
46
|
+
${eyebrow ? `<p class="ui-cta-eyebrow">${e(eyebrow)}</p>` : ''}
|
|
47
|
+
${title ? `<${tag} class="ui-cta-title">${e(title)}</${tag}>` : ''}
|
|
48
|
+
${sub ? `<p class="ui-cta-subtitle">${e(sub)}</p>` : ''}
|
|
49
|
+
${actions ? `<div class="ui-cta-actions">${actions}</div>` : ''}
|
|
36
50
|
</div>`
|
|
37
51
|
}
|
|
@@ -0,0 +1,55 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Pulse UI — Decorate
|
|
3
|
+
*
|
|
4
|
+
* Inline SVG background pattern element. Drop it inside any container as an
|
|
5
|
+
* absolute overlay to add texture — dots, grid, diagonal lines, or zigzag.
|
|
6
|
+
* The SVG is positioned absolute so it doesn't affect layout; the parent must
|
|
7
|
+
* have position: relative (or use a `u-relative` class).
|
|
8
|
+
*
|
|
9
|
+
* @param {object} opts
|
|
10
|
+
* @param {'dots'|'grid'|'lines'|'zigzag'|'cross'} opts.pattern - Pattern type (default: 'dots')
|
|
11
|
+
* @param {string} opts.color - Stroke/fill color, any CSS value (default: var(--ui-border))
|
|
12
|
+
* @param {number} opts.opacity - Opacity 0–1 (default: 0.4)
|
|
13
|
+
* @param {number} opts.size - Pattern tile size in px (default: 20)
|
|
14
|
+
* @param {string} opts.class
|
|
15
|
+
*/
|
|
16
|
+
|
|
17
|
+
const PATTERNS = new Set(['dots', 'grid', 'lines', 'zigzag', 'cross'])
|
|
18
|
+
|
|
19
|
+
let _uid = 0
|
|
20
|
+
|
|
21
|
+
export function decorate({
|
|
22
|
+
pattern = 'dots',
|
|
23
|
+
color = 'var(--ui-border)',
|
|
24
|
+
opacity = 0.4,
|
|
25
|
+
size = 20,
|
|
26
|
+
class: cls = '',
|
|
27
|
+
} = {}) {
|
|
28
|
+
if (!PATTERNS.has(pattern)) pattern = 'dots'
|
|
29
|
+
const s = Math.max(4, parseInt(size) || 20)
|
|
30
|
+
const safeOpac = Math.min(1, Math.max(0, parseFloat(opacity) || 0.4))
|
|
31
|
+
// Use a color we can reference in the SVG — replace var() with currentColor trick
|
|
32
|
+
const useColor = color.startsWith('var(') ? 'currentColor' : color
|
|
33
|
+
const style = color.startsWith('var(') ? ` style="color:${color}"` : ''
|
|
34
|
+
|
|
35
|
+
const classes = ['ui-decorate', cls].filter(Boolean).join(' ')
|
|
36
|
+
|
|
37
|
+
const pid = `dp${++_uid}`
|
|
38
|
+
let patternSvg
|
|
39
|
+
const half = s / 2
|
|
40
|
+
|
|
41
|
+
if (pattern === 'dots') {
|
|
42
|
+
patternSvg = `<pattern id="${pid}" patternUnits="userSpaceOnUse" width="${s}" height="${s}"><circle cx="${half}" cy="${half}" r="1" fill="${useColor}"/></pattern>`
|
|
43
|
+
} else if (pattern === 'grid') {
|
|
44
|
+
patternSvg = `<pattern id="${pid}" patternUnits="userSpaceOnUse" width="${s}" height="${s}"><path d="M ${s} 0 L 0 0 0 ${s}" fill="none" stroke="${useColor}" stroke-width="0.5"/></pattern>`
|
|
45
|
+
} else if (pattern === 'lines') {
|
|
46
|
+
patternSvg = `<pattern id="${pid}" patternUnits="userSpaceOnUse" width="${s}" height="${s}" patternTransform="rotate(45)"><line x1="0" y1="0" x2="0" y2="${s}" stroke="${useColor}" stroke-width="0.75"/></pattern>`
|
|
47
|
+
} else if (pattern === 'zigzag') {
|
|
48
|
+
const q = s / 4
|
|
49
|
+
patternSvg = `<pattern id="${pid}" patternUnits="userSpaceOnUse" width="${s}" height="${half}"><polyline points="0,${half} ${q},0 ${half},${half} ${s - q},0 ${s},${half}" fill="none" stroke="${useColor}" stroke-width="0.75"/></pattern>`
|
|
50
|
+
} else { // cross
|
|
51
|
+
patternSvg = `<pattern id="${pid}" patternUnits="userSpaceOnUse" width="${s}" height="${s}"><line x1="${half}" y1="0" x2="${half}" y2="${s}" stroke="${useColor}" stroke-width="0.5"/><line x1="0" y1="${half}" x2="${s}" y2="${half}" stroke="${useColor}" stroke-width="0.5"/><circle cx="${half}" cy="${half}" r="1.5" fill="${useColor}"/></pattern>`
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
return `<svg class="${classes}"${style} xmlns="http://www.w3.org/2000/svg" width="100%" height="100%" style="position:absolute;inset:0;pointer-events:none;opacity:${safeOpac}" aria-hidden="true"><defs>${patternSvg}</defs><rect width="100%" height="100%" fill="url(#${pid})"/></svg>`
|
|
55
|
+
}
|
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Pulse UI — Display Heading
|
|
3
|
+
*
|
|
4
|
+
* Large typographic heading for editorial moments. Controls line-break,
|
|
5
|
+
* tracking, max-width in ch. Use for hero titles, chapter openings,
|
|
6
|
+
* section leads.
|
|
7
|
+
*
|
|
8
|
+
* @param {object} opts
|
|
9
|
+
* @param {string} opts.text - Heading text (HTML entities supported)
|
|
10
|
+
* @param {number} opts.level - Heading level 1–6 (default 1)
|
|
11
|
+
* @param {number} opts.maxWidth - Max width in ch units (default 20)
|
|
12
|
+
* @param {boolean} opts.balance - Use text-wrap: balance (default true)
|
|
13
|
+
* @param {string} opts.tracking - Letter-spacing: 'tight' | 'normal' | 'wide' (default 'tight')
|
|
14
|
+
* @param {string} opts.class
|
|
15
|
+
*/
|
|
16
|
+
|
|
17
|
+
import { escHtml as e } from '../html.js'
|
|
18
|
+
|
|
19
|
+
const TRACKING = {
|
|
20
|
+
tight: '-0.02em',
|
|
21
|
+
normal: '0',
|
|
22
|
+
wide: '0.05em',
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
export function displayHeading({
|
|
26
|
+
text = '',
|
|
27
|
+
level = 1,
|
|
28
|
+
maxWidth = 20,
|
|
29
|
+
balance = true,
|
|
30
|
+
tracking = 'tight',
|
|
31
|
+
class: cls = '',
|
|
32
|
+
} = {}) {
|
|
33
|
+
const tag = `h${Math.min(Math.max(Math.floor(level), 1), 6)}`
|
|
34
|
+
const classes = ['ui-display-heading', cls].filter(Boolean).join(' ')
|
|
35
|
+
|
|
36
|
+
const letterSpacing = TRACKING[tracking] || TRACKING.tight
|
|
37
|
+
const styles = [
|
|
38
|
+
`max-width:${maxWidth}ch`,
|
|
39
|
+
`letter-spacing:${letterSpacing}`,
|
|
40
|
+
balance && 'text-wrap:balance',
|
|
41
|
+
].filter(Boolean).join(';')
|
|
42
|
+
|
|
43
|
+
return `<${tag} class="${e(classes)}" style="${styles}">${e(text)}</${tag}>`
|
|
44
|
+
}
|
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Pulse UI — Drop Cap
|
|
3
|
+
*
|
|
4
|
+
* First-letter drop cap for editorial openings. Wraps the first
|
|
5
|
+
* letter or word of a paragraph.
|
|
6
|
+
*
|
|
7
|
+
* @param {object} opts
|
|
8
|
+
* @param {string} opts.letter - The drop cap letter or word
|
|
9
|
+
* @param {string} opts.content - The rest of the paragraph text
|
|
10
|
+
* @param {number} opts.lines - How many lines tall (default 3)
|
|
11
|
+
* @param {string} opts.class
|
|
12
|
+
*/
|
|
13
|
+
|
|
14
|
+
import { escHtml as e } from '../html.js'
|
|
15
|
+
|
|
16
|
+
export function dropCap({
|
|
17
|
+
letter = '',
|
|
18
|
+
content = '',
|
|
19
|
+
lines = 3,
|
|
20
|
+
class: cls = '',
|
|
21
|
+
} = {}) {
|
|
22
|
+
if (!letter) return content ? `<p>${e(content)}</p>` : ''
|
|
23
|
+
|
|
24
|
+
const classes = ['ui-drop-cap', cls].filter(Boolean).join(' ')
|
|
25
|
+
|
|
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)}
|
|
28
|
+
</p>`
|
|
29
|
+
}
|
|
@@ -0,0 +1,63 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Pulse UI — Editorial Hero
|
|
3
|
+
*
|
|
4
|
+
* Full-bleed hero with two side-by-side images and display text that
|
|
5
|
+
* overlaps both columns simultaneously. The text uses mix-blend-mode: multiply
|
|
6
|
+
* so it visually "lives inside" the photography — a signature technique in
|
|
7
|
+
* fashion and luxury editorial design.
|
|
8
|
+
*
|
|
9
|
+
* The overlay text is decorative (aria-hidden) with a separate accessible
|
|
10
|
+
* heading provided via the `heading` param.
|
|
11
|
+
*
|
|
12
|
+
* @param {object} opts
|
|
13
|
+
* @param {string} opts.leftImage - Raw HTML slot for the left image (<img>, uiImage, etc.)
|
|
14
|
+
* @param {string} opts.rightImage - Raw HTML slot for the right image
|
|
15
|
+
* @param {string|string[]} opts.lines - Headline text — string or array of lines
|
|
16
|
+
* @param {string} opts.heading - Accessible heading text (sr-only, replaces lines for AT)
|
|
17
|
+
* @param {string} opts.cta - Raw HTML slot — optional CTA link at the bottom
|
|
18
|
+
* @param {string} opts.color - Overlay text colour (default: var(--ui-color-accent))
|
|
19
|
+
* @param {'multiply'|'screen'|'overlay'|'none'} opts.blend - blend-mode for overlay text (default: 'multiply')
|
|
20
|
+
* @param {string} opts.ratio - Image aspect-ratio CSS string (default: '3/4')
|
|
21
|
+
* @param {string} opts.class
|
|
22
|
+
*/
|
|
23
|
+
|
|
24
|
+
import { escHtml as e } from '../html.js'
|
|
25
|
+
|
|
26
|
+
const BLENDS = new Set(['multiply', 'screen', 'overlay', 'none'])
|
|
27
|
+
|
|
28
|
+
export function editorialHero({
|
|
29
|
+
leftImage = '',
|
|
30
|
+
rightImage = '',
|
|
31
|
+
lines = [],
|
|
32
|
+
heading = '',
|
|
33
|
+
cta = '',
|
|
34
|
+
color = '',
|
|
35
|
+
blend = 'multiply',
|
|
36
|
+
ratio = '3/4',
|
|
37
|
+
class: cls = '',
|
|
38
|
+
} = {}) {
|
|
39
|
+
if (!BLENDS.has(blend)) blend = 'multiply'
|
|
40
|
+
|
|
41
|
+
const classes = ['ui-editorial-hero', cls].filter(Boolean).join(' ')
|
|
42
|
+
const lineArray = Array.isArray(lines) ? lines : [lines]
|
|
43
|
+
|
|
44
|
+
const overlayStyle = [
|
|
45
|
+
color ? `color:${color.replace(/"/g, "'")}` : '',
|
|
46
|
+
blend !== 'none' ? `mix-blend-mode:${blend}` : '',
|
|
47
|
+
].filter(Boolean).join(';')
|
|
48
|
+
|
|
49
|
+
const overlayAttr = overlayStyle ? ` style="${overlayStyle}"` : ''
|
|
50
|
+
const imageStyle = ratio ? ` style="aspect-ratio:${e(ratio)}"` : ''
|
|
51
|
+
|
|
52
|
+
const linesHtml = lineArray.map(l => `<span>${e(l)}</span>`).join('')
|
|
53
|
+
|
|
54
|
+
return `<section class="${e(classes)}">
|
|
55
|
+
${heading ? `<h1 class="ui-editorial-hero-heading">${e(heading)}</h1>` : ''}
|
|
56
|
+
<div class="ui-editorial-hero-images"${imageStyle}>
|
|
57
|
+
<div class="ui-editorial-hero-img">${leftImage}</div>
|
|
58
|
+
<div class="ui-editorial-hero-img">${rightImage}</div>
|
|
59
|
+
</div>
|
|
60
|
+
${linesHtml ? `<div class="ui-editorial-hero-overlay" aria-hidden="true"${overlayAttr}>${linesHtml}</div>` : ''}
|
|
61
|
+
${cta ? `<div class="ui-editorial-hero-cta">${cta}</div>` : ''}
|
|
62
|
+
</section>`
|
|
63
|
+
}
|
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Pulse UI — Entry List
|
|
3
|
+
*
|
|
4
|
+
* Tabular list of items with leading metadata (year, number, date),
|
|
5
|
+
* title, and trailing description. For: backlists, release notes,
|
|
6
|
+
* project archives, talk logs, changelogs.
|
|
7
|
+
*
|
|
8
|
+
* @param {object} opts
|
|
9
|
+
* @param {Array<{meta: string, title: string, description: string}>} opts.items
|
|
10
|
+
* @param {string} opts.metaLabel - Accessible label for metadata column (e.g. "Year", "Date")
|
|
11
|
+
* @param {string} opts.class
|
|
12
|
+
*/
|
|
13
|
+
|
|
14
|
+
import { escHtml as e } from '../html.js'
|
|
15
|
+
|
|
16
|
+
export function entryList({
|
|
17
|
+
items = [],
|
|
18
|
+
metaLabel = 'Date',
|
|
19
|
+
class: cls = '',
|
|
20
|
+
} = {}) {
|
|
21
|
+
if (!Array.isArray(items) || items.length === 0) return ''
|
|
22
|
+
|
|
23
|
+
const classes = ['ui-entry-list', cls].filter(Boolean).join(' ')
|
|
24
|
+
|
|
25
|
+
const rows = items.map(item => `
|
|
26
|
+
<div class="ui-entry-list-item">
|
|
27
|
+
<div class="ui-entry-list-meta" aria-label="${e(metaLabel)}">${e(item.meta || '')}</div>
|
|
28
|
+
<div class="ui-entry-list-content">
|
|
29
|
+
<h3 class="ui-entry-list-title">${e(item.title || '')}</h3>
|
|
30
|
+
${item.description ? `<p class="ui-entry-list-description">${e(item.description)}</p>` : ''}
|
|
31
|
+
</div>
|
|
32
|
+
</div>
|
|
33
|
+
`).join('')
|
|
34
|
+
|
|
35
|
+
return `<div class="${e(classes)}">${rows}</div>`
|
|
36
|
+
}
|
package/src/ui/feature.js
CHANGED
|
@@ -4,6 +4,7 @@
|
|
|
4
4
|
* Icon + title + description block. The standard "why us" grid card.
|
|
5
5
|
*
|
|
6
6
|
* @param {object} opts
|
|
7
|
+
* @param {string} opts.image - Raw HTML slot — rendered above the icon (e.g. `<img src="...">`)
|
|
7
8
|
* @param {string} opts.icon - Raw HTML slot — SVG or emoji; displayed in an accent-tinted box
|
|
8
9
|
* @param {string} opts.title
|
|
9
10
|
* @param {number} opts.level - Heading level 1–6 (default 3). Visual style is always ui-feature-title.
|
|
@@ -15,19 +16,35 @@
|
|
|
15
16
|
import { escHtml as e } from '../html.js'
|
|
16
17
|
|
|
17
18
|
export function feature({
|
|
19
|
+
image = '',
|
|
18
20
|
icon = '',
|
|
19
21
|
title = '',
|
|
20
22
|
level = 3,
|
|
21
23
|
description = '',
|
|
24
|
+
body, // alias for description
|
|
22
25
|
center = false,
|
|
23
26
|
class: cls = '',
|
|
27
|
+
...rest
|
|
24
28
|
} = {}) {
|
|
29
|
+
// Accept 'body' as alias for 'description'
|
|
30
|
+
const desc = description || body || ''
|
|
31
|
+
|
|
32
|
+
// Warn about unknown props on the server
|
|
33
|
+
if (typeof window === 'undefined') {
|
|
34
|
+
const knownProps = new Set(['image', 'icon', 'title', 'level', 'description', 'body', 'center', 'class'])
|
|
35
|
+
const unknownProps = Object.keys(rest).filter(k => !knownProps.has(k))
|
|
36
|
+
if (unknownProps.length > 0) {
|
|
37
|
+
console.warn(`[Pulse feature] Unknown prop${unknownProps.length > 1 ? 's' : ''}: ${unknownProps.join(', ')}. Did you mean 'description' instead of '${unknownProps[0]}'?`)
|
|
38
|
+
}
|
|
39
|
+
}
|
|
40
|
+
|
|
25
41
|
const classes = ['ui-feature', center && 'ui-feature--center', cls].filter(Boolean).join(' ')
|
|
26
42
|
const tag = `h${Math.min(Math.max(Math.floor(level), 1), 6)}`
|
|
27
43
|
|
|
28
44
|
return `<div class="${e(classes)}">
|
|
29
|
-
${
|
|
30
|
-
${
|
|
31
|
-
${
|
|
45
|
+
${image ? `<div class="ui-feature-image">${image}</div>` : ''}
|
|
46
|
+
${icon ? `<div class="ui-feature-icon" aria-hidden="true">${icon}</div>` : ''}
|
|
47
|
+
${title ? `<${tag} class="ui-feature-title">${e(title)}</${tag}>` : ''}
|
|
48
|
+
${desc ? `<p class="ui-feature-desc">${e(desc)}</p>` : ''}
|
|
32
49
|
</div>`
|
|
33
50
|
}
|
package/src/ui/footer.js
CHANGED
|
@@ -5,11 +5,27 @@
|
|
|
5
5
|
* Handles accessibility (landmark role, aria-label on nav) and responsive
|
|
6
6
|
* stacking automatically.
|
|
7
7
|
*
|
|
8
|
+
* Two layouts:
|
|
9
|
+
*
|
|
10
|
+
* Simple (default):
|
|
11
|
+
* logo + flat link list + legal — one-liner or small site footer.
|
|
12
|
+
*
|
|
13
|
+
* Rich (when `columns` is provided):
|
|
14
|
+
* Optional subscribe form row, multi-column link grid, optional wordmark,
|
|
15
|
+
* and legal row. Use for brand-heavy or e-commerce sites.
|
|
16
|
+
*
|
|
8
17
|
* @param {object} opts
|
|
9
|
-
* @param {string} opts.logo
|
|
10
|
-
* @param {string} opts.logoHref
|
|
11
|
-
* @param {Array<{label: string, href: string}>} opts.links -
|
|
12
|
-
* @param {string} opts.legal
|
|
18
|
+
* @param {string} opts.logo - Raw HTML slot — SVG, img, or text
|
|
19
|
+
* @param {string} opts.logoHref - Logo link destination (default: '/')
|
|
20
|
+
* @param {Array<{label: string, href: string}>} opts.links - Flat footer nav links (simple layout)
|
|
21
|
+
* @param {string} opts.legal - Copyright / legal text
|
|
22
|
+
*
|
|
23
|
+
* Rich layout params:
|
|
24
|
+
* @param {Array<{title: string, links: Array<{label, href}>}>} opts.columns - Multi-column link sections
|
|
25
|
+
* @param {string} opts.subscribe - Raw HTML slot — newsletter subscribe form
|
|
26
|
+
* @param {string} opts.wordmark - Giant display text shown at the very bottom of the footer
|
|
27
|
+
* @param {string} opts.background - CSS background value — sets the footer background colour
|
|
28
|
+
* @param {string} opts.color - Foreground/text colour — use when background needs contrast override
|
|
13
29
|
* @param {string} opts.class
|
|
14
30
|
*/
|
|
15
31
|
|
|
@@ -20,19 +36,72 @@ export function footer({
|
|
|
20
36
|
logoHref = '/',
|
|
21
37
|
links = [],
|
|
22
38
|
legal = '',
|
|
39
|
+
columns = [],
|
|
40
|
+
subscribe = '',
|
|
41
|
+
wordmark = '',
|
|
42
|
+
background = '',
|
|
43
|
+
color = '',
|
|
23
44
|
class: cls = '',
|
|
24
45
|
} = {}) {
|
|
25
|
-
const
|
|
46
|
+
const isRich = columns.length > 0 || subscribe || wordmark
|
|
26
47
|
|
|
27
|
-
const
|
|
28
|
-
|
|
29
|
-
|
|
48
|
+
const styles = [
|
|
49
|
+
background && `background:${background.replace(/"/g, "'")}`,
|
|
50
|
+
color && `color:${color.replace(/"/g, "'")}`,
|
|
51
|
+
].filter(Boolean).join(';')
|
|
52
|
+
const styleAttr = styles ? ` style="${styles}"` : ''
|
|
53
|
+
|
|
54
|
+
const classes = ['ui-footer', isRich && 'ui-footer--rich', cls].filter(Boolean).join(' ')
|
|
55
|
+
|
|
56
|
+
// Defensive shape checks — common mistakes that produce silent empty output
|
|
57
|
+
if (typeof window === 'undefined') { // server only
|
|
58
|
+
if (columns.length && columns.some(c => c.items && !c.links)) {
|
|
59
|
+
console.warn('[Pulse footer] Column shape should be { title, links: [...] } — found { items } instead. Did you mean links?')
|
|
60
|
+
}
|
|
61
|
+
if (!columns.length && links.some(l => l.title || l.items || l.heading)) {
|
|
62
|
+
console.warn('[Pulse footer] Simple footer expects links: [{ label, href }] — found grouped shape ({ title/heading/items } keys). For multi-column layout, use columns: [{ title, links: [...] }] instead of links.')
|
|
63
|
+
}
|
|
64
|
+
}
|
|
30
65
|
|
|
31
|
-
|
|
66
|
+
// Simple layout
|
|
67
|
+
if (!isRich) {
|
|
68
|
+
const linksHtml = links.map(({ label = '', href = '' }) =>
|
|
69
|
+
`<a href="${e(href)}" class="ui-footer-link">${e(label)}</a>`
|
|
70
|
+
).join('')
|
|
71
|
+
|
|
72
|
+
return `<footer class="${e(classes)}"${styleAttr}>
|
|
32
73
|
<div class="ui-footer-inner">
|
|
33
74
|
${logo ? `<a href="${e(logoHref)}" class="ui-footer-logo">${logo}</a>` : ''}
|
|
34
75
|
${links.length ? `<nav class="ui-footer-links" aria-label="Footer navigation">${linksHtml}</nav>` : ''}
|
|
35
76
|
${legal ? `<p class="ui-footer-legal">${e(legal)}</p>` : ''}
|
|
36
77
|
</div>
|
|
78
|
+
</footer>`
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
// Rich layout
|
|
82
|
+
const columnsHtml = columns.map(({ title = '', links: cols = [] }) => `
|
|
83
|
+
<div class="ui-footer-col">
|
|
84
|
+
${title ? `<p class="ui-footer-col-title">${e(title)}</p>` : ''}
|
|
85
|
+
<ul class="ui-footer-col-links" role="list">
|
|
86
|
+
${cols.map(({ label = '', href = '' }) => `<li><a href="${e(href)}">${e(label)}</a></li>`).join('')}
|
|
87
|
+
</ul>
|
|
88
|
+
</div>`).join('')
|
|
89
|
+
|
|
90
|
+
const legalLinks = links.map(({ label = '', href = '' }) =>
|
|
91
|
+
`<a href="${e(href)}">${e(label)}</a>`
|
|
92
|
+
).join('')
|
|
93
|
+
|
|
94
|
+
return `<footer class="${e(classes)}"${styleAttr}>
|
|
95
|
+
${logo || subscribe ? `<div class="ui-footer-top">
|
|
96
|
+
${logo ? `<a href="${e(logoHref)}" class="ui-footer-logo">${logo}</a>` : ''}
|
|
97
|
+
${subscribe ? `<div class="ui-footer-subscribe">${subscribe}</div>` : ''}
|
|
98
|
+
</div>` : ''}
|
|
99
|
+
${columns.length ? `<div class="ui-footer-columns">${columnsHtml}
|
|
100
|
+
</div>` : ''}
|
|
101
|
+
${wordmark ? `<div class="ui-footer-wordmark" data-wordmark="${e(wordmark)}" aria-hidden="true"></div>` : ''}
|
|
102
|
+
${legal || links.length ? `<div class="ui-footer-legal-row">
|
|
103
|
+
${legalLinks}
|
|
104
|
+
${legal ? `<span>${e(legal)}</span>` : ''}
|
|
105
|
+
</div>` : ''}
|
|
37
106
|
</footer>`
|
|
38
107
|
}
|
|
@@ -0,0 +1,56 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Pulse UI — Gallery
|
|
3
|
+
*
|
|
4
|
+
* Responsive image grid for photos, illustrations, or any visual content.
|
|
5
|
+
* Three layout modes: grid (equal cells), strip (single scrollable row), masonry (Pinterest-style).
|
|
6
|
+
*
|
|
7
|
+
* @param {object} opts
|
|
8
|
+
* @param {Array<{src:string,alt:string,caption?:string,href?:string}>} opts.images
|
|
9
|
+
* @param {'grid'|'strip'|'masonry'} opts.layout - Grid layout (default: 'grid')
|
|
10
|
+
* @param {1|2|3|4} opts.cols - Columns in grid/masonry layout (default: 3)
|
|
11
|
+
* @param {'sm'|'md'|'lg'} opts.gap - Gap between images (default: 'md')
|
|
12
|
+
* @param {boolean} opts.rounded - Rounded corners on images
|
|
13
|
+
* @param {string} opts.ratio - CSS aspect-ratio for grid/strip cells e.g. '4/3', '1/1', '16/9'
|
|
14
|
+
* @param {string} opts.class
|
|
15
|
+
*/
|
|
16
|
+
|
|
17
|
+
import { escHtml as e } from '../html.js'
|
|
18
|
+
|
|
19
|
+
const LAYOUTS = new Set(['grid', 'strip', 'masonry'])
|
|
20
|
+
const GAPS = new Set(['sm', 'md', 'lg'])
|
|
21
|
+
|
|
22
|
+
export function gallery({
|
|
23
|
+
images = [],
|
|
24
|
+
layout = 'grid',
|
|
25
|
+
cols = 3,
|
|
26
|
+
gap = 'md',
|
|
27
|
+
rounded = false,
|
|
28
|
+
ratio = '4/3',
|
|
29
|
+
class: cls = '',
|
|
30
|
+
} = {}) {
|
|
31
|
+
if (!LAYOUTS.has(layout)) layout = 'grid'
|
|
32
|
+
if (!GAPS.has(gap)) gap = 'md'
|
|
33
|
+
const safeC = Math.min(Math.max(Math.floor(cols), 1), 4)
|
|
34
|
+
|
|
35
|
+
const classes = [
|
|
36
|
+
'ui-gallery',
|
|
37
|
+
`ui-gallery--${layout}`,
|
|
38
|
+
`ui-gallery--cols-${safeC}`,
|
|
39
|
+
gap !== 'md' && `ui-gallery--gap-${gap}`,
|
|
40
|
+
rounded && 'ui-gallery--rounded',
|
|
41
|
+
cls,
|
|
42
|
+
].filter(Boolean).join(' ')
|
|
43
|
+
|
|
44
|
+
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">`
|
|
46
|
+
const cap = img.caption ? `<figcaption class="ui-gallery-caption">${e(img.caption)}</figcaption>` : ''
|
|
47
|
+
const inner = img.href
|
|
48
|
+
? `<a href="${e(img.href)}" class="ui-gallery-link">${imgTag}</a>`
|
|
49
|
+
: imgTag
|
|
50
|
+
const cropStyle = (layout !== 'masonry' && ratio) ? ` style="aspect-ratio:${e(ratio)}"` : ''
|
|
51
|
+
const crop = `<div class="ui-gallery-crop"${cropStyle}>${inner}</div>`
|
|
52
|
+
return `<figure class="ui-gallery-item">${crop}${cap}</figure>`
|
|
53
|
+
}).join('\n')
|
|
54
|
+
|
|
55
|
+
return `<div class="${e(classes)}">\n${items}\n</div>`
|
|
56
|
+
}
|