@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,67 @@
1
+ /**
2
+ * `split` — alternating image + text rows. Each row is a 2-column grid; the
3
+ * image side flips per row. Rows with no image collapse to a single text
4
+ * column. Static brick.
5
+ */
6
+ import { escapeAttr, escapeHtml, sanitizeUrl } from '../schema.js';
7
+ const schema = {
8
+ rows: {
9
+ kind: 'array',
10
+ max: 8,
11
+ of: {
12
+ kind: 'object',
13
+ fields: {
14
+ title: { kind: 'string', default: '', max: 120 },
15
+ text: { kind: 'string', default: '', max: 600 },
16
+ image: { kind: 'string', default: '', max: 500 },
17
+ imageAlt: { kind: 'string', default: '', max: 200 },
18
+ },
19
+ },
20
+ },
21
+ };
22
+ const css = `
23
+ .blk-split{padding:var(--space-lg) var(--space);background:var(--bg);color:var(--text)}
24
+ .blk-split .wrap{max-width:1080px;margin:0 auto;display:flex;flex-direction:column;gap:calc(var(--space)*2.2)}
25
+ .blk-split .row{display:grid;grid-template-columns:1fr 1fr;gap:calc(var(--space)*2);align-items:center}
26
+ .blk-split .row.text-only{grid-template-columns:min(68ch,100%);justify-content:center;text-align:center}
27
+ .blk-split .row.img-right .media{order:1}
28
+ .blk-split .row.img-left .media{order:-1}
29
+ .blk-split h2{font-size:var(--fs-xl);margin:0 0 .4em;font-weight:800}
30
+ .blk-split p{color:var(--muted);font-size:var(--fs-lg);margin:0;line-height:1.6;white-space:pre-line}
31
+ .blk-split .media img{width:100%;border-radius:var(--radius);display:block;object-fit:cover}
32
+ @media(max-width:760px){.blk-split .row{grid-template-columns:1fr;text-align:left}.blk-split .row .media{order:-1}}
33
+ `.trim();
34
+ function render(config) {
35
+ const rows = (config.rows ?? []).filter((r) => r && (r.title || r.text || r.image));
36
+ const html = rows
37
+ .map((r, i) => {
38
+ const url = sanitizeUrl(r.image);
39
+ const hasImg = url !== '#' && !!r.image;
40
+ const side = i % 2 === 0 ? 'img-left' : 'img-right';
41
+ const cls = hasImg ? `row ${side}` : 'row text-only';
42
+ const text = `<div class="text">
43
+ ${r.title ? `<h2>${escapeHtml(r.title)}</h2>` : ''}
44
+ ${r.text ? `<p>${escapeHtml(r.text)}</p>` : ''}
45
+ </div>`;
46
+ const media = hasImg
47
+ ? `<div class="media"><img src="${escapeAttr(url)}" alt="${escapeAttr(r.imageAlt)}" loading="lazy"></div>`
48
+ : '';
49
+ return `<div class="${cls}">
50
+ ${text}
51
+ ${media}
52
+ </div>`;
53
+ })
54
+ .join('\n ');
55
+ return `<section class="blk-split" aria-label="Details">
56
+ <div class="wrap">
57
+ ${html}
58
+ </div>
59
+ </section>`;
60
+ }
61
+ export const split = {
62
+ type: 'split',
63
+ description: 'Alternating image-and-text rows; the image side flips each row, and rows without an image collapse to centered text.',
64
+ schema,
65
+ css,
66
+ render,
67
+ };
@@ -0,0 +1,2 @@
1
+ import type { BlockSpec } from '../registry.js';
2
+ export declare const stats: BlockSpec;
@@ -0,0 +1,64 @@
1
+ /**
2
+ * `stats` — a grid of metric counters. Each stat shows an optional prefix, a
3
+ * big value, an optional suffix, and a muted label. The final value renders
4
+ * statically; the `stats` island can enhance it with a count-up animation
5
+ * (reading `data-wl-count`), so the block works with zero JS.
6
+ */
7
+ import { escapeAttr, escapeHtml } from '../schema.js';
8
+ const schema = {
9
+ title: { kind: 'string', default: '', max: 120 },
10
+ columns: { kind: 'int', oneOf: [2, 3, 4], default: 4 },
11
+ items: {
12
+ kind: 'array',
13
+ max: 8,
14
+ of: {
15
+ kind: 'object',
16
+ fields: {
17
+ value: { kind: 'string', required: true, default: '', max: 24 },
18
+ label: { kind: 'string', required: true, default: '', max: 60 },
19
+ prefix: { kind: 'string', default: '', max: 8 },
20
+ suffix: { kind: 'string', default: '', max: 8 },
21
+ },
22
+ },
23
+ },
24
+ };
25
+ const css = `
26
+ .blk-stats{padding:var(--space-lg) var(--space);background:var(--bg);color:var(--text)}
27
+ .blk-stats .wrap{max-width:1080px;margin:0 auto}
28
+ .blk-stats h2{text-align:center;font-size:var(--fs-xl);margin:0 0 var(--space-lg);font-weight:800}
29
+ .blk-stats .grid{display:grid;grid-template-columns:repeat(var(--cols,4),1fr);gap:calc(var(--space)*1.4)}
30
+ .blk-stats .item{text-align:center}
31
+ .blk-stats .value{font-size:var(--fs-xl);font-weight:800;line-height:1;color:var(--primary)}
32
+ .blk-stats .affix{color:var(--accent)}
33
+ .blk-stats .label{margin:.5em 0 0;color:var(--muted);font-size:var(--fs-base)}
34
+ @media(max-width:720px){.blk-stats .grid{grid-template-columns:1fr 1fr}}
35
+ @media(max-width:420px){.blk-stats .grid{grid-template-columns:1fr}}
36
+ `.trim();
37
+ function render(config) {
38
+ const title = config.title;
39
+ const columns = Number.isInteger(config.columns) ? config.columns : 4;
40
+ const items = (config.items ?? []).filter((it) => it && it.value && it.label);
41
+ const cards = items
42
+ .map((it) => `<div class="item">
43
+ <div class="value" data-wl-count="${escapeAttr(it.value)}">${it.prefix ? `<span class="affix">${escapeHtml(it.prefix)}</span>` : ''}${escapeHtml(it.value)}${it.suffix ? `<span class="affix">${escapeHtml(it.suffix)}</span>` : ''}</div>
44
+ <p class="label">${escapeHtml(it.label)}</p>
45
+ </div>`)
46
+ .join('\n ');
47
+ const heading = title ? `<h2>${escapeHtml(title)}</h2>` : '';
48
+ return `<section class="blk-stats" aria-label="${escapeAttr(title || 'Statistics')}">
49
+ <div class="wrap">
50
+ ${heading}
51
+ <div class="grid" style="--cols:${columns}">
52
+ ${cards}
53
+ </div>
54
+ </div>
55
+ </section>`;
56
+ }
57
+ export const stats = {
58
+ type: 'stats',
59
+ description: 'A grid of metric counters: each stat shows an optional prefix/suffix around a big value with a muted label, with an optional count-up animation.',
60
+ schema,
61
+ css,
62
+ render,
63
+ island: 'stats',
64
+ };
@@ -0,0 +1,2 @@
1
+ import type { BlockSpec } from '../registry.js';
2
+ export declare const steps: BlockSpec;
@@ -0,0 +1,63 @@
1
+ /**
2
+ * `steps` — a numbered how-it-works / process list. Each step is an
3
+ * auto-numbered card with a title and optional supporting text. Static brick.
4
+ */
5
+ import { escapeAttr, escapeHtml } from '../schema.js';
6
+ const schema = {
7
+ title: { kind: 'string', default: '', max: 120 },
8
+ subtitle: { kind: 'string', default: '', max: 240 },
9
+ items: {
10
+ kind: 'array',
11
+ max: 12,
12
+ of: {
13
+ kind: 'object',
14
+ fields: {
15
+ title: { kind: 'string', required: true, default: 'Step', max: 100 },
16
+ text: { kind: 'string', default: '', max: 300 },
17
+ },
18
+ },
19
+ },
20
+ };
21
+ const css = `
22
+ .blk-steps{padding:var(--space-lg) var(--space);background:var(--bg);color:var(--text)}
23
+ .blk-steps .wrap{max-width:1000px;margin:0 auto}
24
+ .blk-steps .head{text-align:center;margin-bottom:var(--space-lg)}
25
+ .blk-steps h2{font-size:var(--fs-xl);margin:0 0 .3em;font-weight:800}
26
+ .blk-steps .sub{color:var(--muted);font-size:var(--fs-lg);margin:0}
27
+ .blk-steps ol{list-style:none;margin:0;padding:0;display:grid;grid-template-columns:repeat(auto-fit,minmax(240px,1fr));gap:calc(var(--space)*1.4)}
28
+ .blk-steps li{background:var(--surface);border-radius:var(--radius);padding:calc(var(--space)*1.3)}
29
+ .blk-steps .num{display:inline-flex;align-items:center;justify-content:center;width:2.2em;height:2.2em;border-radius:999px;background:var(--primary);color:var(--on-primary);font-weight:800;font-size:var(--fs-lg);margin-bottom:.5em}
30
+ .blk-steps h3{margin:0 0 .2em;font-size:var(--fs-lg);font-weight:700}
31
+ .blk-steps p{margin:0;color:var(--muted);line-height:1.5}
32
+ @media(max-width:480px){.blk-steps ol{grid-template-columns:1fr}}
33
+ `.trim();
34
+ function render(config) {
35
+ const title = config.title;
36
+ const subtitle = config.subtitle;
37
+ const items = (config.items ?? []).filter((it) => it && it.title);
38
+ const head = (title || subtitle)
39
+ ? `<div class="head">${title ? `<h2>${escapeHtml(title)}</h2>` : ''}${subtitle ? `<p class="sub">${escapeHtml(subtitle)}</p>` : ''}</div>`
40
+ : '';
41
+ const cards = items
42
+ .map((it, i) => `<li>
43
+ <div class="num" aria-hidden="true">${i + 1}</div>
44
+ <h3>${escapeHtml(it.title)}</h3>
45
+ ${it.text ? `<p>${escapeHtml(it.text)}</p>` : ''}
46
+ </li>`)
47
+ .join('\n ');
48
+ return `<section class="blk-steps" aria-label="${escapeAttr(title || 'Steps')}">
49
+ <div class="wrap">
50
+ ${head}
51
+ <ol>
52
+ ${cards}
53
+ </ol>
54
+ </div>
55
+ </section>`;
56
+ }
57
+ export const steps = {
58
+ type: 'steps',
59
+ description: 'A numbered how-it-works / process list: an optional title and subtitle over auto-numbered step cards, each with a title and short text.',
60
+ schema,
61
+ css,
62
+ render,
63
+ };
@@ -0,0 +1,2 @@
1
+ import type { BlockSpec } from '../registry.js';
2
+ export declare const tabs: BlockSpec;
@@ -0,0 +1,57 @@
1
+ /**
2
+ * `tabs` — tabbed content panels, CSS-ONLY (no JavaScript, no island). Hidden
3
+ * radio inputs drive `:checked ~` sibling rules to show one panel at a time. The
4
+ * radio group name is scoped to the placed block's id (via the render context)
5
+ * so multiple `tabs` blocks on one page never collide.
6
+ */
7
+ import { escapeAttr, escapeHtml } from '../schema.js';
8
+ const schema = {
9
+ items: {
10
+ kind: 'array', max: 8,
11
+ of: {
12
+ kind: 'object',
13
+ fields: {
14
+ label: { kind: 'string', required: true, default: '', max: 60 },
15
+ text: { kind: 'string', default: '', max: 2000 },
16
+ },
17
+ },
18
+ },
19
+ };
20
+ const css = `
21
+ .blk-tabs{padding:var(--space-lg) var(--space);background:var(--bg);color:var(--text)}
22
+ .blk-tabs .wrap{max-width:820px;margin:0 auto}
23
+ .blk-tabs input{position:absolute;opacity:0;pointer-events:none}
24
+ .blk-tabs .tablist{display:flex;flex-wrap:wrap;gap:.3em;border-bottom:1px solid color-mix(in srgb,var(--text) 15%,transparent);margin-bottom:var(--space)}
25
+ .blk-tabs label{cursor:pointer;padding:.55em .9em;border-radius:var(--radius) var(--radius) 0 0;color:var(--muted);font-weight:600;font-size:var(--fs-base)}
26
+ .blk-tabs label:hover{color:var(--text)}
27
+ .blk-tabs .panel{display:none;line-height:1.6;color:var(--muted);font-size:var(--fs-lg);white-space:pre-line}
28
+ ${[0, 1, 2, 3, 4, 5, 6, 7].map((i) => `.blk-tabs input.t${i}:checked ~ .tablist label.l${i}{color:var(--primary);border-bottom:2px solid var(--primary);margin-bottom:-1px}
29
+ .blk-tabs input.t${i}:checked ~ .panels .panel.p${i}{display:block}`).join('\n')}
30
+ `.trim();
31
+ function render(config, _tokens, ctx) {
32
+ const base = (ctx?.id || 'tabs').replace(/[^A-Za-z0-9_-]/g, '') || 'tabs';
33
+ const items = (config.items ?? []).filter((it) => it && it.label);
34
+ if (!items.length)
35
+ return `<section class="blk-tabs" aria-label="Tabs"><div class="wrap"></div></section>`;
36
+ const inputs = items.map((_it, i) => `<input class="t${i}" type="radio" name="${escapeAttr(base)}" id="${escapeAttr(base)}-${i}"${i === 0 ? ' checked' : ''}>`).join('\n ');
37
+ const labels = items.map((it, i) => `<label class="l${i}" for="${escapeAttr(base)}-${i}">${escapeHtml(it.label)}</label>`).join('\n ');
38
+ const panels = items.map((it, i) => `<div class="panel p${i}">${escapeHtml(it.text)}</div>`).join('\n ');
39
+ return `<section class="blk-tabs" aria-label="Tabs">
40
+ <div class="wrap">
41
+ ${inputs}
42
+ <div class="tablist" role="tablist">
43
+ ${labels}
44
+ </div>
45
+ <div class="panels">
46
+ ${panels}
47
+ </div>
48
+ </div>
49
+ </section>`;
50
+ }
51
+ export const tabs = {
52
+ type: 'tabs',
53
+ description: 'Tabbed content panels that switch with no JavaScript; each tab has a label and a text panel.',
54
+ schema,
55
+ css,
56
+ render,
57
+ };
@@ -0,0 +1,2 @@
1
+ import type { BlockSpec } from '../registry.js';
2
+ export declare const team: BlockSpec;
@@ -0,0 +1,94 @@
1
+ /** `team` — a grid of people cards (photo, role, bio, socials). 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
+ columns: { kind: 'int', oneOf: [2, 3, 4], default: 3 },
7
+ members: {
8
+ kind: 'array', max: 24,
9
+ of: {
10
+ kind: 'object',
11
+ fields: {
12
+ name: { kind: 'string', required: true, default: '', max: 80 },
13
+ role: { kind: 'string', default: '', max: 80 },
14
+ photo: { kind: 'string', default: '', max: 500 },
15
+ bio: { kind: 'string', default: '', max: 240 },
16
+ socials: {
17
+ kind: 'array', max: 5,
18
+ of: {
19
+ kind: 'object',
20
+ fields: {
21
+ label: { kind: 'string', required: true, default: '', max: 40 },
22
+ href: { kind: 'string', default: '', max: 500 },
23
+ },
24
+ },
25
+ },
26
+ },
27
+ },
28
+ },
29
+ };
30
+ const css = `
31
+ .blk-team{padding:var(--space-lg) var(--space);background:var(--bg);color:var(--text)}
32
+ .blk-team .wrap{max-width:1120px;margin:0 auto}
33
+ .blk-team .head{text-align:center;margin-bottom:var(--space-lg)}
34
+ .blk-team h2{font-size:var(--fs-xl);margin:0 0 .3em;font-weight:800}
35
+ .blk-team .sub{color:var(--muted);font-size:var(--fs-lg);margin:0}
36
+ .blk-team .grid{display:grid;grid-template-columns:repeat(var(--cols,3),1fr);gap:calc(var(--space)*1.4)}
37
+ .blk-team .card{background:var(--surface);border-radius:var(--radius);padding:calc(var(--space)*1.2);text-align:center}
38
+ .blk-team .avatar,.blk-team .initials{width:88px;height:88px;border-radius:999px;margin:0 auto .7em;object-fit:cover;display:flex;align-items:center;justify-content:center}
39
+ .blk-team .initials{background:color-mix(in srgb,var(--primary) 18%,var(--surface));color:var(--primary);font-weight:800;font-size:var(--fs-lg)}
40
+ .blk-team .name{margin:0;font-size:var(--fs-lg);font-weight:700}
41
+ .blk-team .role{margin:.15em 0 .5em;color:var(--accent);font-size:var(--fs-base);font-weight:600}
42
+ .blk-team .bio{margin:0 0 .6em;color:var(--muted);font-size:var(--fs-base);line-height:1.5}
43
+ .blk-team .socials{display:flex;justify-content:center;gap:.6em;flex-wrap:wrap}
44
+ .blk-team .socials a{color:var(--muted);font-size:var(--fs-base);text-decoration:none;font-weight:600}
45
+ .blk-team .socials a:hover,.blk-team .socials a:focus-visible{color:var(--primary)}
46
+ @media(max-width:720px){.blk-team .grid{grid-template-columns:1fr 1fr}}
47
+ @media(max-width:440px){.blk-team .grid{grid-template-columns:1fr}}
48
+ `.trim();
49
+ function initials(name) {
50
+ return name.trim().split(/\s+/).slice(0, 2).map((w) => w[0] ?? '').join('').toUpperCase() || '?';
51
+ }
52
+ function card(m) {
53
+ if (!m.name)
54
+ return '';
55
+ const photo = sanitizeUrl(m.photo);
56
+ const avatar = (photo !== '#' && m.photo)
57
+ ? `<img class="avatar" src="${escapeAttr(photo)}" alt="${escapeAttr(m.name)}" loading="lazy">`
58
+ : `<div class="initials" aria-hidden="true">${escapeHtml(initials(m.name))}</div>`;
59
+ const socials = (m.socials ?? [])
60
+ .filter((s) => s && s.label && sanitizeUrl(s.href) !== '#')
61
+ .map((s) => `<a href="${escapeAttr(sanitizeUrl(s.href))}" rel="noopener">${escapeHtml(s.label)}</a>`)
62
+ .join('');
63
+ return `<div class="card">
64
+ ${avatar}
65
+ <p class="name">${escapeHtml(m.name)}</p>
66
+ ${m.role ? `<p class="role">${escapeHtml(m.role)}</p>` : ''}
67
+ ${m.bio ? `<p class="bio">${escapeHtml(m.bio)}</p>` : ''}
68
+ ${socials ? `<div class="socials">${socials}</div>` : ''}
69
+ </div>`;
70
+ }
71
+ function render(config) {
72
+ const title = config.title;
73
+ const subtitle = config.subtitle;
74
+ const columns = Number.isInteger(config.columns) ? config.columns : 3;
75
+ const members = (config.members ?? []).map(card).filter(Boolean);
76
+ const head = (title || subtitle)
77
+ ? `<div class="head">${title ? `<h2>${escapeHtml(title)}</h2>` : ''}${subtitle ? `<p class="sub">${escapeHtml(subtitle)}</p>` : ''}</div>`
78
+ : '';
79
+ return `<section class="blk-team" aria-label="${escapeAttr(title || 'Team')}">
80
+ <div class="wrap">
81
+ ${head}
82
+ <div class="grid" style="--cols:${columns}">
83
+ ${members.join('\n ')}
84
+ </div>
85
+ </div>
86
+ </section>`;
87
+ }
88
+ export const team = {
89
+ type: 'team',
90
+ description: 'A grid of people cards with photo (or initials), name, role, a short bio, and optional social links.',
91
+ schema,
92
+ css,
93
+ render,
94
+ };
@@ -0,0 +1,2 @@
1
+ import type { BlockSpec } from '../registry.js';
2
+ export declare const testimonials: BlockSpec;
@@ -0,0 +1,54 @@
1
+ /** `testimonials` — customer quotes. Static brick. */
2
+ import { escapeAttr, escapeHtml } from '../schema.js';
3
+ const schema = {
4
+ title: { kind: 'string', default: '', max: 120 },
5
+ items: {
6
+ kind: 'array', max: 12,
7
+ of: {
8
+ kind: 'object',
9
+ fields: {
10
+ quote: { kind: 'string', required: true, default: '', max: 400 },
11
+ author: { kind: 'string', default: '', max: 80 },
12
+ role: { kind: 'string', default: '', max: 80 },
13
+ },
14
+ },
15
+ },
16
+ };
17
+ const css = `
18
+ .blk-testimonials{padding:var(--space-lg) var(--space);background:var(--surface)}
19
+ .blk-testimonials .wrap{max-width:1080px;margin:0 auto}
20
+ .blk-testimonials h2{text-align:center;font-size:var(--fs-xl);margin:0 0 var(--space-lg);font-weight:800;color:var(--text)}
21
+ .blk-testimonials .grid{display:grid;grid-template-columns:repeat(auto-fit,minmax(280px,1fr));gap:calc(var(--space)*1.2)}
22
+ .blk-testimonials figure{margin:0;background:var(--bg);border-radius:var(--radius);padding:calc(var(--space)*1.3)}
23
+ .blk-testimonials blockquote{margin:0;font-size:var(--fs-lg);color:var(--text);line-height:1.4}
24
+ .blk-testimonials figcaption{margin-top:.8em;color:var(--muted);font-weight:600}
25
+ .blk-testimonials figcaption .role{font-weight:400}
26
+ `.trim();
27
+ function item(it) {
28
+ if (!it.quote)
29
+ return '';
30
+ const cap = it.author
31
+ ? `<figcaption>${escapeHtml(it.author)}${it.role ? ` <span class="role">· ${escapeHtml(it.role)}</span>` : ''}</figcaption>`
32
+ : '';
33
+ return `<figure>
34
+ <blockquote>“${escapeHtml(it.quote)}”</blockquote>
35
+ ${cap}
36
+ </figure>`;
37
+ }
38
+ function render(config) {
39
+ const title = config.title;
40
+ const items = (config.items ?? []).map(item).filter(Boolean);
41
+ return `<section class="blk-testimonials" aria-label="${escapeAttr(title || 'Testimonials')}">
42
+ <div class="wrap">
43
+ ${title ? `<h2>${escapeHtml(title)}</h2>` : ''}
44
+ <div class="grid">
45
+ ${items.join('\n ')}
46
+ </div>
47
+ </div>
48
+ </section>`;
49
+ }
50
+ export const testimonials = {
51
+ type: 'testimonials',
52
+ description: 'A grid of customer quotes, each with the quote text and an optional author name and role.',
53
+ schema, css, render,
54
+ };
@@ -0,0 +1,2 @@
1
+ import type { BlockSpec } from '../registry.js';
2
+ export declare const timeline: BlockSpec;
@@ -0,0 +1,53 @@
1
+ /** `timeline` — a vertical list of chronological milestones. Static brick. */
2
+ import { escapeAttr, escapeHtml } from '../schema.js';
3
+ const schema = {
4
+ title: { kind: 'string', default: '', max: 120 },
5
+ items: {
6
+ kind: 'array', max: 24,
7
+ of: {
8
+ kind: 'object',
9
+ fields: {
10
+ date: { kind: 'string', default: '', max: 40 },
11
+ title: { kind: 'string', required: true, default: '', max: 120 },
12
+ text: { kind: 'string', default: '', max: 400 },
13
+ },
14
+ },
15
+ },
16
+ };
17
+ const css = `
18
+ .blk-timeline{padding:var(--space-lg) var(--space);background:var(--bg);color:var(--text)}
19
+ .blk-timeline .wrap{max-width:760px;margin:0 auto}
20
+ .blk-timeline h2{font-size:var(--fs-xl);margin:0 0 var(--space-lg);font-weight:800;text-align:center}
21
+ .blk-timeline ol{list-style:none;margin:0;padding:0;position:relative}
22
+ .blk-timeline ol::before{content:"";position:absolute;left:7px;top:.4em;bottom:.4em;width:2px;background:color-mix(in srgb,var(--muted) 35%,transparent)}
23
+ .blk-timeline li{position:relative;padding:0 0 calc(var(--space)*1.6) calc(var(--space)*2)}
24
+ .blk-timeline li:last-child{padding-bottom:0}
25
+ .blk-timeline li::before{content:"";position:absolute;left:0;top:.35em;width:16px;height:16px;border-radius:999px;background:var(--primary);box-shadow:0 0 0 3px var(--bg)}
26
+ .blk-timeline .date{color:var(--accent);font-weight:700;font-size:var(--fs-base)}
27
+ .blk-timeline h3{margin:.1em 0 .2em;font-size:var(--fs-lg);font-weight:700}
28
+ .blk-timeline p{margin:0;color:var(--muted);line-height:1.55}
29
+ `.trim();
30
+ function render(config) {
31
+ const title = config.title;
32
+ const items = (config.items ?? []).filter((it) => it && it.title);
33
+ const entries = items.map((it) => `<li>
34
+ ${it.date ? `<span class="date">${escapeHtml(it.date)}</span>` : ''}
35
+ <h3>${escapeHtml(it.title)}</h3>
36
+ ${it.text ? `<p>${escapeHtml(it.text)}</p>` : ''}
37
+ </li>`).join('\n ');
38
+ return `<section class="blk-timeline" aria-label="${escapeAttr(title || 'Timeline')}">
39
+ <div class="wrap">
40
+ ${title ? `<h2>${escapeHtml(title)}</h2>` : ''}
41
+ <ol>
42
+ ${entries}
43
+ </ol>
44
+ </div>
45
+ </section>`;
46
+ }
47
+ export const timeline = {
48
+ type: 'timeline',
49
+ description: 'A vertical timeline of chronological milestones, each with an optional date, a title, and supporting text.',
50
+ schema,
51
+ css,
52
+ render,
53
+ };
@@ -0,0 +1,2 @@
1
+ import type { BlockSpec } from '../registry.js';
2
+ export declare const video: BlockSpec;
@@ -0,0 +1,61 @@
1
+ /**
2
+ * `video` — a responsive embedded video (YouTube / Vimeo) or a self-hosted file.
3
+ * Provider embeds are built from a sanitized id (privacy-friendly hosts), so no
4
+ * arbitrary URL is ever interpolated into the iframe src. Static brick (native
5
+ * players — no island).
6
+ */
7
+ import { escapeAttr, escapeHtml, sanitizeUrl } from '../schema.js';
8
+ const schema = {
9
+ provider: { kind: 'enum', values: ['youtube', 'vimeo', 'file'], default: 'youtube' },
10
+ src: { kind: 'string', required: true, default: '', max: 500 },
11
+ title: { kind: 'string', default: 'Video', max: 120 },
12
+ poster: { kind: 'string', default: '', max: 500 },
13
+ caption: { kind: 'string', default: '', max: 200 },
14
+ };
15
+ const css = `
16
+ .blk-video{padding:var(--space-lg) var(--space);background:var(--bg);color:var(--text)}
17
+ .blk-video .wrap{max-width:900px;margin:0 auto}
18
+ .blk-video .frame{position:relative;aspect-ratio:16/9;border-radius:var(--radius);overflow:hidden;background:var(--surface)}
19
+ .blk-video iframe,.blk-video video{position:absolute;inset:0;width:100%;height:100%;border:0;display:block;object-fit:cover}
20
+ .blk-video figcaption{margin:.6em 0 0;color:var(--muted);font-size:var(--fs-base);text-align:center}
21
+ `.trim();
22
+ function ytId(src) {
23
+ // Accept a bare id or a common URL; reduce to the safe id charset only.
24
+ const m = /(?:v=|youtu\.be\/|embed\/)([A-Za-z0-9_-]{6,})/.exec(src);
25
+ return (m ? m[1] : src).replace(/[^A-Za-z0-9_-]/g, '');
26
+ }
27
+ function render(config) {
28
+ const provider = config.provider;
29
+ const src = String(config.src ?? '');
30
+ const title = config.title || 'Video';
31
+ const caption = config.caption;
32
+ let frame;
33
+ if (provider === 'file') {
34
+ const url = sanitizeUrl(src);
35
+ const poster = sanitizeUrl(config.poster);
36
+ frame = url === '#'
37
+ ? ''
38
+ : `<video controls preload="metadata"${poster !== '#' && config.poster ? ` poster="${escapeAttr(poster)}"` : ''}><source src="${escapeAttr(url)}"></video>`;
39
+ }
40
+ else if (provider === 'vimeo') {
41
+ const id = src.replace(/[^0-9]/g, '');
42
+ frame = id ? `<iframe src="https://player.vimeo.com/video/${escapeAttr(id)}" title="${escapeAttr(title)}" loading="lazy" allowfullscreen></iframe>` : '';
43
+ }
44
+ else {
45
+ const id = ytId(src);
46
+ frame = id ? `<iframe src="https://www.youtube-nocookie.com/embed/${escapeAttr(id)}" title="${escapeAttr(title)}" loading="lazy" allow="accelerometer; encrypted-media; picture-in-picture" allowfullscreen></iframe>` : '';
47
+ }
48
+ return `<section class="blk-video" aria-label="${escapeAttr(title)}">
49
+ <figure class="wrap">
50
+ <div class="frame">${frame}</div>
51
+ ${caption ? `<figcaption>${escapeHtml(caption)}</figcaption>` : ''}
52
+ </figure>
53
+ </section>`;
54
+ }
55
+ export const video = {
56
+ type: 'video',
57
+ description: 'A responsive embedded video from YouTube or Vimeo (by id or URL) or a self-hosted file, with an optional caption.',
58
+ schema,
59
+ css,
60
+ render,
61
+ };
@@ -0,0 +1,26 @@
1
+ export interface BlockCatalogEntry {
2
+ type: string;
3
+ description: string;
4
+ schema: JsonSchema;
5
+ /** Powered brick (§6): the host runtime capabilities it needs, if any. */
6
+ runtime?: {
7
+ capabilities: string[];
8
+ };
9
+ }
10
+ export interface JsonSchema {
11
+ type?: string;
12
+ enum?: readonly (string | number)[];
13
+ properties?: Record<string, JsonSchema>;
14
+ required?: string[];
15
+ items?: JsonSchema;
16
+ maxItems?: number;
17
+ maxLength?: number;
18
+ minimum?: number;
19
+ maximum?: number;
20
+ default?: unknown;
21
+ additionalProperties?: boolean;
22
+ }
23
+ /** Full structured catalog — one entry per registered brick. */
24
+ export declare function catalog(): BlockCatalogEntry[];
25
+ /** A short human/AI-readable list of every brick and its params (`*` = expected). */
26
+ export declare function catalogPrompt(): string;
package/lib/catalog.js ADDED
@@ -0,0 +1,62 @@
1
+ import { REGISTRY } from './registry.js';
2
+ function fieldToJsonSchema(field) {
3
+ switch (field.kind) {
4
+ case 'string': return { type: 'string', ...(field.max !== undefined && { maxLength: field.max }), ...(field.default !== undefined && { default: field.default }) };
5
+ case 'enum': return { enum: field.values, ...(field.default !== undefined && { default: field.default }) };
6
+ case 'boolean': return { type: 'boolean', ...(field.default !== undefined && { default: field.default }) };
7
+ case 'int': return {
8
+ type: 'integer',
9
+ ...(field.oneOf && { enum: field.oneOf }),
10
+ ...(field.min !== undefined && { minimum: field.min }),
11
+ ...(field.max !== undefined && { maximum: field.max }),
12
+ ...(field.default !== undefined && { default: field.default }),
13
+ };
14
+ case 'object': return schemaToJsonSchema(field.fields);
15
+ case 'array': return { type: 'array', items: fieldToJsonSchema(field.of), ...(field.max !== undefined && { maxItems: field.max }) };
16
+ }
17
+ }
18
+ function schemaToJsonSchema(schema) {
19
+ const properties = {};
20
+ const required = [];
21
+ for (const [key, field] of Object.entries(schema)) {
22
+ properties[key] = fieldToJsonSchema(field);
23
+ if ((field.kind === 'string' || field.kind === 'enum' || field.kind === 'object' || field.kind === 'array') && field.required) {
24
+ required.push(key);
25
+ }
26
+ }
27
+ return { type: 'object', properties, ...(required.length && { required }), additionalProperties: false };
28
+ }
29
+ /** Full structured catalog — one entry per registered brick. */
30
+ export function catalog() {
31
+ return [...REGISTRY.values()].map((spec) => ({
32
+ type: spec.type,
33
+ description: spec.description,
34
+ schema: schemaToJsonSchema(spec.schema),
35
+ ...(spec.runtime ? { runtime: { capabilities: [...spec.runtime.capabilities] } } : {}),
36
+ }));
37
+ }
38
+ // ── Compact prompt rendering (token-cheap "menu" for a system prompt) ──────────
39
+ function fieldSummary(field) {
40
+ switch (field.kind) {
41
+ case 'string': return `string${field.max ? `(≤${field.max})` : ''}${field.required ? ' *' : ''}`;
42
+ case 'enum': return `${field.values.join('|')}${field.default ? `=${field.default}` : ''}`;
43
+ case 'boolean': return `bool${field.default !== undefined ? `=${field.default}` : ''}`;
44
+ case 'int': return `int${field.oneOf ? `(${field.oneOf.join('|')})` : ''}${field.default !== undefined ? `=${field.default}` : ''}`;
45
+ case 'object': return `{ ${Object.entries(field.fields).map(([k, f]) => `${k}:${fieldSummary(f)}`).join(', ')} }`;
46
+ case 'array': return `[ ${fieldSummary(field.of)} ]${field.max ? `(≤${field.max})` : ''}`;
47
+ }
48
+ }
49
+ /** A short human/AI-readable list of every brick and its params (`*` = expected). */
50
+ export function catalogPrompt() {
51
+ return catalog0()
52
+ .map((spec) => {
53
+ const params = Object.entries(spec.schema).map(([k, f]) => `${k}: ${fieldSummary(f)}`).join('; ');
54
+ const dyn = spec.runtime ? ` [dynamic — host provides: ${spec.runtime.capabilities.join(', ')}]` : '';
55
+ return `- ${spec.type} — ${spec.description}${dyn}\n config: { ${params} }`;
56
+ })
57
+ .join('\n');
58
+ }
59
+ // internal: iterate specs with their raw Schema (for the compact summary)
60
+ function catalog0() {
61
+ return [...REGISTRY.values()].map((s) => ({ type: s.type, description: s.description, schema: s.schema, runtime: s.runtime }));
62
+ }