@bytesbrains/weblocks 0.2.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/AGENT.md +214 -0
- package/CATALOG.md +387 -0
- package/CHANGELOG.md +63 -0
- package/LICENSE +21 -0
- package/README.md +294 -0
- package/catalog.json +1850 -0
- package/lib/blocks/about.d.ts +2 -0
- package/lib/blocks/about.js +50 -0
- package/lib/blocks/accordion.d.ts +2 -0
- package/lib/blocks/accordion.js +51 -0
- package/lib/blocks/announcementBar.d.ts +2 -0
- package/lib/blocks/announcementBar.js +56 -0
- package/lib/blocks/appShell.d.ts +2 -0
- package/lib/blocks/appShell.js +54 -0
- package/lib/blocks/auth.d.ts +2 -0
- package/lib/blocks/auth.js +82 -0
- package/lib/blocks/blogList.d.ts +2 -0
- package/lib/blocks/blogList.js +81 -0
- package/lib/blocks/blogPost.d.ts +2 -0
- package/lib/blocks/blogPost.js +60 -0
- package/lib/blocks/carousel.d.ts +2 -0
- package/lib/blocks/carousel.js +56 -0
- package/lib/blocks/contactDetails.d.ts +2 -0
- package/lib/blocks/contactDetails.js +50 -0
- package/lib/blocks/contactForm.d.ts +2 -0
- package/lib/blocks/contactForm.js +100 -0
- package/lib/blocks/cta.d.ts +2 -0
- package/lib/blocks/cta.js +40 -0
- package/lib/blocks/divider.d.ts +2 -0
- package/lib/blocks/divider.js +28 -0
- package/lib/blocks/faq.d.ts +2 -0
- package/lib/blocks/faq.js +52 -0
- package/lib/blocks/features.d.ts +2 -0
- package/lib/blocks/features.js +58 -0
- package/lib/blocks/feed.d.ts +2 -0
- package/lib/blocks/feed.js +81 -0
- package/lib/blocks/footer.d.ts +2 -0
- package/lib/blocks/footer.js +40 -0
- package/lib/blocks/gallery.d.ts +2 -0
- package/lib/blocks/gallery.js +64 -0
- package/lib/blocks/hero.d.ts +2 -0
- package/lib/blocks/hero.js +48 -0
- package/lib/blocks/heroApp.d.ts +2 -0
- package/lib/blocks/heroApp.js +65 -0
- package/lib/blocks/logos.d.ts +2 -0
- package/lib/blocks/logos.js +59 -0
- package/lib/blocks/map.d.ts +2 -0
- package/lib/blocks/map.js +43 -0
- package/lib/blocks/nav.d.ts +2 -0
- package/lib/blocks/nav.js +39 -0
- package/lib/blocks/newsletter.d.ts +2 -0
- package/lib/blocks/newsletter.js +56 -0
- package/lib/blocks/pricing.d.ts +2 -0
- package/lib/blocks/pricing.js +89 -0
- package/lib/blocks/prose.d.ts +5 -0
- package/lib/blocks/prose.js +46 -0
- package/lib/blocks/richText.d.ts +2 -0
- package/lib/blocks/richText.js +46 -0
- package/lib/blocks/services.d.ts +2 -0
- package/lib/blocks/services.js +65 -0
- package/lib/blocks/sidebar.d.ts +2 -0
- package/lib/blocks/sidebar.js +58 -0
- package/lib/blocks/socialLinks.d.ts +2 -0
- package/lib/blocks/socialLinks.js +53 -0
- package/lib/blocks/spacer.d.ts +2 -0
- package/lib/blocks/spacer.js +25 -0
- package/lib/blocks/split.d.ts +2 -0
- package/lib/blocks/split.js +67 -0
- package/lib/blocks/stats.d.ts +2 -0
- package/lib/blocks/stats.js +64 -0
- package/lib/blocks/steps.d.ts +2 -0
- package/lib/blocks/steps.js +63 -0
- package/lib/blocks/tabs.d.ts +2 -0
- package/lib/blocks/tabs.js +57 -0
- package/lib/blocks/team.d.ts +2 -0
- package/lib/blocks/team.js +94 -0
- package/lib/blocks/testimonials.d.ts +2 -0
- package/lib/blocks/testimonials.js +54 -0
- package/lib/blocks/timeline.d.ts +2 -0
- package/lib/blocks/timeline.js +53 -0
- package/lib/blocks/video.d.ts +2 -0
- package/lib/blocks/video.js +61 -0
- package/lib/catalog.d.ts +26 -0
- package/lib/catalog.js +62 -0
- package/lib/generate.d.ts +35 -0
- package/lib/generate.js +160 -0
- package/lib/index.d.ts +19 -0
- package/lib/index.js +11 -0
- package/lib/ops.d.ts +85 -0
- package/lib/ops.js +248 -0
- package/lib/presets.d.ts +16 -0
- package/lib/presets.js +50 -0
- package/lib/pwa.d.ts +30 -0
- package/lib/pwa.js +79 -0
- package/lib/registry.d.ts +53 -0
- package/lib/registry.js +81 -0
- package/lib/render.d.ts +8 -0
- package/lib/render.js +105 -0
- package/lib/runtime.d.ts +68 -0
- package/lib/runtime.js +46 -0
- package/lib/schema.d.ts +67 -0
- package/lib/schema.js +150 -0
- package/lib/tokens.d.ts +37 -0
- package/lib/tokens.js +136 -0
- package/lib/types.d.ts +108 -0
- package/lib/types.js +10 -0
- package/lib/validate.d.ts +10 -0
- package/lib/validate.js +48 -0
- package/package.json +65 -0
|
@@ -0,0 +1,100 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `contact-form` — a powered brick (§6). Emits a real, accessible form whose
|
|
3
|
+
* fields are TYPED config (never raw HTML). The submit target is resolved by the
|
|
4
|
+
* host runtime via the `contact-form.submit` capability; with no runtime wired
|
|
5
|
+
* the form renders inert-but-valid (action `#`, disabled submit, a visible note)
|
|
6
|
+
* while keeping the `data-wl-*` hooks so a host can still enhance it client-side.
|
|
7
|
+
*
|
|
8
|
+
* The safety-critical parts (spam/captcha, server-side validation, delivery,
|
|
9
|
+
* abuse caps) live in the host's vetted runtime — never in anything the AI emits.
|
|
10
|
+
*/
|
|
11
|
+
import { escapeAttr, escapeHtml } from '../schema.js';
|
|
12
|
+
import { NOOP_RUNTIME, safeMethod } from '../runtime.js';
|
|
13
|
+
const CAPABILITY = 'contact-form.submit';
|
|
14
|
+
const schema = {
|
|
15
|
+
title: { kind: 'string', default: 'Get in touch', max: 120 },
|
|
16
|
+
intro: { kind: 'string', default: '', max: 280 },
|
|
17
|
+
submitLabel: { kind: 'string', default: 'Send', max: 40 },
|
|
18
|
+
successMessage: { kind: 'string', default: "Thanks — we'll be in touch.", max: 200 },
|
|
19
|
+
fields: {
|
|
20
|
+
kind: 'array', max: 12,
|
|
21
|
+
of: {
|
|
22
|
+
kind: 'object',
|
|
23
|
+
fields: {
|
|
24
|
+
name: { kind: 'string', required: true, default: 'field', max: 40 },
|
|
25
|
+
label: { kind: 'string', required: true, default: 'Field', max: 80 },
|
|
26
|
+
type: { kind: 'enum', values: ['text', 'email', 'tel', 'textarea', 'select', 'checkbox'], default: 'text' },
|
|
27
|
+
placeholder: { kind: 'string', default: '', max: 80 },
|
|
28
|
+
required: { kind: 'boolean', default: false },
|
|
29
|
+
options: { kind: 'array', max: 24, of: { kind: 'string', default: '', max: 80 } },
|
|
30
|
+
},
|
|
31
|
+
},
|
|
32
|
+
},
|
|
33
|
+
};
|
|
34
|
+
const css = `
|
|
35
|
+
.blk-contact-form{padding:var(--space-lg) var(--space);background:var(--bg);color:var(--text)}
|
|
36
|
+
.blk-contact-form .wrap{max-width:560px;margin:0 auto}
|
|
37
|
+
.blk-contact-form h2{font-size:var(--fs-xl);margin:0 0 .2em;font-weight:800}
|
|
38
|
+
.blk-contact-form .intro{color:var(--muted);margin:0 0 var(--space)}
|
|
39
|
+
.blk-contact-form form{display:grid;gap:calc(var(--space)*.9)}
|
|
40
|
+
.blk-contact-form label{display:grid;gap:.35em;font-weight:600}
|
|
41
|
+
.blk-contact-form .row-check{grid-auto-flow:column;justify-content:start;align-items:center;gap:.5em}
|
|
42
|
+
.blk-contact-form input,.blk-contact-form textarea,.blk-contact-form select{font:inherit;color:inherit;background:var(--surface);border:1px solid color-mix(in srgb,var(--text) 18%,transparent);border-radius:var(--radius);padding:.6em .7em;width:100%}
|
|
43
|
+
.blk-contact-form textarea{min-height:7em;resize:vertical}
|
|
44
|
+
.blk-contact-form button{font:inherit;font-weight:700;cursor:pointer;border:0;border-radius:var(--radius);padding:.7em 1.2em;background:var(--primary);color:var(--on-primary);transition:opacity var(--motion)}
|
|
45
|
+
.blk-contact-form button[disabled]{opacity:.55;cursor:not-allowed}
|
|
46
|
+
.blk-contact-form .note{margin:.4em 0 0;font-size:var(--fs-base);color:var(--muted)}
|
|
47
|
+
`.trim();
|
|
48
|
+
/** A DOM-safe id fragment from a field name (label/input association can't break). */
|
|
49
|
+
function fieldId(base, name, i) {
|
|
50
|
+
const slug = String(name).replace(/[^A-Za-z0-9_-]/g, '') || `f${i}`;
|
|
51
|
+
return `${base}-${slug}`;
|
|
52
|
+
}
|
|
53
|
+
function control(f, base, i) {
|
|
54
|
+
const id = escapeAttr(fieldId(base, f.name, i));
|
|
55
|
+
const req = f.required ? ' required' : '';
|
|
56
|
+
const ph = f.placeholder ? ` placeholder="${escapeAttr(f.placeholder)}"` : '';
|
|
57
|
+
const nm = escapeAttr(f.name);
|
|
58
|
+
if (f.type === 'textarea') {
|
|
59
|
+
return `<label for="${id}">${escapeHtml(f.label)}<textarea id="${id}" name="${nm}"${ph}${req}></textarea></label>`;
|
|
60
|
+
}
|
|
61
|
+
if (f.type === 'checkbox') {
|
|
62
|
+
return `<label class="row-check" for="${id}"><input id="${id}" type="checkbox" name="${nm}"${req}>${escapeHtml(f.label)}</label>`;
|
|
63
|
+
}
|
|
64
|
+
if (f.type === 'select') {
|
|
65
|
+
const opts = (f.options ?? []).filter(Boolean).map((o) => `<option value="${escapeAttr(o)}">${escapeHtml(o)}</option>`).join('');
|
|
66
|
+
return `<label for="${id}">${escapeHtml(f.label)}<select id="${id}" name="${nm}"${req}>${opts}</select></label>`;
|
|
67
|
+
}
|
|
68
|
+
const t = ['text', 'email', 'tel'].includes(f.type) ? f.type : 'text';
|
|
69
|
+
return `<label for="${id}">${escapeHtml(f.label)}<input id="${id}" type="${t}" name="${nm}"${ph}${req}></label>`;
|
|
70
|
+
}
|
|
71
|
+
function render(config, _tokens, ctx) {
|
|
72
|
+
const id = ctx?.id ?? 'contact-form';
|
|
73
|
+
const runtime = ctx?.runtime ?? NOOP_RUNTIME;
|
|
74
|
+
const title = config.title;
|
|
75
|
+
const intro = config.intro;
|
|
76
|
+
const submitLabel = config.submitLabel || 'Send';
|
|
77
|
+
const fields = config.fields ?? [];
|
|
78
|
+
const action = runtime.resolve(CAPABILITY, id);
|
|
79
|
+
const controls = fields.map((f, i) => control(f, `f-${id}`, i)).join('\n ');
|
|
80
|
+
return `<section class="blk-contact-form" aria-label="${escapeAttr(title || 'Contact form')}">
|
|
81
|
+
<div class="wrap">
|
|
82
|
+
${title ? `<h2>${escapeHtml(title)}</h2>` : ''}
|
|
83
|
+
${intro ? `<p class="intro">${escapeHtml(intro)}</p>` : ''}
|
|
84
|
+
<form method="${safeMethod(action?.method)}" action="${action ? escapeAttr(action.url) : '#'}" data-wl-capability="${CAPABILITY}" data-wl-block="${escapeAttr(id)}"${action ? '' : ' data-wl-inert="true"'}>
|
|
85
|
+
${controls}
|
|
86
|
+
<button type="submit"${action ? '' : ' disabled'}>${escapeHtml(submitLabel)}</button>
|
|
87
|
+
${action ? '' : '<p class="note">This form needs a runtime to be wired before it can send.</p>'}
|
|
88
|
+
</form>
|
|
89
|
+
</div>
|
|
90
|
+
</section>`;
|
|
91
|
+
}
|
|
92
|
+
export const contactForm = {
|
|
93
|
+
type: 'contact-form',
|
|
94
|
+
description: 'A configurable contact form with typed fields (text, email, tel, textarea, select, checkbox) that posts to a host-provided runtime. No raw HTML.',
|
|
95
|
+
schema,
|
|
96
|
+
css,
|
|
97
|
+
render,
|
|
98
|
+
island: 'contact-form',
|
|
99
|
+
runtime: { capabilities: [CAPABILITY] },
|
|
100
|
+
};
|
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
/** `cta` — a conversion band with a headline and one button. Static brick. */
|
|
2
|
+
import { escapeAttr, escapeHtml, sanitizeUrl } from '../schema.js';
|
|
3
|
+
const schema = {
|
|
4
|
+
headline: { kind: 'string', required: true, default: 'Ready to start?', max: 160 },
|
|
5
|
+
subhead: { kind: 'string', default: '', max: 240 },
|
|
6
|
+
background: { kind: 'enum', values: ['primary', 'accent', 'surface'], default: 'primary' },
|
|
7
|
+
button: {
|
|
8
|
+
kind: 'object',
|
|
9
|
+
fields: { label: { kind: 'string', default: 'Get started', max: 40 }, href: { kind: 'string', default: '#', max: 300 } },
|
|
10
|
+
},
|
|
11
|
+
};
|
|
12
|
+
const css = `
|
|
13
|
+
.blk-cta{padding:var(--space-lg) var(--space);text-align:center}
|
|
14
|
+
.blk-cta[data-bg="primary"]{background:var(--primary);color:var(--on-primary)}
|
|
15
|
+
.blk-cta[data-bg="accent"]{background:var(--accent);color:var(--on-accent)}
|
|
16
|
+
.blk-cta[data-bg="surface"]{background:var(--surface);color:var(--text)}
|
|
17
|
+
.blk-cta .wrap{max-width:720px;margin:0 auto;display:flex;flex-direction:column;align-items:center;gap:calc(var(--space)*0.9)}
|
|
18
|
+
.blk-cta h2{font-size:var(--fs-xl);margin:0;font-weight:800}
|
|
19
|
+
.blk-cta p{margin:0;opacity:.9;font-size:var(--fs-lg)}
|
|
20
|
+
.blk-cta .btn{display:inline-block;background:var(--surface);color:var(--text);padding:.7em 1.6em;border-radius:var(--radius);text-decoration:none;font-weight:700}
|
|
21
|
+
.blk-cta[data-bg="surface"] .btn{background:var(--primary);color:var(--on-primary)}
|
|
22
|
+
`.trim();
|
|
23
|
+
function render(config) {
|
|
24
|
+
const headline = config.headline;
|
|
25
|
+
const subhead = config.subhead;
|
|
26
|
+
const bg = config.background;
|
|
27
|
+
const button = config.button;
|
|
28
|
+
return `<section class="blk-cta" data-bg="${escapeAttr(bg)}" aria-label="Call to action">
|
|
29
|
+
<div class="wrap">
|
|
30
|
+
<h2>${escapeHtml(headline)}</h2>
|
|
31
|
+
${subhead ? `<p>${escapeHtml(subhead)}</p>` : ''}
|
|
32
|
+
${button.label ? `<a class="btn" href="${escapeAttr(sanitizeUrl(button.href))}">${escapeHtml(button.label)}</a>` : ''}
|
|
33
|
+
</div>
|
|
34
|
+
</section>`;
|
|
35
|
+
}
|
|
36
|
+
export const cta = {
|
|
37
|
+
type: 'cta',
|
|
38
|
+
description: 'A full-width call-to-action band: a headline, optional subheading, and one button. Use near the bottom.',
|
|
39
|
+
schema, css, render,
|
|
40
|
+
};
|
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
/** `divider` — a visual section break (line / dots / gradient). Static brick. */
|
|
2
|
+
import { escapeAttr } from '../schema.js';
|
|
3
|
+
const schema = {
|
|
4
|
+
style: { kind: 'enum', values: ['line', 'dots', 'gradient'], default: 'line' },
|
|
5
|
+
};
|
|
6
|
+
const css = `
|
|
7
|
+
.blk-divider{padding:var(--space) var(--space);background:var(--bg)}
|
|
8
|
+
.blk-divider .wrap{max-width:1080px;margin:0 auto}
|
|
9
|
+
.blk-divider hr{border:0;margin:0}
|
|
10
|
+
.blk-divider.style-line hr{height:1px;background:color-mix(in srgb,var(--text) 15%,transparent)}
|
|
11
|
+
.blk-divider.style-gradient hr{height:3px;border-radius:999px;background:linear-gradient(90deg,transparent,var(--primary),var(--accent),transparent)}
|
|
12
|
+
.blk-divider.style-dots hr{height:6px;background:radial-gradient(circle,color-mix(in srgb,var(--muted) 60%,transparent) 1.5px,transparent 1.6px);background-size:16px 6px;background-position:center}
|
|
13
|
+
`.trim();
|
|
14
|
+
const STYLES = new Set(['line', 'dots', 'gradient']);
|
|
15
|
+
function render(config) {
|
|
16
|
+
const raw = config.style;
|
|
17
|
+
const style = STYLES.has(raw) ? raw : 'line';
|
|
18
|
+
return `<section class="blk-divider style-${escapeAttr(style)}" aria-hidden="true">
|
|
19
|
+
<div class="wrap"><hr></div>
|
|
20
|
+
</section>`;
|
|
21
|
+
}
|
|
22
|
+
export const divider = {
|
|
23
|
+
type: 'divider',
|
|
24
|
+
description: 'A visual section break rendered as a thin line, a dotted rule, or a gradient bar.',
|
|
25
|
+
schema,
|
|
26
|
+
css,
|
|
27
|
+
render,
|
|
28
|
+
};
|
|
@@ -0,0 +1,52 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `faq` — question/answer accordion. Uses native <details>/<summary>, so it
|
|
3
|
+
* expands/collapses accessibly with ZERO JavaScript (static-first — no island).
|
|
4
|
+
*/
|
|
5
|
+
import { escapeAttr, escapeHtml } from '../schema.js';
|
|
6
|
+
const schema = {
|
|
7
|
+
title: { kind: 'string', default: 'Frequently asked questions', max: 120 },
|
|
8
|
+
items: {
|
|
9
|
+
kind: 'array', max: 20,
|
|
10
|
+
of: {
|
|
11
|
+
kind: 'object',
|
|
12
|
+
fields: {
|
|
13
|
+
question: { kind: 'string', required: true, default: '', max: 200 },
|
|
14
|
+
answer: { kind: 'string', default: '', max: 800 },
|
|
15
|
+
},
|
|
16
|
+
},
|
|
17
|
+
},
|
|
18
|
+
};
|
|
19
|
+
const css = `
|
|
20
|
+
.blk-faq{padding:var(--space-lg) var(--space);background:var(--bg);color:var(--text)}
|
|
21
|
+
.blk-faq .wrap{max-width:760px;margin:0 auto}
|
|
22
|
+
.blk-faq h2{font-size:var(--fs-xl);margin:0 0 var(--space-lg);font-weight:800;text-align:center}
|
|
23
|
+
.blk-faq details{border-bottom:1px solid color-mix(in srgb,var(--text) 12%,transparent);padding:.9em 0}
|
|
24
|
+
.blk-faq summary{cursor:pointer;font-weight:600;font-size:var(--fs-lg);list-style:none;display:flex;justify-content:space-between;gap:1em}
|
|
25
|
+
.blk-faq summary::-webkit-details-marker{display:none}
|
|
26
|
+
.blk-faq summary::after{content:"+";color:var(--accent);transition:transform var(--motion)}
|
|
27
|
+
.blk-faq details[open] summary::after{content:"–"}
|
|
28
|
+
.blk-faq .ans{margin:.6em 0 0;color:var(--muted)}
|
|
29
|
+
`.trim();
|
|
30
|
+
function item(it) {
|
|
31
|
+
if (!it.question)
|
|
32
|
+
return '';
|
|
33
|
+
return `<details>
|
|
34
|
+
<summary>${escapeHtml(it.question)}</summary>
|
|
35
|
+
${it.answer ? `<p class="ans">${escapeHtml(it.answer)}</p>` : ''}
|
|
36
|
+
</details>`;
|
|
37
|
+
}
|
|
38
|
+
function render(config) {
|
|
39
|
+
const title = config.title;
|
|
40
|
+
const items = (config.items ?? []).map(item).filter(Boolean);
|
|
41
|
+
return `<section class="blk-faq" aria-label="${escapeAttr(title || 'FAQ')}">
|
|
42
|
+
<div class="wrap">
|
|
43
|
+
${title ? `<h2>${escapeHtml(title)}</h2>` : ''}
|
|
44
|
+
${items.join('\n ')}
|
|
45
|
+
</div>
|
|
46
|
+
</section>`;
|
|
47
|
+
}
|
|
48
|
+
export const faq = {
|
|
49
|
+
type: 'faq',
|
|
50
|
+
description: 'An accordion of question/answer pairs. Expands/collapses natively (no JS).',
|
|
51
|
+
schema, css, render,
|
|
52
|
+
};
|
|
@@ -0,0 +1,58 @@
|
|
|
1
|
+
/** `features` — icon + title + text grid (value props). Static brick. */
|
|
2
|
+
import { escapeAttr, escapeHtml } from '../schema.js';
|
|
3
|
+
const schema = {
|
|
4
|
+
title: { kind: 'string', default: '', max: 120 },
|
|
5
|
+
subtitle: { kind: 'string', default: '', max: 240 },
|
|
6
|
+
columns: { kind: 'int', oneOf: [2, 3, 4], default: 3 },
|
|
7
|
+
items: {
|
|
8
|
+
kind: 'array', max: 12,
|
|
9
|
+
of: {
|
|
10
|
+
kind: 'object',
|
|
11
|
+
fields: {
|
|
12
|
+
icon: { kind: 'string', default: '', max: 8 }, // emoji or a single glyph
|
|
13
|
+
title: { kind: 'string', required: true, default: 'Feature', max: 80 },
|
|
14
|
+
text: { kind: 'string', default: '', max: 240 },
|
|
15
|
+
},
|
|
16
|
+
},
|
|
17
|
+
},
|
|
18
|
+
};
|
|
19
|
+
const css = `
|
|
20
|
+
.blk-features{padding:var(--space-lg) var(--space);background:var(--bg);color:var(--text)}
|
|
21
|
+
.blk-features .wrap{max-width:1080px;margin:0 auto}
|
|
22
|
+
.blk-features .head{text-align:center;margin-bottom:var(--space-lg)}
|
|
23
|
+
.blk-features h2{font-size:var(--fs-xl);margin:0 0 .3em;font-weight:800}
|
|
24
|
+
.blk-features .sub{color:var(--muted);font-size:var(--fs-lg);margin:0}
|
|
25
|
+
.blk-features .grid{display:grid;grid-template-columns:repeat(var(--cols,3),1fr);gap:calc(var(--space)*1.4)}
|
|
26
|
+
.blk-features .item .ico{font-size:1.8em;line-height:1}
|
|
27
|
+
.blk-features .item h3{margin:.4em 0 .2em;font-size:var(--fs-lg);font-weight:700}
|
|
28
|
+
.blk-features .item p{margin:0;color:var(--muted)}
|
|
29
|
+
@media(max-width:720px){.blk-features .grid{grid-template-columns:1fr 1fr}}
|
|
30
|
+
@media(max-width:440px){.blk-features .grid{grid-template-columns:1fr}}
|
|
31
|
+
`.trim();
|
|
32
|
+
function render(config) {
|
|
33
|
+
const title = config.title;
|
|
34
|
+
const subtitle = config.subtitle;
|
|
35
|
+
const columns = Number.isInteger(config.columns) ? config.columns : 3;
|
|
36
|
+
const items = config.items ?? [];
|
|
37
|
+
const head = (title || subtitle)
|
|
38
|
+
? `<div class="head">${title ? `<h2>${escapeHtml(title)}</h2>` : ''}${subtitle ? `<p class="sub">${escapeHtml(subtitle)}</p>` : ''}</div>`
|
|
39
|
+
: '';
|
|
40
|
+
const cards = items.map((it) => `<div class="item">
|
|
41
|
+
${it.icon ? `<div class="ico" aria-hidden="true">${escapeHtml(it.icon)}</div>` : ''}
|
|
42
|
+
<h3>${escapeHtml(it.title)}</h3>
|
|
43
|
+
${it.text ? `<p>${escapeHtml(it.text)}</p>` : ''}
|
|
44
|
+
</div>`).join('\n ');
|
|
45
|
+
return `<section class="blk-features" aria-label="${escapeAttr(title || 'Features')}">
|
|
46
|
+
<div class="wrap">
|
|
47
|
+
${head}
|
|
48
|
+
<div class="grid" style="--cols:${columns}">
|
|
49
|
+
${cards}
|
|
50
|
+
</div>
|
|
51
|
+
</div>
|
|
52
|
+
</section>`;
|
|
53
|
+
}
|
|
54
|
+
export const features = {
|
|
55
|
+
type: 'features',
|
|
56
|
+
description: 'A grid of value propositions, each with an optional icon/emoji, a title, and a short line of text.',
|
|
57
|
+
schema, css, render,
|
|
58
|
+
};
|
|
@@ -0,0 +1,81 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `feed` — a generic data-driven list of items (list or card layout). Items are
|
|
3
|
+
* static config for now; a host can later back them with a data source via the
|
|
4
|
+
* runtime contract. Static brick.
|
|
5
|
+
*/
|
|
6
|
+
import { escapeAttr, escapeHtml, sanitizeUrl } from '../schema.js';
|
|
7
|
+
const schema = {
|
|
8
|
+
title: { kind: 'string', default: '', max: 120 },
|
|
9
|
+
layout: { kind: 'enum', values: ['list', 'cards'], default: 'list' },
|
|
10
|
+
items: {
|
|
11
|
+
kind: 'array', max: 50,
|
|
12
|
+
of: {
|
|
13
|
+
kind: 'object',
|
|
14
|
+
fields: {
|
|
15
|
+
title: { kind: 'string', required: true, default: '', max: 160 },
|
|
16
|
+
subtitle: { kind: 'string', default: '', max: 120 },
|
|
17
|
+
text: { kind: 'string', default: '', max: 280 },
|
|
18
|
+
href: { kind: 'string', default: '', max: 500 },
|
|
19
|
+
image: { kind: 'string', default: '', max: 500 },
|
|
20
|
+
badge: { kind: 'string', default: '', max: 40 },
|
|
21
|
+
},
|
|
22
|
+
},
|
|
23
|
+
},
|
|
24
|
+
};
|
|
25
|
+
const css = `
|
|
26
|
+
.blk-feed{padding:var(--space-lg) var(--space);background:var(--bg);color:var(--text)}
|
|
27
|
+
.blk-feed .wrap{max-width:900px;margin:0 auto}
|
|
28
|
+
.blk-feed h2{font-size:var(--fs-xl);margin:0 0 var(--space-lg);font-weight:800;text-align:center}
|
|
29
|
+
.blk-feed .items{display:flex;flex-direction:column;gap:.7em}
|
|
30
|
+
.blk-feed.cards .items{display:grid;grid-template-columns:repeat(auto-fill,minmax(240px,1fr));gap:calc(var(--space)*1.1)}
|
|
31
|
+
.blk-feed .item{display:flex;gap:1em;align-items:center;background:var(--surface);border-radius:var(--radius);padding:.9em 1em;text-decoration:none;color:inherit}
|
|
32
|
+
.blk-feed.cards .item{flex-direction:column;align-items:stretch}
|
|
33
|
+
.blk-feed a.item{transition:transform var(--motion)}
|
|
34
|
+
.blk-feed a.item:hover{transform:translateY(-2px)}
|
|
35
|
+
.blk-feed .thumb{width:56px;height:56px;border-radius:var(--radius);object-fit:cover;flex:0 0 auto}
|
|
36
|
+
.blk-feed.cards .thumb{width:100%;height:140px}
|
|
37
|
+
.blk-feed .it-body{flex:1;min-width:0}
|
|
38
|
+
.blk-feed .it-top{display:flex;gap:.5em;align-items:baseline;justify-content:space-between}
|
|
39
|
+
.blk-feed .it-title{margin:0;font-size:var(--fs-lg);font-weight:700}
|
|
40
|
+
.blk-feed .badge{color:var(--accent);font-weight:700;font-size:var(--fs-base);flex:0 0 auto}
|
|
41
|
+
.blk-feed .it-sub{margin:.1em 0 0;color:var(--muted);font-size:var(--fs-base)}
|
|
42
|
+
.blk-feed .it-text{margin:.3em 0 0;color:var(--muted);font-size:var(--fs-base);line-height:1.5}
|
|
43
|
+
`.trim();
|
|
44
|
+
function item(it) {
|
|
45
|
+
if (!it.title)
|
|
46
|
+
return '';
|
|
47
|
+
const href = sanitizeUrl(it.href);
|
|
48
|
+
const linked = href !== '#' && !!it.href;
|
|
49
|
+
const img = sanitizeUrl(it.image);
|
|
50
|
+
const thumb = (img !== '#' && it.image) ? `<img class="thumb" src="${escapeAttr(img)}" alt="${escapeAttr(it.title)}" loading="lazy">` : '';
|
|
51
|
+
const inner = `${thumb}
|
|
52
|
+
<div class="it-body">
|
|
53
|
+
<div class="it-top">
|
|
54
|
+
<p class="it-title">${escapeHtml(it.title)}</p>
|
|
55
|
+
${it.badge ? `<span class="badge">${escapeHtml(it.badge)}</span>` : ''}
|
|
56
|
+
</div>
|
|
57
|
+
${it.subtitle ? `<p class="it-sub">${escapeHtml(it.subtitle)}</p>` : ''}
|
|
58
|
+
${it.text ? `<p class="it-text">${escapeHtml(it.text)}</p>` : ''}
|
|
59
|
+
</div>`;
|
|
60
|
+
return linked ? `<a class="item" href="${escapeAttr(href)}">${inner}</a>` : `<div class="item">${inner}</div>`;
|
|
61
|
+
}
|
|
62
|
+
function render(config) {
|
|
63
|
+
const title = config.title;
|
|
64
|
+
const layout = config.layout === 'cards' ? 'cards' : 'list';
|
|
65
|
+
const items = (config.items ?? []).map(item).filter(Boolean);
|
|
66
|
+
return `<section class="blk-feed ${layout}" aria-label="${escapeAttr(title || 'Feed')}">
|
|
67
|
+
<div class="wrap">
|
|
68
|
+
${title ? `<h2>${escapeHtml(title)}</h2>` : ''}
|
|
69
|
+
<div class="items">
|
|
70
|
+
${items.join('\n ')}
|
|
71
|
+
</div>
|
|
72
|
+
</div>
|
|
73
|
+
</section>`;
|
|
74
|
+
}
|
|
75
|
+
export const feed = {
|
|
76
|
+
type: 'feed',
|
|
77
|
+
description: 'A generic data-driven list of items in a list or card layout, each with a title, optional subtitle/text, badge, image, and link.',
|
|
78
|
+
schema,
|
|
79
|
+
css,
|
|
80
|
+
render,
|
|
81
|
+
};
|
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
/** `footer` — brand, links, copyright. Static brick. Place last. */
|
|
2
|
+
import { escapeAttr, escapeHtml, sanitizeUrl } from '../schema.js';
|
|
3
|
+
const schema = {
|
|
4
|
+
brand: { kind: 'string', default: '', max: 60 },
|
|
5
|
+
tagline: { kind: 'string', default: '', max: 160 },
|
|
6
|
+
links: {
|
|
7
|
+
kind: 'array', max: 12,
|
|
8
|
+
of: { kind: 'object', fields: { label: { kind: 'string', required: true, default: 'Link', max: 40 }, href: { kind: 'string', default: '#', max: 300 } } },
|
|
9
|
+
},
|
|
10
|
+
copyright: { kind: 'string', default: '', max: 160 },
|
|
11
|
+
};
|
|
12
|
+
const css = `
|
|
13
|
+
.blk-footer{padding:var(--space-lg) var(--space);background:var(--text);color:color-mix(in srgb,var(--surface) 82%,var(--text))}
|
|
14
|
+
.blk-footer .wrap{max-width:1120px;margin:0 auto;display:flex;flex-wrap:wrap;gap:var(--space);align-items:baseline;justify-content:space-between}
|
|
15
|
+
.blk-footer .brand{font-weight:800;font-size:var(--fs-lg);color:var(--surface)}
|
|
16
|
+
.blk-footer .tagline{margin:.3em 0 0;opacity:.7;max-width:40ch}
|
|
17
|
+
.blk-footer .links{display:flex;flex-wrap:wrap;gap:calc(var(--space)*1.1)}
|
|
18
|
+
.blk-footer .links a{color:inherit;text-decoration:none;opacity:.85}
|
|
19
|
+
.blk-footer .links a:hover{opacity:1}
|
|
20
|
+
.blk-footer .copy{width:100%;opacity:.6;font-size:var(--fs-base);margin-top:var(--space)}
|
|
21
|
+
`.trim();
|
|
22
|
+
function render(config) {
|
|
23
|
+
const brand = config.brand;
|
|
24
|
+
const tagline = config.tagline;
|
|
25
|
+
const links = config.links ?? [];
|
|
26
|
+
const copyright = config.copyright;
|
|
27
|
+
const linkHtml = links.map((l) => `<a href="${escapeAttr(sanitizeUrl(l.href))}">${escapeHtml(l.label)}</a>`).join('');
|
|
28
|
+
return `<footer class="blk-footer">
|
|
29
|
+
<div class="wrap">
|
|
30
|
+
<div>${brand ? `<div class="brand">${escapeHtml(brand)}</div>` : ''}${tagline ? `<p class="tagline">${escapeHtml(tagline)}</p>` : ''}</div>
|
|
31
|
+
${links.length ? `<nav class="links" aria-label="Footer">${linkHtml}</nav>` : ''}
|
|
32
|
+
${copyright ? `<div class="copy">${escapeHtml(copyright)}</div>` : ''}
|
|
33
|
+
</div>
|
|
34
|
+
</footer>`;
|
|
35
|
+
}
|
|
36
|
+
export const footer = {
|
|
37
|
+
type: 'footer',
|
|
38
|
+
description: 'Page footer: brand, tagline, a row of links, and a copyright line. Place last.',
|
|
39
|
+
schema, css, render,
|
|
40
|
+
};
|
|
@@ -0,0 +1,64 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `gallery` — responsive image grid. Static markup; the lightbox is a client
|
|
3
|
+
* "island" (declared via `island`), hydrated only when `lightbox` is true.
|
|
4
|
+
*/
|
|
5
|
+
import { escapeAttr, escapeHtml } from '../schema.js';
|
|
6
|
+
const schema = {
|
|
7
|
+
layout: { kind: 'enum', values: ['grid', 'masonry', 'carousel'], default: 'grid' },
|
|
8
|
+
columns: { kind: 'int', oneOf: [2, 3, 4], default: 3 },
|
|
9
|
+
gap: { kind: 'enum', values: ['sm', 'md', 'lg'], default: 'md' },
|
|
10
|
+
lightbox: { kind: 'boolean', default: true },
|
|
11
|
+
items: {
|
|
12
|
+
kind: 'array',
|
|
13
|
+
max: 60,
|
|
14
|
+
of: {
|
|
15
|
+
kind: 'object',
|
|
16
|
+
fields: {
|
|
17
|
+
src: { kind: 'string', required: true, default: '', max: 500 },
|
|
18
|
+
alt: { kind: 'string', required: true, default: '', max: 200 },
|
|
19
|
+
caption: { kind: 'string', default: '', max: 200 },
|
|
20
|
+
},
|
|
21
|
+
},
|
|
22
|
+
},
|
|
23
|
+
};
|
|
24
|
+
const GAP_REM = { sm: '0.4rem', md: '0.8rem', lg: '1.4rem' };
|
|
25
|
+
const css = `
|
|
26
|
+
.blk-gallery{padding:var(--space-lg) var(--space);background:var(--bg)}
|
|
27
|
+
.blk-gallery .wrap{max-width:1120px;margin:0 auto}
|
|
28
|
+
.blk-gallery .grid{display:grid;grid-template-columns:repeat(var(--cols,3),1fr);gap:var(--gap,0.8rem)}
|
|
29
|
+
.blk-gallery figure{margin:0;border-radius:var(--radius);overflow:hidden;background:var(--surface)}
|
|
30
|
+
.blk-gallery img{display:block;width:100%;height:100%;object-fit:cover;aspect-ratio:4/3}
|
|
31
|
+
.blk-gallery figcaption{padding:.5em .7em;color:var(--muted);font-size:var(--fs-base)}
|
|
32
|
+
@media(max-width:720px){.blk-gallery .grid{grid-template-columns:repeat(2,1fr)}}
|
|
33
|
+
@media(max-width:440px){.blk-gallery .grid{grid-template-columns:1fr}}
|
|
34
|
+
`.trim();
|
|
35
|
+
function figure(it) {
|
|
36
|
+
// Drop items with no source rather than emit a broken <img> (total render).
|
|
37
|
+
if (!it.src)
|
|
38
|
+
return '';
|
|
39
|
+
return `<figure>
|
|
40
|
+
<img src="${escapeAttr(it.src)}" alt="${escapeAttr(it.alt)}" loading="lazy">
|
|
41
|
+
${it.caption ? `<figcaption>${escapeHtml(it.caption)}</figcaption>` : ''}
|
|
42
|
+
</figure>`;
|
|
43
|
+
}
|
|
44
|
+
function render(config) {
|
|
45
|
+
const columns = Number.isInteger(config.columns) ? config.columns : 3;
|
|
46
|
+
const gap = GAP_REM[config.gap] ?? GAP_REM.md;
|
|
47
|
+
const items = (config.items ?? []).map(figure).filter(Boolean);
|
|
48
|
+
return `<section class="blk-gallery" aria-label="Gallery">
|
|
49
|
+
<div class="wrap">
|
|
50
|
+
<div class="grid" style="--cols:${columns};--gap:${gap}">
|
|
51
|
+
${items.join('\n ')}
|
|
52
|
+
</div>
|
|
53
|
+
</div>
|
|
54
|
+
</section>`;
|
|
55
|
+
}
|
|
56
|
+
export const gallery = {
|
|
57
|
+
type: 'gallery',
|
|
58
|
+
description: 'A responsive image grid (grid/masonry/carousel) with an optional click-to-zoom lightbox. Each item needs a src and alt text.',
|
|
59
|
+
schema,
|
|
60
|
+
css,
|
|
61
|
+
render,
|
|
62
|
+
// Only hydrated when lightbox:true (see registry.needsIsland). Static otherwise.
|
|
63
|
+
island: 'lightbox',
|
|
64
|
+
};
|
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
/** `hero` — headline + optional eyebrow/subhead/CTA. Static brick. */
|
|
2
|
+
import { escapeAttr, escapeHtml, sanitizeUrl } from '../schema.js';
|
|
3
|
+
const schema = {
|
|
4
|
+
eyebrow: { kind: 'string', default: '', max: 80 },
|
|
5
|
+
headline: { kind: 'string', required: true, default: 'Your headline here', max: 160 },
|
|
6
|
+
subhead: { kind: 'string', default: '', max: 320 },
|
|
7
|
+
align: { kind: 'enum', values: ['center', 'left'], default: 'center' },
|
|
8
|
+
cta: {
|
|
9
|
+
kind: 'object',
|
|
10
|
+
fields: {
|
|
11
|
+
label: { kind: 'string', default: '', max: 40 },
|
|
12
|
+
href: { kind: 'string', default: '#', max: 500 },
|
|
13
|
+
},
|
|
14
|
+
},
|
|
15
|
+
};
|
|
16
|
+
const css = `
|
|
17
|
+
.blk-hero{padding:var(--space-lg) var(--space);background:var(--bg);color:var(--text)}
|
|
18
|
+
.blk-hero .wrap{max-width:960px;margin:0 auto;display:flex;flex-direction:column;gap:calc(var(--space)*0.9)}
|
|
19
|
+
.blk-hero[data-align="center"] .wrap{align-items:center;text-align:center}
|
|
20
|
+
.blk-hero .eyebrow{font-size:var(--fs-base);letter-spacing:.08em;text-transform:uppercase;color:var(--accent);font-weight:600}
|
|
21
|
+
.blk-hero h1{font-size:var(--fs-xl);line-height:1.05;margin:0;font-weight:800}
|
|
22
|
+
.blk-hero p{font-size:var(--fs-lg);color:var(--muted);margin:0;max-width:60ch}
|
|
23
|
+
.blk-hero .cta{display:inline-block;background:var(--primary);color:var(--on-primary);padding:.7em 1.4em;border-radius:var(--radius);text-decoration:none;font-weight:600;transition:transform var(--motion)}
|
|
24
|
+
.blk-hero .cta:hover{transform:translateY(-1px)}
|
|
25
|
+
`.trim();
|
|
26
|
+
function render(config) {
|
|
27
|
+
const eyebrow = config.eyebrow;
|
|
28
|
+
const headline = config.headline;
|
|
29
|
+
const subhead = config.subhead;
|
|
30
|
+
const align = config.align;
|
|
31
|
+
const cta = config.cta;
|
|
32
|
+
const parts = [
|
|
33
|
+
eyebrow ? `<p class="eyebrow">${escapeHtml(eyebrow)}</p>` : '',
|
|
34
|
+
`<h1>${escapeHtml(headline)}</h1>`,
|
|
35
|
+
subhead ? `<p>${escapeHtml(subhead)}</p>` : '',
|
|
36
|
+
cta.label ? `<a class="cta" href="${escapeAttr(sanitizeUrl(cta.href))}">${escapeHtml(cta.label)}</a>` : '',
|
|
37
|
+
].filter(Boolean).join('\n ');
|
|
38
|
+
return `<section class="blk-hero" data-align="${escapeAttr(align)}" aria-label="Hero">
|
|
39
|
+
<div class="wrap">
|
|
40
|
+
${parts}
|
|
41
|
+
</div>
|
|
42
|
+
</section>`;
|
|
43
|
+
}
|
|
44
|
+
export const hero = {
|
|
45
|
+
type: 'hero',
|
|
46
|
+
description: 'Top-of-page banner: a big headline with an optional eyebrow, subheading, and one call-to-action button.',
|
|
47
|
+
schema, css, render,
|
|
48
|
+
};
|
|
@@ -0,0 +1,65 @@
|
|
|
1
|
+
/** `hero-app` — app landing hero: install CTA + a row of screenshots. Static brick. */
|
|
2
|
+
import { escapeAttr, escapeHtml, sanitizeUrl } from '../schema.js';
|
|
3
|
+
const schema = {
|
|
4
|
+
headline: { kind: 'string', required: true, default: '', max: 120 },
|
|
5
|
+
subhead: { kind: 'string', default: '', max: 240 },
|
|
6
|
+
installLabel: { kind: 'string', default: 'Install app', max: 40 },
|
|
7
|
+
installHref: { kind: 'string', default: '', max: 500 },
|
|
8
|
+
screenshots: {
|
|
9
|
+
kind: 'array',
|
|
10
|
+
max: 6,
|
|
11
|
+
of: {
|
|
12
|
+
kind: 'object',
|
|
13
|
+
fields: {
|
|
14
|
+
src: { kind: 'string', required: true, default: '', max: 500 },
|
|
15
|
+
alt: { kind: 'string', default: '', max: 200 },
|
|
16
|
+
},
|
|
17
|
+
},
|
|
18
|
+
},
|
|
19
|
+
};
|
|
20
|
+
const css = `
|
|
21
|
+
.blk-hero-app{padding:var(--space-lg) var(--space);background:var(--bg);color:var(--text);font-family:var(--font);text-align:center}
|
|
22
|
+
.blk-hero-app .wrap{max-width:1080px;margin:0 auto}
|
|
23
|
+
.blk-hero-app h1{font-size:var(--fs-xl);margin:0 0 .3em;font-weight:800;line-height:1.1}
|
|
24
|
+
.blk-hero-app .sub{color:var(--muted);font-size:var(--fs-lg);margin:0 auto;max-width:60ch}
|
|
25
|
+
.blk-hero-app .cta{margin:var(--space-lg) 0 0}
|
|
26
|
+
.blk-hero-app .install{display:inline-block;padding:.7em 1.6em;border-radius:var(--radius);background:var(--primary);color:var(--on-primary);text-decoration:none;font-weight:700;font-size:var(--fs-lg);transition:filter var(--motion)}
|
|
27
|
+
.blk-hero-app .install:hover,.blk-hero-app .install:focus-visible{filter:brightness(1.08)}
|
|
28
|
+
.blk-hero-app .shots{display:flex;gap:var(--space);overflow-x:auto;scroll-snap-type:x mandatory;padding:var(--space-lg) .2em .4em;margin-top:var(--space);justify-content:flex-start}
|
|
29
|
+
.blk-hero-app .shots::-webkit-scrollbar{height:8px}
|
|
30
|
+
.blk-hero-app .shot{flex:0 0 auto;width:min(240px,60vw);scroll-snap-align:center}
|
|
31
|
+
.blk-hero-app .shot img{display:block;width:100%;border-radius:calc(var(--radius)*1.5);background:var(--surface);border:1px solid color-mix(in srgb, var(--text) 15%, transparent);aspect-ratio:9/19;object-fit:cover}
|
|
32
|
+
@media(min-width:720px){.blk-hero-app .shots{justify-content:center}}
|
|
33
|
+
@media(max-width:440px){.blk-hero-app .shot{width:70vw}}
|
|
34
|
+
`.trim();
|
|
35
|
+
function shot(s) {
|
|
36
|
+
// Drop items with no source rather than emit a broken <img> (total render).
|
|
37
|
+
if (!s.src)
|
|
38
|
+
return '';
|
|
39
|
+
return `<div class="shot"><img src="${escapeAttr(sanitizeUrl(s.src))}" alt="${escapeAttr(s.alt)}" loading="lazy"></div>`;
|
|
40
|
+
}
|
|
41
|
+
function render(config) {
|
|
42
|
+
const headline = config.headline;
|
|
43
|
+
const subhead = config.subhead;
|
|
44
|
+
const installLabel = config.installLabel || 'Install app';
|
|
45
|
+
const installHref = escapeAttr(sanitizeUrl(config.installHref || '#'));
|
|
46
|
+
const shots = (config.screenshots ?? []).map(shot).filter(Boolean);
|
|
47
|
+
const gallery = shots.length
|
|
48
|
+
? `<div class="shots">\n ${shots.join('\n ')}\n </div>`
|
|
49
|
+
: '';
|
|
50
|
+
return `<section class="blk-hero-app" aria-label="${escapeAttr(headline || 'App')}">
|
|
51
|
+
<div class="wrap">
|
|
52
|
+
${headline ? `<h1>${escapeHtml(headline)}</h1>` : ''}
|
|
53
|
+
${subhead ? `<p class="sub">${escapeHtml(subhead)}</p>` : ''}
|
|
54
|
+
<div class="cta"><a class="install" href="${installHref}">${escapeHtml(installLabel)}</a></div>
|
|
55
|
+
${gallery}
|
|
56
|
+
</div>
|
|
57
|
+
</section>`;
|
|
58
|
+
}
|
|
59
|
+
export const heroApp = {
|
|
60
|
+
type: 'hero-app',
|
|
61
|
+
description: 'An app landing hero: a headline and subhead over a primary install button, with a horizontal scroll row of app screenshots.',
|
|
62
|
+
schema,
|
|
63
|
+
css,
|
|
64
|
+
render,
|
|
65
|
+
};
|