@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.
Files changed (109) hide show
  1. package/AGENT.md +214 -0
  2. package/CATALOG.md +387 -0
  3. package/CHANGELOG.md +63 -0
  4. package/LICENSE +21 -0
  5. package/README.md +294 -0
  6. package/catalog.json +1850 -0
  7. package/lib/blocks/about.d.ts +2 -0
  8. package/lib/blocks/about.js +50 -0
  9. package/lib/blocks/accordion.d.ts +2 -0
  10. package/lib/blocks/accordion.js +51 -0
  11. package/lib/blocks/announcementBar.d.ts +2 -0
  12. package/lib/blocks/announcementBar.js +56 -0
  13. package/lib/blocks/appShell.d.ts +2 -0
  14. package/lib/blocks/appShell.js +54 -0
  15. package/lib/blocks/auth.d.ts +2 -0
  16. package/lib/blocks/auth.js +82 -0
  17. package/lib/blocks/blogList.d.ts +2 -0
  18. package/lib/blocks/blogList.js +81 -0
  19. package/lib/blocks/blogPost.d.ts +2 -0
  20. package/lib/blocks/blogPost.js +60 -0
  21. package/lib/blocks/carousel.d.ts +2 -0
  22. package/lib/blocks/carousel.js +56 -0
  23. package/lib/blocks/contactDetails.d.ts +2 -0
  24. package/lib/blocks/contactDetails.js +50 -0
  25. package/lib/blocks/contactForm.d.ts +2 -0
  26. package/lib/blocks/contactForm.js +100 -0
  27. package/lib/blocks/cta.d.ts +2 -0
  28. package/lib/blocks/cta.js +40 -0
  29. package/lib/blocks/divider.d.ts +2 -0
  30. package/lib/blocks/divider.js +28 -0
  31. package/lib/blocks/faq.d.ts +2 -0
  32. package/lib/blocks/faq.js +52 -0
  33. package/lib/blocks/features.d.ts +2 -0
  34. package/lib/blocks/features.js +58 -0
  35. package/lib/blocks/feed.d.ts +2 -0
  36. package/lib/blocks/feed.js +81 -0
  37. package/lib/blocks/footer.d.ts +2 -0
  38. package/lib/blocks/footer.js +40 -0
  39. package/lib/blocks/gallery.d.ts +2 -0
  40. package/lib/blocks/gallery.js +64 -0
  41. package/lib/blocks/hero.d.ts +2 -0
  42. package/lib/blocks/hero.js +48 -0
  43. package/lib/blocks/heroApp.d.ts +2 -0
  44. package/lib/blocks/heroApp.js +65 -0
  45. package/lib/blocks/logos.d.ts +2 -0
  46. package/lib/blocks/logos.js +59 -0
  47. package/lib/blocks/map.d.ts +2 -0
  48. package/lib/blocks/map.js +43 -0
  49. package/lib/blocks/nav.d.ts +2 -0
  50. package/lib/blocks/nav.js +39 -0
  51. package/lib/blocks/newsletter.d.ts +2 -0
  52. package/lib/blocks/newsletter.js +56 -0
  53. package/lib/blocks/pricing.d.ts +2 -0
  54. package/lib/blocks/pricing.js +89 -0
  55. package/lib/blocks/prose.d.ts +5 -0
  56. package/lib/blocks/prose.js +46 -0
  57. package/lib/blocks/richText.d.ts +2 -0
  58. package/lib/blocks/richText.js +46 -0
  59. package/lib/blocks/services.d.ts +2 -0
  60. package/lib/blocks/services.js +65 -0
  61. package/lib/blocks/sidebar.d.ts +2 -0
  62. package/lib/blocks/sidebar.js +58 -0
  63. package/lib/blocks/socialLinks.d.ts +2 -0
  64. package/lib/blocks/socialLinks.js +53 -0
  65. package/lib/blocks/spacer.d.ts +2 -0
  66. package/lib/blocks/spacer.js +25 -0
  67. package/lib/blocks/split.d.ts +2 -0
  68. package/lib/blocks/split.js +67 -0
  69. package/lib/blocks/stats.d.ts +2 -0
  70. package/lib/blocks/stats.js +64 -0
  71. package/lib/blocks/steps.d.ts +2 -0
  72. package/lib/blocks/steps.js +63 -0
  73. package/lib/blocks/tabs.d.ts +2 -0
  74. package/lib/blocks/tabs.js +57 -0
  75. package/lib/blocks/team.d.ts +2 -0
  76. package/lib/blocks/team.js +94 -0
  77. package/lib/blocks/testimonials.d.ts +2 -0
  78. package/lib/blocks/testimonials.js +54 -0
  79. package/lib/blocks/timeline.d.ts +2 -0
  80. package/lib/blocks/timeline.js +53 -0
  81. package/lib/blocks/video.d.ts +2 -0
  82. package/lib/blocks/video.js +61 -0
  83. package/lib/catalog.d.ts +26 -0
  84. package/lib/catalog.js +62 -0
  85. package/lib/generate.d.ts +35 -0
  86. package/lib/generate.js +160 -0
  87. package/lib/index.d.ts +19 -0
  88. package/lib/index.js +11 -0
  89. package/lib/ops.d.ts +85 -0
  90. package/lib/ops.js +248 -0
  91. package/lib/presets.d.ts +16 -0
  92. package/lib/presets.js +50 -0
  93. package/lib/pwa.d.ts +30 -0
  94. package/lib/pwa.js +79 -0
  95. package/lib/registry.d.ts +53 -0
  96. package/lib/registry.js +81 -0
  97. package/lib/render.d.ts +8 -0
  98. package/lib/render.js +105 -0
  99. package/lib/runtime.d.ts +68 -0
  100. package/lib/runtime.js +46 -0
  101. package/lib/schema.d.ts +67 -0
  102. package/lib/schema.js +150 -0
  103. package/lib/tokens.d.ts +37 -0
  104. package/lib/tokens.js +136 -0
  105. package/lib/types.d.ts +108 -0
  106. package/lib/types.js +10 -0
  107. package/lib/validate.d.ts +10 -0
  108. package/lib/validate.js +48 -0
  109. package/package.json +65 -0
@@ -0,0 +1,59 @@
1
+ /** `logos` — a "trusted by" strip of client/partner logos. Static brick. */
2
+ import { escapeAttr, escapeHtml, sanitizeUrl } from '../schema.js';
3
+ const schema = {
4
+ title: { kind: 'string', default: 'Trusted by', max: 120 },
5
+ items: {
6
+ kind: 'array', max: 24,
7
+ of: {
8
+ kind: 'object',
9
+ fields: {
10
+ src: { kind: 'string', required: true, default: '', max: 500 },
11
+ alt: { kind: 'string', default: '', max: 120 },
12
+ href: { kind: 'string', default: '', max: 500 },
13
+ },
14
+ },
15
+ },
16
+ };
17
+ const css = `
18
+ .blk-logos{padding:var(--space-lg) var(--space);background:var(--bg);color:var(--text)}
19
+ .blk-logos .wrap{max-width:1120px;margin:0 auto;text-align:center}
20
+ .blk-logos h2{font-size:var(--fs-lg);font-weight:700;color:var(--muted);margin:0 0 var(--space)}
21
+ .blk-logos .strip{display:flex;flex-wrap:wrap;justify-content:center;align-items:center;gap:calc(var(--space)*1.6)}
22
+ .blk-logos .logo{display:inline-flex}
23
+ .blk-logos img{display:block;height:2.2rem;width:auto;max-width:160px;object-fit:contain;filter:grayscale(1);opacity:.65;transition:filter var(--motion),opacity var(--motion)}
24
+ .blk-logos .logo:hover img,.blk-logos a:hover img{filter:grayscale(0);opacity:1}
25
+ @media(max-width:440px){.blk-logos .strip{gap:var(--space)}.blk-logos img{height:1.8rem}}
26
+ `.trim();
27
+ function logo(it) {
28
+ // Drop items with no source rather than emit a broken <img>.
29
+ if (!it.src)
30
+ return '';
31
+ const src = sanitizeUrl(it.src);
32
+ if (src === '#')
33
+ return '';
34
+ const img = `<img src="${escapeAttr(src)}" alt="${escapeAttr(it.alt)}" loading="lazy">`;
35
+ const href = sanitizeUrl(it.href);
36
+ const hasHref = href !== '#' && !!it.href;
37
+ return hasHref
38
+ ? `<a class="logo" href="${escapeAttr(href)}" rel="noopener">${img}</a>`
39
+ : `<span class="logo">${img}</span>`;
40
+ }
41
+ function render(config) {
42
+ const title = config.title;
43
+ const items = (config.items ?? []).map(logo).filter(Boolean);
44
+ return `<section class="blk-logos" aria-label="${escapeAttr(title || 'Logos')}">
45
+ <div class="wrap">
46
+ ${title ? `<h2>${escapeHtml(title)}</h2>` : ''}
47
+ <div class="strip">
48
+ ${items.join('\n ')}
49
+ </div>
50
+ </div>
51
+ </section>`;
52
+ }
53
+ export const logos = {
54
+ type: 'logos',
55
+ description: 'A centered strip of client or partner logos (grayscale until hover), each optionally linking out, under a short heading.',
56
+ schema,
57
+ css,
58
+ render,
59
+ };
@@ -0,0 +1,2 @@
1
+ import type { BlockSpec } from '../registry.js';
2
+ export declare const map: BlockSpec;
@@ -0,0 +1,43 @@
1
+ /**
2
+ * `map` — a location map embedded from a place query. The query is URL-encoded
3
+ * into a standard maps embed; no raw URL is interpolated. Static brick (embed).
4
+ */
5
+ import { escapeAttr } from '../schema.js';
6
+ const schema = {
7
+ query: { kind: 'string', required: true, default: '', max: 200 },
8
+ zoom: { kind: 'int', min: 1, max: 20, default: 14 },
9
+ height: { kind: 'int', min: 160, max: 800, default: 360 },
10
+ label: { kind: 'string', default: '', max: 120 },
11
+ };
12
+ const css = `
13
+ .blk-map{padding:var(--space-lg) var(--space);background:var(--bg);color:var(--text)}
14
+ .blk-map .wrap{max-width:1000px;margin:0 auto}
15
+ .blk-map .frame{border-radius:var(--radius);overflow:hidden;background:var(--surface)}
16
+ .blk-map iframe{display:block;width:100%;border:0}
17
+ .blk-map .more{display:inline-block;margin:.6em 0 0;color:var(--primary);font-weight:600;font-size:var(--fs-base);text-decoration:none}
18
+ .blk-map .more:hover,.blk-map .more:focus-visible{text-decoration:underline}
19
+ `.trim();
20
+ function render(config) {
21
+ const query = String(config.query ?? '');
22
+ const zoom = Number.isInteger(config.zoom) ? config.zoom : 14;
23
+ const height = Number.isInteger(config.height) ? config.height : 360;
24
+ const label = config.label || query;
25
+ const enc = encodeURIComponent(query);
26
+ const embed = query ? `https://maps.google.com/maps?q=${enc}&z=${zoom}&output=embed` : '';
27
+ const link = `https://maps.google.com/maps?q=${enc}`;
28
+ return `<section class="blk-map" aria-label="${escapeAttr(label || 'Map')}">
29
+ <div class="wrap">
30
+ <div class="frame">
31
+ ${embed ? `<iframe src="${escapeAttr(embed)}" title="${escapeAttr(label || 'Map')}" height="${height}" loading="lazy"></iframe>` : ''}
32
+ </div>
33
+ ${query ? `<a class="more" href="${escapeAttr(link)}" rel="noopener" target="_blank">View larger map</a>` : ''}
34
+ </div>
35
+ </section>`;
36
+ }
37
+ export const map = {
38
+ type: 'map',
39
+ description: 'An embedded location map for a place or address query, with configurable zoom and height and a link to the full map.',
40
+ schema,
41
+ css,
42
+ render,
43
+ };
@@ -0,0 +1,2 @@
1
+ import type { BlockSpec } from '../registry.js';
2
+ export declare const nav: BlockSpec;
@@ -0,0 +1,39 @@
1
+ /** `nav` — header bar: brand, links, optional CTA. Static brick. */
2
+ import { escapeAttr, escapeHtml, sanitizeUrl } from '../schema.js';
3
+ const schema = {
4
+ brand: { kind: 'string', required: true, default: 'Brand', max: 60 },
5
+ sticky: { kind: 'boolean', default: false },
6
+ links: {
7
+ kind: 'array', max: 8,
8
+ of: { kind: 'object', fields: { label: { kind: 'string', required: true, default: 'Link', max: 40 }, href: { kind: 'string', default: '#', max: 300 } } },
9
+ },
10
+ cta: { kind: 'object', fields: { label: { kind: 'string', default: '', max: 40 }, href: { kind: 'string', default: '#', max: 300 } } },
11
+ };
12
+ const css = `
13
+ .blk-nav{background:var(--surface);border-bottom:1px solid color-mix(in srgb,var(--text) 10%,transparent)}
14
+ .blk-nav[data-sticky="true"]{position:sticky;top:0;z-index:10}
15
+ .blk-nav .wrap{max-width:1120px;margin:0 auto;display:flex;align-items:center;gap:calc(var(--space)*1.2);padding:.9rem var(--space)}
16
+ .blk-nav .brand{font-weight:800;font-size:var(--fs-lg);color:var(--text);text-decoration:none}
17
+ .blk-nav .links{display:flex;gap:calc(var(--space)*1.1);margin-left:auto;flex-wrap:wrap}
18
+ .blk-nav .links a{color:var(--muted);text-decoration:none;font-weight:500}
19
+ .blk-nav .links a:hover{color:var(--text)}
20
+ .blk-nav .cta{background:var(--primary);color:var(--on-primary);padding:.5em 1.1em;border-radius:var(--radius);text-decoration:none;font-weight:600}
21
+ `.trim();
22
+ function render(config) {
23
+ const brand = config.brand;
24
+ const sticky = config.sticky;
25
+ const links = config.links ?? [];
26
+ const cta = config.cta;
27
+ const linkHtml = links.map((l) => `<a href="${escapeAttr(sanitizeUrl(l.href))}">${escapeHtml(l.label)}</a>`).join('');
28
+ return `<nav class="blk-nav" data-sticky="${sticky ? 'true' : 'false'}" aria-label="Primary">
29
+ <div class="wrap">
30
+ <a class="brand" href="#">${escapeHtml(brand)}</a>
31
+ <div class="links">${linkHtml}${cta.label ? `<a class="cta" href="${escapeAttr(sanitizeUrl(cta.href))}">${escapeHtml(cta.label)}</a>` : ''}</div>
32
+ </div>
33
+ </nav>`;
34
+ }
35
+ export const nav = {
36
+ type: 'nav',
37
+ description: 'Top navigation bar: brand/logo text, a row of links, and an optional call-to-action button. Place first.',
38
+ schema, css, render,
39
+ };
@@ -0,0 +1,2 @@
1
+ import type { BlockSpec } from '../registry.js';
2
+ export declare const newsletter: BlockSpec;
@@ -0,0 +1,56 @@
1
+ /**
2
+ * `newsletter` — email capture. Powered brick (§6): the submit target is resolved
3
+ * by the host via the `newsletter.subscribe` capability. With no runtime wired it
4
+ * renders inert-but-valid (disabled, with a note) while keeping the `data-wl-*`
5
+ * hooks so a host can enhance it client-side.
6
+ */
7
+ import { escapeAttr, escapeHtml } from '../schema.js';
8
+ import { NOOP_RUNTIME, safeMethod } from '../runtime.js';
9
+ const CAPABILITY = 'newsletter.subscribe';
10
+ const schema = {
11
+ title: { kind: 'string', default: 'Subscribe to our newsletter', max: 120 },
12
+ intro: { kind: 'string', default: '', max: 240 },
13
+ placeholder: { kind: 'string', default: 'you@example.com', max: 80 },
14
+ submitLabel: { kind: 'string', default: 'Subscribe', max: 40 },
15
+ successMessage: { kind: 'string', default: "You're in — check your inbox.", max: 200 },
16
+ };
17
+ const css = `
18
+ .blk-newsletter{padding:var(--space-lg) var(--space);background:var(--bg);color:var(--text);text-align:center}
19
+ .blk-newsletter .wrap{max-width:560px;margin:0 auto}
20
+ .blk-newsletter h2{font-size:var(--fs-xl);margin:0 0 .3em;font-weight:800}
21
+ .blk-newsletter .intro{color:var(--muted);margin:0 0 var(--space);font-size:var(--fs-lg)}
22
+ .blk-newsletter form{display:flex;gap:.5em;flex-wrap:wrap;justify-content:center}
23
+ .blk-newsletter input{flex:1 1 220px;font:inherit;color:inherit;background:var(--surface);border:1px solid color-mix(in srgb,var(--text) 18%,transparent);border-radius:var(--radius);padding:.7em .8em}
24
+ .blk-newsletter button{font:inherit;font-weight:700;cursor:pointer;border:0;border-radius:var(--radius);padding:.7em 1.4em;background:var(--primary);color:var(--on-primary);transition:opacity var(--motion)}
25
+ .blk-newsletter button[disabled]{opacity:.55;cursor:not-allowed}
26
+ .blk-newsletter .note{margin:.5em 0 0;font-size:var(--fs-base);color:var(--muted)}
27
+ `.trim();
28
+ function render(config, _tokens, ctx) {
29
+ const id = ctx?.id ?? 'newsletter';
30
+ const runtime = ctx?.runtime ?? NOOP_RUNTIME;
31
+ const title = config.title;
32
+ const intro = config.intro;
33
+ const placeholder = config.placeholder || 'you@example.com';
34
+ const submitLabel = config.submitLabel || 'Subscribe';
35
+ const action = runtime.resolve(CAPABILITY, id);
36
+ return `<section class="blk-newsletter" aria-label="${escapeAttr(title || 'Newsletter')}">
37
+ <div class="wrap">
38
+ ${title ? `<h2>${escapeHtml(title)}</h2>` : ''}
39
+ ${intro ? `<p class="intro">${escapeHtml(intro)}</p>` : ''}
40
+ <form method="${safeMethod(action?.method)}" action="${action ? escapeAttr(action.url) : '#'}" data-wl-capability="${CAPABILITY}" data-wl-block="${escapeAttr(id)}"${action ? '' : ' data-wl-inert="true"'}>
41
+ <input type="email" name="email" placeholder="${escapeAttr(placeholder)}" aria-label="Email address" required>
42
+ <button type="submit"${action ? '' : ' disabled'}>${escapeHtml(submitLabel)}</button>
43
+ </form>
44
+ ${action ? '' : '<p class="note">Connect a runtime to start collecting subscribers.</p>'}
45
+ </div>
46
+ </section>`;
47
+ }
48
+ export const newsletter = {
49
+ type: 'newsletter',
50
+ description: 'An email-capture form that posts to a host-provided runtime; renders inert until a runtime is wired.',
51
+ schema,
52
+ css,
53
+ render,
54
+ island: 'newsletter',
55
+ runtime: { capabilities: [CAPABILITY] },
56
+ };
@@ -0,0 +1,2 @@
1
+ import type { BlockSpec } from '../registry.js';
2
+ export declare const pricing: BlockSpec;
@@ -0,0 +1,89 @@
1
+ /** `pricing` — plan/tier cards with features and a CTA. Static brick. */
2
+ import { escapeAttr, escapeHtml, sanitizeUrl } from '../schema.js';
3
+ const schema = {
4
+ title: { kind: 'string', default: '', max: 120 },
5
+ subtitle: { kind: 'string', default: '', max: 240 },
6
+ plans: {
7
+ kind: 'array', max: 6,
8
+ of: {
9
+ kind: 'object',
10
+ fields: {
11
+ name: { kind: 'string', required: true, default: '', max: 60 },
12
+ price: { kind: 'string', required: true, default: '', max: 24 },
13
+ period: { kind: 'string', default: '', max: 24 }, // e.g. "/mo"
14
+ features: {
15
+ kind: 'array', max: 12,
16
+ of: { kind: 'string', default: '', max: 120 },
17
+ },
18
+ ctaLabel: { kind: 'string', default: 'Choose', max: 40 },
19
+ ctaHref: { kind: 'string', default: '', max: 500 },
20
+ featured: { kind: 'boolean', default: false },
21
+ },
22
+ },
23
+ },
24
+ };
25
+ const css = `
26
+ .blk-pricing{padding:var(--space-lg) var(--space);background:var(--bg);color:var(--text)}
27
+ .blk-pricing .wrap{max-width:1120px;margin:0 auto}
28
+ .blk-pricing .head{text-align:center;margin-bottom:var(--space-lg)}
29
+ .blk-pricing h2{font-size:var(--fs-xl);margin:0 0 .3em;font-weight:800}
30
+ .blk-pricing .sub{color:var(--muted);font-size:var(--fs-lg);margin:0}
31
+ .blk-pricing .grid{display:grid;grid-template-columns:repeat(auto-fit,minmax(240px,1fr));gap:calc(var(--space)*1.2);align-items:stretch}
32
+ .blk-pricing .plan{display:flex;flex-direction:column;background:var(--surface);border:1px solid color-mix(in srgb,var(--muted) 30%,transparent);border-radius:var(--radius);padding:calc(var(--space)*1.2)}
33
+ .blk-pricing .plan.featured{border:2px solid var(--primary);position:relative}
34
+ .blk-pricing .badge{position:absolute;top:-.8em;left:50%;transform:translateX(-50%);background:var(--primary);color:var(--on-primary);font-size:var(--fs-base);font-weight:700;padding:.15em .8em;border-radius:var(--radius)}
35
+ .blk-pricing .name{font-size:var(--fs-lg);font-weight:700;margin:0 0 .3em}
36
+ .blk-pricing .price{font-size:var(--fs-xl);font-weight:800;margin:0 0 .6em}
37
+ .blk-pricing .price .period{font-size:var(--fs-base);font-weight:500;color:var(--muted)}
38
+ .blk-pricing ul{list-style:none;margin:0 0 var(--space);padding:0;flex:1}
39
+ .blk-pricing ul li{padding:.35em 0;color:var(--muted);border-top:1px solid color-mix(in srgb,var(--muted) 18%,transparent)}
40
+ .blk-pricing .cta{display:block;text-align:center;text-decoration:none;background:var(--primary);color:var(--on-primary);font-weight:700;padding:.7em 1em;border-radius:var(--radius);transition:opacity var(--motion)}
41
+ .blk-pricing .cta:hover{opacity:.88}
42
+ .blk-pricing .plan:not(.featured) .cta{background:transparent;color:var(--primary);border:1px solid var(--primary)}
43
+ @media(max-width:440px){.blk-pricing .grid{grid-template-columns:1fr}}
44
+ `.trim();
45
+ function planCard(p) {
46
+ // Drop incomplete plans (need a name + price to be meaningful).
47
+ if (!p.name || !p.price)
48
+ return '';
49
+ const feats = (p.features ?? [])
50
+ .filter((f) => !!f)
51
+ .map((f) => `<li>${escapeHtml(f)}</li>`)
52
+ .join('\n ');
53
+ const href = sanitizeUrl(p.ctaHref);
54
+ const hasHref = href !== '#' && !!p.ctaHref;
55
+ const label = p.ctaLabel || 'Choose';
56
+ const cta = hasHref
57
+ ? `<a class="cta" href="${escapeAttr(href)}">${escapeHtml(label)}</a>`
58
+ : '';
59
+ return `<div class="plan${p.featured ? ' featured' : ''}">
60
+ ${p.featured ? '<span class="badge">Popular</span>' : ''}
61
+ <p class="name">${escapeHtml(p.name)}</p>
62
+ <p class="price">${escapeHtml(p.price)}${p.period ? `<span class="period">${escapeHtml(p.period)}</span>` : ''}</p>
63
+ ${feats ? `<ul>${feats}</ul>` : '<ul></ul>'}
64
+ ${cta}
65
+ </div>`;
66
+ }
67
+ function render(config) {
68
+ const title = config.title;
69
+ const subtitle = config.subtitle;
70
+ const plans = (config.plans ?? []).map(planCard).filter(Boolean);
71
+ const head = (title || subtitle)
72
+ ? `<div class="head">${title ? `<h2>${escapeHtml(title)}</h2>` : ''}${subtitle ? `<p class="sub">${escapeHtml(subtitle)}</p>` : ''}</div>`
73
+ : '';
74
+ return `<section class="blk-pricing" aria-label="${escapeAttr(title || 'Pricing')}">
75
+ <div class="wrap">
76
+ ${head}
77
+ <div class="grid">
78
+ ${plans.join('\n ')}
79
+ </div>
80
+ </div>
81
+ </section>`;
82
+ }
83
+ export const pricing = {
84
+ type: 'pricing',
85
+ description: 'A row of pricing/tier cards, each with a name, price, feature list, and a call-to-action button; one plan can be highlighted.',
86
+ schema,
87
+ css,
88
+ render,
89
+ };
@@ -0,0 +1,5 @@
1
+ export type ProseNode = {
2
+ kind: string;
3
+ text: string;
4
+ };
5
+ export declare function renderProse(nodes: ProseNode[] | undefined, sep?: string): string;
@@ -0,0 +1,46 @@
1
+ /**
2
+ * Shared typed-content renderer for prose bricks (`rich-text`, `blog-post`).
3
+ *
4
+ * Turns an ordered array of TYPED content nodes — never raw HTML — into escaped
5
+ * markup, grouping consecutive `bullet` / `numbered` items into a single
6
+ * `<ul>` / `<ol>`. Total: every node's text is escaped, empty nodes are skipped,
7
+ * and it never throws. A block's schema decides which `kind`s can occur; kinds it
8
+ * doesn't allow simply never appear here.
9
+ */
10
+ import { escapeHtml } from '../schema.js';
11
+ export function renderProse(nodes, sep = '\n ') {
12
+ const out = [];
13
+ let listKind = null;
14
+ let items = [];
15
+ const flush = () => {
16
+ if (listKind && items.length) {
17
+ const tag = listKind === 'bullet' ? 'ul' : 'ol';
18
+ out.push(`<${tag}>${items.join('')}</${tag}>`);
19
+ }
20
+ listKind = null;
21
+ items = [];
22
+ };
23
+ for (const n of nodes ?? []) {
24
+ if (!n || typeof n.text !== 'string' || !n.text)
25
+ continue;
26
+ const t = escapeHtml(n.text);
27
+ if (n.kind === 'bullet' || n.kind === 'numbered') {
28
+ if (listKind && listKind !== n.kind)
29
+ flush();
30
+ listKind = n.kind;
31
+ items.push(`<li>${t}</li>`);
32
+ continue;
33
+ }
34
+ flush();
35
+ if (n.kind === 'heading')
36
+ out.push(`<h2>${t}</h2>`);
37
+ else if (n.kind === 'subheading')
38
+ out.push(`<h3>${t}</h3>`);
39
+ else if (n.kind === 'quote')
40
+ out.push(`<blockquote>${t}</blockquote>`);
41
+ else
42
+ out.push(`<p>${t}</p>`);
43
+ }
44
+ flush();
45
+ return out.join(sep);
46
+ }
@@ -0,0 +1,2 @@
1
+ import type { BlockSpec } from '../registry.js';
2
+ export declare const richText: BlockSpec;
@@ -0,0 +1,46 @@
1
+ import { renderProse } from './prose.js';
2
+ const schema = {
3
+ blocks: {
4
+ kind: 'array',
5
+ max: 60,
6
+ of: {
7
+ kind: 'object',
8
+ fields: {
9
+ kind: {
10
+ kind: 'enum',
11
+ values: ['heading', 'subheading', 'paragraph', 'quote', 'bullet', 'numbered'],
12
+ default: 'paragraph',
13
+ },
14
+ text: { kind: 'string', required: true, default: '', max: 2000 },
15
+ },
16
+ },
17
+ },
18
+ };
19
+ const css = `
20
+ .blk-rich-text{padding:var(--space-lg) var(--space);background:var(--bg);color:var(--text)}
21
+ .blk-rich-text .wrap{max-width:68ch;margin:0 auto}
22
+ .blk-rich-text h2{font-size:var(--fs-xl);margin:1em 0 .3em;font-weight:800;line-height:1.2}
23
+ .blk-rich-text h3{font-size:var(--fs-lg);margin:1em 0 .3em;font-weight:700;line-height:1.25}
24
+ .blk-rich-text p{margin:0 0 1em;font-size:var(--fs-lg);line-height:1.6}
25
+ .blk-rich-text blockquote{margin:1.2em 0;padding:.4em 0 .4em 1em;border-left:3px solid var(--accent);color:var(--muted);font-style:italic;font-size:var(--fs-lg)}
26
+ .blk-rich-text ul,.blk-rich-text ol{margin:0 0 1em;padding-left:1.4em}
27
+ .blk-rich-text li{margin:.3em 0;font-size:var(--fs-lg);line-height:1.5}
28
+ .blk-rich-text>.wrap>:first-child{margin-top:0}
29
+ .blk-rich-text>.wrap>:last-child{margin-bottom:0}
30
+ @media(max-width:560px){.blk-rich-text p,.blk-rich-text li,.blk-rich-text blockquote{font-size:var(--fs-base)}}
31
+ `.trim();
32
+ function render(config) {
33
+ const body = renderProse(config.blocks, '\n ');
34
+ return `<section class="blk-rich-text" aria-label="Content">
35
+ <div class="wrap">
36
+ ${body}
37
+ </div>
38
+ </section>`;
39
+ }
40
+ export const richText = {
41
+ type: 'rich-text',
42
+ description: 'A typed prose section with ordered headings, paragraphs, quotes, and bullet/numbered lists — a safe freeform-content block, never raw HTML.',
43
+ schema,
44
+ css,
45
+ render,
46
+ };
@@ -0,0 +1,2 @@
1
+ import type { BlockSpec } from '../registry.js';
2
+ export declare const services: BlockSpec;
@@ -0,0 +1,65 @@
1
+ /** `services-catalogue` — a grid of services/products. Static brick. */
2
+ import { escapeAttr, escapeHtml, sanitizeUrl } from '../schema.js';
3
+ const schema = {
4
+ title: { kind: 'string', default: '', max: 120 },
5
+ subtitle: { kind: 'string', default: '', max: 240 },
6
+ items: {
7
+ kind: 'array',
8
+ max: 24,
9
+ of: {
10
+ kind: 'object',
11
+ fields: {
12
+ name: { kind: 'string', required: true, default: 'Service', max: 80 },
13
+ description: { kind: 'string', default: '', max: 300 },
14
+ price: { kind: 'string', default: '', max: 40 },
15
+ ctaLabel: { kind: 'string', default: '', max: 40 },
16
+ ctaHref: { kind: 'string', default: '#', max: 500 },
17
+ },
18
+ },
19
+ },
20
+ };
21
+ const css = `
22
+ .blk-services{padding:var(--space-lg) var(--space);background:var(--bg);color:var(--text)}
23
+ .blk-services .wrap{max-width:1080px;margin:0 auto}
24
+ .blk-services .head{text-align:center;margin-bottom:var(--space-lg)}
25
+ .blk-services h2{font-size:var(--fs-xl);margin:0 0 .3em;font-weight:800}
26
+ .blk-services .sub{color:var(--muted);font-size:var(--fs-lg);margin:0}
27
+ .blk-services .grid{display:grid;grid-template-columns:repeat(auto-fit,minmax(240px,1fr));gap:calc(var(--space)*1.1)}
28
+ .blk-services .card{background:var(--surface);border:1px solid color-mix(in srgb,var(--text) 12%,transparent);border-radius:var(--radius);padding:calc(var(--space)*1.3);display:flex;flex-direction:column;gap:.5em}
29
+ .blk-services .card h3{margin:0;font-size:var(--fs-lg);font-weight:700}
30
+ .blk-services .card .price{color:var(--accent);font-weight:700}
31
+ .blk-services .card p{margin:0;color:var(--muted)}
32
+ .blk-services .card .cta{margin-top:auto;align-self:flex-start;color:var(--primary);text-decoration:none;font-weight:600}
33
+ `.trim();
34
+ function card(it) {
35
+ return `<article class="card">
36
+ <h3>${escapeHtml(it.name)}</h3>
37
+ ${it.price ? `<span class="price">${escapeHtml(it.price)}</span>` : ''}
38
+ ${it.description ? `<p>${escapeHtml(it.description)}</p>` : ''}
39
+ ${it.ctaLabel ? `<a class="cta" href="${escapeAttr(sanitizeUrl(it.ctaHref))}">${escapeHtml(it.ctaLabel)} →</a>` : ''}
40
+ </article>`;
41
+ }
42
+ function render(config) {
43
+ const title = config.title;
44
+ const subtitle = config.subtitle;
45
+ const items = config.items ?? [];
46
+ const head = (title || subtitle)
47
+ ? `<div class="head">
48
+ ${title ? `<h2>${escapeHtml(title)}</h2>` : ''}
49
+ ${subtitle ? `<p class="sub">${escapeHtml(subtitle)}</p>` : ''}
50
+ </div>`
51
+ : '';
52
+ return `<section class="blk-services" aria-label="${escapeAttr(title || 'Services')}">
53
+ <div class="wrap">
54
+ ${head}
55
+ <div class="grid">
56
+ ${items.map(card).join('\n ')}
57
+ </div>
58
+ </div>
59
+ </section>`;
60
+ }
61
+ export const services = {
62
+ type: 'services-catalogue',
63
+ description: 'A grid of services or products, each with a name, optional price, description, and link. Use for "what we offer".',
64
+ schema, css, render,
65
+ };
@@ -0,0 +1,2 @@
1
+ import type { BlockSpec } from '../registry.js';
2
+ export declare const sidebar: BlockSpec;
@@ -0,0 +1,58 @@
1
+ /** `sidebar` — section / drawer nav for multi-view apps. Static brick. */
2
+ import { escapeAttr, escapeHtml, sanitizeUrl } from '../schema.js';
3
+ const schema = {
4
+ title: { kind: 'string', default: '', max: 60 },
5
+ links: {
6
+ kind: 'array',
7
+ max: 20,
8
+ of: {
9
+ kind: 'object',
10
+ fields: {
11
+ label: { kind: 'string', required: true, default: '', max: 60 },
12
+ href: { kind: 'string', default: '', max: 500 },
13
+ icon: { kind: 'string', default: '', max: 8 }, // emoji or a single glyph
14
+ },
15
+ },
16
+ },
17
+ };
18
+ const css = `
19
+ .blk-sidebar{background:var(--surface);color:var(--text);font-family:var(--font);padding:var(--space);border-right:1px solid color-mix(in srgb, var(--text) 15%, transparent)}
20
+ .blk-sidebar .wrap{max-width:280px}
21
+ .blk-sidebar .title{margin:0 0 var(--space);font-size:var(--fs-lg);font-weight:800;color:var(--text)}
22
+ .blk-sidebar ul{list-style:none;margin:0;padding:0;display:flex;flex-direction:column;gap:.15em}
23
+ .blk-sidebar a{display:flex;align-items:center;gap:.6em;padding:.55em .7em;text-decoration:none;color:var(--muted);font-size:var(--fs-base);border-radius:var(--radius);transition:color var(--motion),background var(--motion)}
24
+ .blk-sidebar a:hover,.blk-sidebar a:focus-visible{color:var(--primary);background:color-mix(in srgb, var(--primary) 10%, transparent)}
25
+ .blk-sidebar .ico{font-size:1.15em;line-height:1;flex:0 0 auto}
26
+ .blk-sidebar .lbl{overflow:hidden;text-overflow:ellipsis;white-space:nowrap}
27
+ @media(min-width:900px){.blk-sidebar{position:sticky;top:0;align-self:flex-start;min-height:100vh}}
28
+ @media(max-width:899px){.blk-sidebar{border-right:0;border-bottom:1px solid color-mix(in srgb, var(--text) 15%, transparent)}.blk-sidebar .wrap{max-width:none}.blk-sidebar ul{flex-direction:row;flex-wrap:wrap}}
29
+ `.trim();
30
+ function linkItem(l) {
31
+ // Drop links with no label rather than emit an empty target (total render).
32
+ if (!l.label)
33
+ return '';
34
+ const href = escapeAttr(sanitizeUrl(l.href || '#'));
35
+ return `<li><a href="${href}">
36
+ ${l.icon ? `<span class="ico" aria-hidden="true">${escapeHtml(l.icon)}</span>` : ''}
37
+ <span class="lbl">${escapeHtml(l.label)}</span>
38
+ </a></li>`;
39
+ }
40
+ function render(config) {
41
+ const title = config.title;
42
+ const links = (config.links ?? []).map(linkItem).filter(Boolean);
43
+ return `<nav class="blk-sidebar" aria-label="${escapeAttr(title || 'Sidebar')}">
44
+ <div class="wrap">
45
+ ${title ? `<p class="title">${escapeHtml(title)}</p>` : ''}
46
+ <ul>
47
+ ${links.join('\n ')}
48
+ </ul>
49
+ </div>
50
+ </nav>`;
51
+ }
52
+ export const sidebar = {
53
+ type: 'sidebar',
54
+ description: 'A vertical section/drawer navigation for multi-view apps: an optional title over a list of icon-and-label links.',
55
+ schema,
56
+ css,
57
+ render,
58
+ };
@@ -0,0 +1,2 @@
1
+ import type { BlockSpec } from '../registry.js';
2
+ export declare const socialLinks: BlockSpec;
@@ -0,0 +1,53 @@
1
+ /** `social-links` — a centered icon row linking out to profiles. Static brick. */
2
+ import { escapeAttr, escapeHtml, sanitizeUrl } from '../schema.js';
3
+ const schema = {
4
+ title: { kind: 'string', default: '', max: 120 },
5
+ links: {
6
+ kind: 'array', max: 12,
7
+ of: {
8
+ kind: 'object',
9
+ fields: {
10
+ label: { kind: 'string', required: true, default: '', max: 40 },
11
+ href: { kind: 'string', required: true, default: '', max: 500 },
12
+ icon: { kind: 'string', default: '', max: 8 }, // emoji or a single glyph
13
+ },
14
+ },
15
+ },
16
+ };
17
+ const css = `
18
+ .blk-social-links{padding:var(--space-lg) var(--space);background:var(--bg);color:var(--text);text-align:center}
19
+ .blk-social-links .wrap{max-width:800px;margin:0 auto}
20
+ .blk-social-links h2{font-size:var(--fs-lg);font-weight:700;margin:0 0 var(--space)}
21
+ .blk-social-links .row{display:flex;flex-wrap:wrap;justify-content:center;gap:.8em}
22
+ .blk-social-links a{display:inline-flex;align-items:center;gap:.4em;padding:.5em .9em;border-radius:var(--radius);text-decoration:none;color:var(--text);background:var(--surface);border:1px solid color-mix(in srgb,var(--text) 15%,transparent);font-size:var(--fs-base);font-weight:600;transition:color var(--motion),border-color var(--motion)}
23
+ .blk-social-links a:hover,.blk-social-links a:focus-visible{color:var(--primary);border-color:var(--primary)}
24
+ .blk-social-links .ico{font-size:1.15em;line-height:1}
25
+ `.trim();
26
+ function link(l) {
27
+ const href = sanitizeUrl(l.href);
28
+ if (!l.label || href === '#' || !l.href)
29
+ return '';
30
+ return `<a href="${escapeAttr(href)}" rel="noopener">
31
+ ${l.icon ? `<span class="ico" aria-hidden="true">${escapeHtml(l.icon)}</span>` : ''}
32
+ <span>${escapeHtml(l.label)}</span>
33
+ </a>`;
34
+ }
35
+ function render(config) {
36
+ const title = config.title;
37
+ const links = (config.links ?? []).map(link).filter(Boolean);
38
+ return `<section class="blk-social-links" aria-label="${escapeAttr(title || 'Social links')}">
39
+ <div class="wrap">
40
+ ${title ? `<h2>${escapeHtml(title)}</h2>` : ''}
41
+ <div class="row">
42
+ ${links.join('\n ')}
43
+ </div>
44
+ </div>
45
+ </section>`;
46
+ }
47
+ export const socialLinks = {
48
+ type: 'social-links',
49
+ description: 'A centered row of links to social or external profiles, each an optional icon plus a label.',
50
+ schema,
51
+ css,
52
+ render,
53
+ };
@@ -0,0 +1,2 @@
1
+ import type { BlockSpec } from '../registry.js';
2
+ export declare const spacer: BlockSpec;
@@ -0,0 +1,25 @@
1
+ /** `spacer` — deliberate vertical whitespace between sections. Static brick. */
2
+ import { escapeAttr } from '../schema.js';
3
+ const schema = {
4
+ size: { kind: 'enum', values: ['sm', 'md', 'lg', 'xl'], default: 'md' },
5
+ };
6
+ const css = `
7
+ .blk-spacer{background:var(--bg)}
8
+ .blk-spacer.size-sm{height:calc(var(--space)*1)}
9
+ .blk-spacer.size-md{height:calc(var(--space)*2)}
10
+ .blk-spacer.size-lg{height:var(--space-lg)}
11
+ .blk-spacer.size-xl{height:calc(var(--space-lg)*2)}
12
+ `.trim();
13
+ const SIZES = new Set(['sm', 'md', 'lg', 'xl']);
14
+ function render(config) {
15
+ const raw = config.size;
16
+ const size = SIZES.has(raw) ? raw : 'md';
17
+ return `<section class="blk-spacer size-${escapeAttr(size)}" aria-hidden="true"></section>`;
18
+ }
19
+ export const spacer = {
20
+ type: 'spacer',
21
+ description: 'Deliberate vertical whitespace between sections, in one of four sizes (sm, md, lg, xl).',
22
+ schema,
23
+ css,
24
+ render,
25
+ };
@@ -0,0 +1,2 @@
1
+ import type { BlockSpec } from '../registry.js';
2
+ export declare const split: BlockSpec;