diff --git a/craft/src/sections/families/b-content.test.tsx b/craft/src/sections/families/b-content.test.tsx
new file mode 100644
index 0000000..6618149
--- /dev/null
+++ b/craft/src/sections/families/b-content.test.tsx
@@ -0,0 +1,106 @@
+import { describe, test, expect, afterEach } from 'vitest';
+import { renderEditorHarness, EditorHarness } from '../../test-utils/editorHarness';
+import { exportBodyHtml } from '../../utils/html-export';
+import { insertSection, type SectionDef } from '../catalog';
+import { SOCIAL_PROOF_SECTIONS } from './social-proof';
+import { FAQ_SECTIONS } from './faq';
+import { PRICING_SECTIONS } from './pricing';
+import { TEAM_SECTIONS } from './team';
+import type { TemplateComponent } from '../../templates/definitions';
+
+const FAMILIES: Array<[string, SectionDef[]]> = [
+ ['social-proof', SOCIAL_PROOF_SECTIONS],
+ ['faq', FAQ_SECTIONS],
+ ['pricing', PRICING_SECTIONS],
+ ['team', TEAM_SECTIONS],
+];
+const ALL = FAMILIES.flatMap(([, s]) => s);
+const ROOT_TYPES = new Set(['Section', 'HeroSimple', 'CTASection', 'CallToAction']);
+
+let h: EditorHarness;
+afterEach(() => h?.unmount());
+
+const walk = (n: TemplateComponent, fn: (n: TemplateComponent) => void) => { fn(n); (n.children || []).forEach((c) => walk(c, fn)); };
+
+/** Body HTML of one inserted section (real editor, real export). */
+function exportOne(def: SectionDef): string {
+ h = renderEditorHarness();
+ h.act(() => { insertSection(h.query, h.actions, def, { select: false }); });
+ const html = exportBodyHtml(h.getSerialized()).html;
+ h.unmount();
+ return html;
+}
+
+describe('content B families', () => {
+ test('counts: 3+ layouts per family and roughly 18 sections', () => {
+ for (const [name, defs] of FAMILIES) expect(defs.length, name).toBeGreaterThanOrEqual(3);
+ expect(ALL.length).toBeGreaterThanOrEqual(18);
+ });
+
+ test('ids unique across the files, prefixed by family, family field correct', () => {
+ expect(new Set(ALL.map((s) => s.id)).size).toBe(ALL.length);
+ for (const [name, defs] of FAMILIES) {
+ for (const d of defs) {
+ expect(d.family).toBe(name);
+ expect(d.id.startsWith(`${name}-`), d.id).toBe(true);
+ }
+ }
+ });
+
+ test('keywords >= 3, root type is a top-level block with anchorId ""', () => {
+ for (const d of ALL) {
+ expect(d.keywords.length, d.id).toBeGreaterThanOrEqual(3);
+ expect(ROOT_TYPES.has(d.tree.type), d.id).toBe(true);
+ expect(d.tree.props.anchorId, d.id).toBe('');
+ }
+ });
+
+ test('ColumnLayout children count equals columns; no animation props; padding longhands on Section', () => {
+ for (const d of ALL) {
+ walk(d.tree, (n) => {
+ if (n.type === 'ColumnLayout') expect(n.children?.length, d.id).toBe(n.props.columns);
+ expect(n.props.animation, d.id).toBeUndefined();
+ });
+ expect(d.tree.props.style.padding, d.id).toBeUndefined();
+ expect(d.tree.props.style.paddingTop, d.id).toBeTruthy();
+ }
+ });
+
+ test.each(ALL.map((d) => [d.id, d] as const))('%s: inserts, exports token-only, alts and links ok', (_id, def) => {
+ h = renderEditorHarness();
+ let rootId = '';
+ h.act(() => { rootId = insertSection(h.query, h.actions, def, { select: false }); });
+ expect(h.query.node(rootId).get().data.parent).toBe('ROOT');
+
+ // Every ImageBlock in the node tree has a non-empty alt (the export omits placeholders, so check nodes).
+ const ids = [rootId, ...h.query.node(rootId).decendants(true)];
+ const imgs = ids.filter((i) => h.query.node(i).get().data.displayName === 'Image');
+ for (const i of imgs) expect(String(h.query.node(i).get().data.props.alt || '').trim().length, def.id).toBeGreaterThan(0);
+
+ const html = exportBodyHtml(h.getSerialized()).html;
+ expect(html.length).toBeGreaterThan(100);
+ const scrubbed = html.replace(/linear-gradient\([^)]*\)/g, '').replace(/data:image\/svg[^"')]*/g, '');
+ expect(scrubbed.match(/#[0-9a-fA-F]{6}\b/g) || [], def.id).toEqual([]);
+ expect(html).toContain('var(--wsb-');
+
+ for (const m of html.matchAll(/
]*>/g)) expect(m[0], def.id).toMatch(/\balt="[^"]+"/);
+ for (const m of html.matchAll(/\bhref="([^"]*)"/g)) {
+ const href = m[1];
+ expect(href === '#' || /^#[\w-]+$/.test(href) || (!/^[a-z][a-z0-9+.-]*:/i.test(href) && !href.startsWith('//')), `${def.id} href ${href}`).toBe(true);
+ }
+ });
+
+ test('FAQ accordion keeps its markup through the export sanitiser', () => {
+ const html = exportOne(FAQ_SECTIONS.find((s) => s.id === 'faq-accordion')!);
+ expect(html).toContain(' {
+ const html = exportOne(PRICING_SECTIONS.find((s) => s.id === 'pricing-three-tiers')!);
+ expect(html).toContain('2px solid var(--wsb-primary)');
+ expect(html).toContain('$99');
+ expect(html).toContain('Choose Standard');
+ });
+});
diff --git a/craft/src/sections/families/faq.ts b/craft/src/sections/families/faq.ts
index 245c64d..45e69eb 100644
--- a/craft/src/sections/families/faq.ts
+++ b/craft/src/sections/families/faq.ts
@@ -1,4 +1,134 @@
import type { SectionDef } from '../catalog';
+import type { TemplateComponent } from '../../templates/definitions';
+import { tokenRef } from '../../utils/tokens';
-/** Content task appends to FAQ_SECTIONS only (see hero.ts for the shape). */
-export const FAQ_SECTIONS: SectionDef[] = [];
+/** FAQ sections. The accordion is a script-free list in an HtmlBlock:
+ * the Accordion component's static export hard-codes hex body colours, which
+ * would not follow the site palette. */
+
+type Tok = 'bg' | 'primary' | 'text' | 'muted' | 'border';
+
+const section = (aiName: string, children: TemplateComponent[], maxWidth = '900px'): TemplateComponent => ({
+ type: 'Section',
+ props: {
+ aiName, anchorId: '', innerMaxWidth: maxWidth,
+ style: { paddingTop: '72px', paddingBottom: '72px', paddingLeft: '20px', paddingRight: '20px', backgroundColor: tokenRef('bg') },
+ },
+ children,
+});
+
+const heading = (t: string, level: 'h2' | 'h3', align = 'center', extra: Record = {}): TemplateComponent => ({
+ type: 'Heading',
+ props: { text: t, level, style: { color: tokenRef('text'), fontFamily: tokenRef('heading-font'), textAlign: align, marginBottom: '16px', ...extra } },
+});
+
+const text = (t: string, color: Tok = 'muted', align = 'center', extra: Record = {}): TemplateComponent => ({
+ type: 'TextBlock',
+ props: { text: t, style: { color: tokenRef(color), fontFamily: tokenRef('body-font'), lineHeight: '1.7', textAlign: align, ...extra } },
+});
+
+const box = (children: TemplateComponent[], extra: Record = {}): TemplateComponent => ({
+ type: 'Container',
+ props: { tag: 'div', style: { paddingTop: '8px', paddingBottom: '8px', paddingLeft: '8px', paddingRight: '8px', ...extra } },
+ children,
+});
+
+const columns = (n: number, split: string, kids: TemplateComponent[], gap = '32px'): TemplateComponent => ({
+ type: 'ColumnLayout',
+ props: { columns: n, split, gap, anchorId: '' },
+ children: kids,
+});
+
+const QA: Array<[string, string]> = [
+ ['How do I get a quote?', 'Get in touch by phone or through the contact form. We will ask a few questions and send you a clear, no-obligation quote.'],
+ ['What areas do you cover?', 'We serve your town and the surrounding area. If you are unsure whether we cover your address, just ask.'],
+ ['How long does a typical job take?', 'Most jobs are completed within a few days. We will give you a realistic timeline before we start.'],
+ ['What are your opening hours?', 'We are open Monday to Friday, 9am to 5pm. Outside those hours, leave a message and we will reply the next working day.'],
+ ['Do you offer a guarantee?', 'Yes. If you are not happy with our work, tell us and we will put it right.'],
+];
+
+/** Accordion markup; styles reference tokens only so it follows the palette. */
+function detailsHtml(items: Array<[string, string]>, openFirst = true): string {
+ const rows = items.map(([q, a], i) =>
+ `` +
+ `${q}
` +
+ `${a}
` +
+ ` `).join('');
+ return `${rows}
`;
+}
+
+const accordion = (items: Array<[string, string]>, openFirst = true): TemplateComponent => ({
+ type: 'HtmlBlock',
+ props: { code: detailsHtml(items, openFirst) },
+});
+
+const qaBlock = (q: string, a: string): TemplateComponent =>
+ box([heading(q, 'h3', 'left', { fontSize: '20px', marginBottom: '8px' }), text(a, 'muted', 'left')]);
+
+const qaCard = (q: string, a: string): TemplateComponent => ({
+ type: 'Container',
+ props: {
+ tag: 'div',
+ style: {
+ paddingTop: '24px', paddingBottom: '24px', paddingLeft: '24px', paddingRight: '24px',
+ backgroundColor: tokenRef('bg'), border: `1px solid ${tokenRef('border')}`, borderRadius: '12px',
+ },
+ },
+ children: [heading(q, 'h3', 'left', { fontSize: '18px', marginBottom: '8px' }), text(a, 'muted', 'left', { fontSize: '15px' })],
+});
+
+export const FAQ_SECTIONS: SectionDef[] = [
+ {
+ id: 'faq-accordion',
+ name: 'FAQ - centred accordion',
+ family: 'faq',
+ keywords: ['questions', 'answers', 'accordion', 'help', 'collapsible'],
+ tree: section('FAQ', [
+ heading('Frequently asked questions', 'h2'),
+ text('Quick answers to the things people ask us most.', 'muted', 'center', { marginBottom: '32px' }),
+ accordion(QA),
+ ]),
+ },
+ {
+ id: 'faq-split-intro',
+ name: 'FAQ - intro beside accordion',
+ family: 'faq',
+ keywords: ['questions', 'answers', 'accordion', 'split', 'contact'],
+ tree: section('FAQ with intro', [
+ columns(2, '35-65', [
+ box([
+ heading('Questions? We have answers.', 'h2', 'left'),
+ text('Cannot find what you are looking for? Get in touch and we will be happy to help.', 'muted', 'left', { marginBottom: '20px' }),
+ { type: 'ButtonLink', props: { text: 'Contact us', href: '#contact', style: { backgroundColor: tokenRef('primary'), color: tokenRef('bg'), paddingTop: '12px', paddingBottom: '12px', paddingLeft: '24px', paddingRight: '24px', borderRadius: '8px', fontWeight: '600', fontSize: '16px' } } },
+ ]),
+ box([accordion(QA.slice(0, 4))]),
+ ]),
+ ], '1100px'),
+ },
+ {
+ id: 'faq-two-column',
+ name: 'FAQ - two columns, all visible',
+ family: 'faq',
+ keywords: ['questions', 'answers', 'two column', 'list', 'plain'],
+ tree: section('FAQ two columns', [
+ heading('Good to know', 'h2'),
+ text('Everything you need to know before you get in touch.', 'muted', 'center', { marginBottom: '40px' }),
+ columns(2, '50-50', [
+ box([qaBlock(QA[0][0], QA[0][1]), qaBlock(QA[1][0], QA[1][1]), qaBlock(QA[2][0], QA[2][1])]),
+ box([qaBlock(QA[3][0], QA[3][1]), qaBlock(QA[4][0], QA[4][1]), qaBlock('Can I change or cancel a booking?', 'Yes. Let us know as early as you can and we will rearrange or cancel at no charge.')]),
+ ], '40px'),
+ ], '1100px'),
+ },
+ {
+ id: 'faq-cards-grid',
+ name: 'FAQ - card grid',
+ family: 'faq',
+ keywords: ['questions', 'answers', 'cards', 'grid', 'boxes'],
+ tree: section('FAQ cards', [
+ heading('Your questions, answered', 'h2', 'center', { marginBottom: '36px' }),
+ columns(2, '50-50', [qaCard(QA[0][0], QA[0][1]), qaCard(QA[1][0], QA[1][1])], '20px'),
+ { type: 'Spacer', props: { height: '20px' } },
+ columns(2, '50-50', [qaCard(QA[2][0], QA[2][1]), qaCard(QA[4][0], QA[4][1])], '20px'),
+ ], '1000px'),
+ },
+];
diff --git a/craft/src/sections/families/pricing.ts b/craft/src/sections/families/pricing.ts
index a5621a7..af0d098 100644
--- a/craft/src/sections/families/pricing.ts
+++ b/craft/src/sections/families/pricing.ts
@@ -1,4 +1,153 @@
import type { SectionDef } from '../catalog';
+import type { TemplateComponent } from '../../templates/definitions';
+import { tokenRef } from '../../utils/tokens';
-/** Content task appends to PRICING_SECTIONS only (see hero.ts for the shape). */
-export const PRICING_SECTIONS: SectionDef[] = [];
+/** Pricing sections, built from primitives: the PricingTable component's static
+ * export hard-codes hex colours (badge, borders, check marks) that would not
+ * follow the site palette. */
+
+type Tok = 'bg' | 'primary' | 'text' | 'muted' | 'border' | 'accent';
+
+const section = (aiName: string, children: TemplateComponent[], maxWidth = '1100px'): TemplateComponent => ({
+ type: 'Section',
+ props: {
+ aiName, anchorId: '', innerMaxWidth: maxWidth,
+ style: { paddingTop: '72px', paddingBottom: '72px', paddingLeft: '20px', paddingRight: '20px', backgroundColor: tokenRef('bg') },
+ },
+ children,
+});
+
+const heading = (t: string, level: 'h2' | 'h3' | 'h4', color: Tok = 'text', align = 'center', extra: Record = {}): TemplateComponent => ({
+ type: 'Heading',
+ props: { text: t, level, style: { color: tokenRef(color), fontFamily: tokenRef('heading-font'), textAlign: align, marginBottom: '12px', ...extra } },
+});
+
+const text = (t: string, color: Tok = 'muted', align = 'center', extra: Record = {}): TemplateComponent => ({
+ type: 'TextBlock',
+ props: { text: t, style: { color: tokenRef(color), fontFamily: tokenRef('body-font'), lineHeight: '1.6', textAlign: align, ...extra } },
+});
+
+const button = (label: string, filled: boolean): TemplateComponent => ({
+ type: 'ButtonLink',
+ props: {
+ text: label, href: '#contact',
+ style: filled
+ ? { backgroundColor: tokenRef('primary'), color: tokenRef('bg'), paddingTop: '12px', paddingBottom: '12px', paddingLeft: '28px', paddingRight: '28px', borderRadius: '8px', fontWeight: '600', fontSize: '16px' }
+ : { backgroundColor: 'transparent', color: tokenRef('primary'), border: `2px solid ${tokenRef('primary')}`, paddingTop: '10px', paddingBottom: '10px', paddingLeft: '26px', paddingRight: '26px', borderRadius: '8px', fontWeight: '600', fontSize: '16px' },
+ },
+});
+
+const columns = (n: number, split: string, kids: TemplateComponent[], gap = '24px'): TemplateComponent => ({
+ type: 'ColumnLayout',
+ props: { columns: n, split, gap, anchorId: '' },
+ children: kids,
+});
+
+const box = (children: TemplateComponent[], style: Record = {}): TemplateComponent => ({
+ type: 'Container',
+ props: { tag: 'div', style: { paddingTop: '8px', paddingBottom: '8px', paddingLeft: '8px', paddingRight: '8px', ...style } },
+ children,
+});
+
+const feature = (f: string): TemplateComponent =>
+ text(`✓ ${f}`, 'text', 'left', { marginBottom: '8px', fontSize: '15px' });
+
+function plan(name: string, price: string, period: string, blurb: string, features: string[], cta: string, featured: boolean): TemplateComponent {
+ const kids: TemplateComponent[] = [];
+ if (featured) kids.push(text('MOST POPULAR', 'primary', 'center', { fontSize: '12px', fontWeight: '700', letterSpacing: '2px', marginBottom: '8px' }));
+ kids.push(
+ heading(name, 'h3', 'text', 'center', { fontSize: '22px', marginBottom: '4px' }),
+ text(blurb, 'muted', 'center', { fontSize: '14px', marginBottom: '16px' }),
+ heading(price, 'h2', 'primary', 'center', { fontSize: '44px', marginBottom: '0', lineHeight: '1.1' }),
+ text(period, 'muted', 'center', { fontSize: '14px', marginBottom: '20px' }),
+ ...features.map(feature),
+ box([button(cta, featured)], { textAlign: 'center', marginTop: '16px' }),
+ );
+ return {
+ type: 'Container',
+ props: {
+ tag: 'div',
+ style: {
+ paddingTop: '32px', paddingBottom: '32px', paddingLeft: '28px', paddingRight: '28px',
+ backgroundColor: tokenRef('bg'),
+ border: featured ? `2px solid ${tokenRef('primary')}` : `1px solid ${tokenRef('border')}`,
+ borderRadius: '14px',
+ },
+ },
+ children: kids,
+ };
+}
+
+/** One row of a price list: name + note on the left, price on the right. */
+const priceRow = (name: string, note: string, price: string): TemplateComponent =>
+ box([
+ columns(2, '75-25', [
+ box([heading(name, 'h4', 'text', 'left', { fontSize: '18px', marginBottom: '2px' }), text(note, 'muted', 'left', { fontSize: '14px' })]),
+ box([heading(price, 'h4', 'primary', 'right', { fontSize: '20px', marginBottom: '0' })]),
+ ], '12px'),
+ ], { borderBottom: `1px solid ${tokenRef('border')}`, paddingTop: '14px', paddingBottom: '14px' });
+
+export const PRICING_SECTIONS: SectionDef[] = [
+ {
+ id: 'pricing-three-tiers',
+ name: 'Pricing - three tiers',
+ family: 'pricing',
+ keywords: ['plans', 'packages', 'tiers', 'prices', 'comparison'],
+ tree: section('Pricing', [
+ heading('Simple, clear pricing', 'h2'),
+ text('Pick the package that suits you. No hidden fees.', 'muted', 'center', { marginBottom: '40px' }),
+ columns(3, '33-33-33', [
+ plan('Basic', '$49', 'per month', 'For getting started', ['One service visit', 'Email support', 'Online booking'], 'Choose Basic', false),
+ plan('Standard', '$99', 'per month', 'Our most popular option', ['Everything in Basic', 'Priority scheduling', 'Phone support', 'Monthly report'], 'Choose Standard', true),
+ plan('Premium', '$199', 'per month', 'For the full service', ['Everything in Standard', 'Dedicated contact', 'Same-day response', 'Quarterly review'], 'Choose Premium', false),
+ ]),
+ ]),
+ },
+ {
+ id: 'pricing-two-plans',
+ name: 'Pricing - two plans',
+ family: 'pricing',
+ keywords: ['plans', 'packages', 'two options', 'prices', 'compare'],
+ tree: section('Pricing two plans', [
+ heading('Choose your plan', 'h2'),
+ text('Two straightforward options. Change or cancel any time.', 'muted', 'center', { marginBottom: '40px' }),
+ columns(2, '50-50', [
+ plan('Essential', '$79', 'per month', 'Everything you need', ['Core service', 'Email support', 'Cancel any time'], 'Get Essential', false),
+ plan('Complete', '$149', 'per month', 'The full package', ['Everything in Essential', 'Priority support', 'Extra visits included'], 'Get Complete', true),
+ ], '28px'),
+ ], '820px'),
+ },
+ {
+ id: 'pricing-price-list',
+ name: 'Pricing - simple price list',
+ family: 'pricing',
+ keywords: ['price list', 'menu', 'rates', 'services', 'fees'],
+ tree: section('Price list', [
+ heading('Our prices', 'h2'),
+ text('Clear starting prices for our most common services.', 'muted', 'center', { marginBottom: '32px' }),
+ priceRow('Standard service', 'A short description of what is included', 'from $60'),
+ priceRow('Extended service', 'More time and more detail for larger jobs', 'from $120'),
+ priceRow('Premium service', 'Our complete, all-inclusive option', 'from $220'),
+ priceRow('Call-out fee', 'Applies to visits outside normal hours', '$40'),
+ box([text('Prices are a guide and may vary by job. Contact us for a free quote.', 'muted', 'center', { fontSize: '14px', marginTop: '20px' })]),
+ ], '760px'),
+ },
+ {
+ id: 'pricing-single-offer',
+ name: 'Pricing - single offer with details',
+ family: 'pricing',
+ keywords: ['offer', 'flat fee', 'package', 'price', 'highlight'],
+ tree: section('Pricing single offer', [
+ columns(2, '55-45', [
+ box([
+ heading('One fair price for the whole job', 'h2', 'text', 'left'),
+ text('No surprises and no extras. You pay one flat fee and we take care of everything from start to finish.', 'muted', 'left', { marginBottom: '20px' }),
+ feature('Free, no-obligation consultation'),
+ feature('All materials and labour included'),
+ feature('Written guarantee on our work'),
+ ]),
+ plan('Complete package', '$299', 'one-off payment', 'Everything included', ['Planning and preparation', 'Full delivery', 'Aftercare support'], 'Get started', true),
+ ], '40px'),
+ ]),
+ },
+];
diff --git a/craft/src/sections/families/social-proof.ts b/craft/src/sections/families/social-proof.ts
index 6dbdc6f..26c1f4b 100644
--- a/craft/src/sections/families/social-proof.ts
+++ b/craft/src/sections/families/social-proof.ts
@@ -1,4 +1,194 @@
import type { SectionDef } from '../catalog';
+import type { TemplateComponent } from '../../templates/definitions';
+import { tokenRef } from '../../utils/tokens';
-/** Content task appends to SOCIAL_PROOF_SECTIONS only (see hero.ts for the shape). */
-export const SOCIAL_PROOF_SECTIONS: SectionDef[] = [];
+/** Social-proof sections: testimonials, logos / trust badges, stats.
+ * Built from primitives (Section / ColumnLayout / Heading / TextBlock) rather
+ * than the Testimonials component, whose static export hard-codes hex colours. */
+
+type Tok = 'bg' | 'primary' | 'secondary' | 'accent' | 'text' | 'muted' | 'border';
+
+const section = (aiName: string, children: TemplateComponent[], bg: Tok = 'bg'): TemplateComponent => ({
+ type: 'Section',
+ props: {
+ aiName, anchorId: '', innerMaxWidth: '1100px',
+ style: { paddingTop: '72px', paddingBottom: '72px', paddingLeft: '20px', paddingRight: '20px', backgroundColor: tokenRef(bg) },
+ },
+ children,
+});
+
+const heading = (text: string, level: 'h2' | 'h3' | 'h4', color: Tok = 'text', extra: Record = {}): TemplateComponent => ({
+ type: 'Heading',
+ props: { text, level, style: { color: tokenRef(color), fontFamily: tokenRef('heading-font'), textAlign: 'center', marginBottom: '16px', ...extra } },
+});
+
+const text = (t: string, color: Tok = 'muted', extra: Record = {}): TemplateComponent => ({
+ type: 'TextBlock',
+ props: { text: t, style: { color: tokenRef(color), fontFamily: tokenRef('body-font'), lineHeight: '1.7', textAlign: 'center', ...extra } },
+});
+
+const stars = (): TemplateComponent =>
+ text('★★★★★', 'accent', { fontSize: '20px', letterSpacing: '3px', marginBottom: '12px' });
+
+const card = (children: TemplateComponent[], extra: Record = {}): TemplateComponent => ({
+ type: 'Container',
+ props: {
+ tag: 'div',
+ style: {
+ paddingTop: '28px', paddingBottom: '28px', paddingLeft: '24px', paddingRight: '24px',
+ backgroundColor: tokenRef('bg'), border: `1px solid ${tokenRef('border')}`, borderRadius: '12px', ...extra,
+ },
+ },
+ children,
+});
+
+const plain = (children: TemplateComponent[], extra: Record = {}): TemplateComponent => ({
+ type: 'Container',
+ props: { tag: 'div', style: { paddingTop: '8px', paddingBottom: '8px', paddingLeft: '8px', paddingRight: '8px', ...extra } },
+ children,
+});
+
+const columns = (n: number, split: string, kids: TemplateComponent[], gap = '24px'): TemplateComponent => ({
+ type: 'ColumnLayout',
+ props: { columns: n, split, gap, anchorId: '' },
+ children: kids,
+});
+
+const testimonialCard = (quote: string, name: string, role: string): TemplateComponent =>
+ card([
+ stars(),
+ text(`“${quote}”`, 'text', { fontStyle: 'italic', marginBottom: '16px' }),
+ text(name, 'text', { fontWeight: '700', fontSize: '15px' }),
+ text(role, 'muted', { fontSize: '13px' }),
+ ]);
+
+const logoWord = (label: string): TemplateComponent =>
+ plain([text(label, 'muted', { fontWeight: '700', fontSize: '18px', letterSpacing: '1px', textTransform: 'uppercase' })], { textAlign: 'center' });
+
+const badge = (icon: string, title: string, sub: string): TemplateComponent =>
+ plain([
+ { type: 'Icon', props: { icon, size: '32px', color: tokenRef('primary') } },
+ text(title, 'text', { fontWeight: '700', fontSize: '15px' }),
+ text(sub, 'muted', { fontSize: '13px' }),
+ ], { textAlign: 'center' });
+
+const stat = (value: string, label: string, valueColor: Tok, labelColor: Tok): TemplateComponent =>
+ plain([
+ heading(value, 'h3', valueColor, { fontSize: '48px', marginBottom: '4px', lineHeight: '1.1' }),
+ text(label, labelColor, { fontWeight: '500' }),
+ ], { textAlign: 'center' });
+
+export const SOCIAL_PROOF_SECTIONS: SectionDef[] = [
+ {
+ id: 'social-proof-testimonials-cards',
+ name: 'Testimonials - three cards',
+ family: 'social-proof',
+ keywords: ['reviews', 'customers', 'quotes', 'feedback', 'cards'],
+ tree: section('Testimonials', [
+ heading('What our customers say', 'h2'),
+ text('Real feedback from the people we have worked with.', 'muted', { maxWidth: '560px', margin: '0 auto 40px' }),
+ columns(3, '33-33-33', [
+ testimonialCard('Friendly, fast and great value. We would happily use them again.', 'Alex P.', 'Local customer'),
+ testimonialCard('Exactly what we needed, and the team was a pleasure to deal with.', 'Jordan M.', 'Small business owner'),
+ testimonialCard('Professional from the first call to the finished job.', 'Sam R.', 'Repeat customer'),
+ ]),
+ ], 'bg'),
+ },
+ {
+ id: 'social-proof-testimonial-featured',
+ name: 'Testimonial - single featured quote',
+ family: 'social-proof',
+ keywords: ['quote', 'review', 'featured', 'customer story', 'centred'],
+ tree: section('Featured testimonial', [
+ stars(),
+ heading('“The best decision we made for our business this year. Clear, reliable and genuinely helpful.”', 'h3', 'text', { fontSize: '28px', lineHeight: '1.4', maxWidth: '760px', margin: '0 auto 24px', fontWeight: '600' }),
+ text('Jordan M.', 'text', { fontWeight: '700' }),
+ text('Owner, Example Company', 'muted', { fontSize: '14px' }),
+ ], 'bg'),
+ },
+ {
+ id: 'social-proof-testimonial-photo',
+ name: 'Testimonial - quote beside photo',
+ family: 'social-proof',
+ keywords: ['quote', 'photo', 'customer story', 'split', 'review'],
+ tree: section('Testimonial with photo', [
+ columns(2, '40-60', [
+ plain([{ type: 'ImageBlock', props: { alt: 'Photo of a happy customer', style: { width: '100%', height: 'auto', aspectRatio: '1 / 1', objectFit: 'cover', borderRadius: '16px' } } }]),
+ plain([
+ stars(),
+ text('“From our first conversation I knew we were in good hands. The result exceeded what we had hoped for, and it was delivered on time.”', 'text', { textAlign: 'left', fontSize: '20px', fontStyle: 'italic', marginBottom: '20px' }),
+ text('Sam R.', 'text', { textAlign: 'left', fontWeight: '700' }),
+ text('Customer since 2022', 'muted', { textAlign: 'left', fontSize: '14px' }),
+ ], { paddingLeft: '24px' }),
+ ], '32px'),
+ ], 'bg'),
+ },
+ {
+ id: 'social-proof-reviews-strip',
+ name: 'Testimonials - star review strip',
+ family: 'social-proof',
+ keywords: ['reviews', 'stars', 'ratings', 'google', 'strip'],
+ tree: {
+ type: 'Section',
+ props: {
+ aiName: 'Reviews', anchorId: '', innerMaxWidth: '1100px',
+ style: { paddingTop: '24px', paddingBottom: '24px', paddingLeft: '20px', paddingRight: '20px', backgroundColor: tokenRef('bg') },
+ },
+ children: [{ type: 'ReviewsStrip', props: { cardBg: tokenRef('bg'), style: { backgroundColor: 'transparent' } } }],
+ },
+ },
+ {
+ id: 'social-proof-logos-strip',
+ name: 'Logos - trusted by strip',
+ family: 'social-proof',
+ keywords: ['logos', 'clients', 'partners', 'trusted by', 'brands'],
+ tree: section('Trusted by', [
+ text('Trusted by local businesses and organisations', 'muted', { fontSize: '14px', textTransform: 'uppercase', letterSpacing: '2px', marginBottom: '28px' }),
+ columns(5, '20-20-20-20-20', [
+ logoWord('Client one'), logoWord('Client two'), logoWord('Client three'), logoWord('Client four'), logoWord('Client five'),
+ ], '16px'),
+ ], 'bg'),
+ },
+ {
+ id: 'social-proof-trust-badges',
+ name: 'Trust badges - four icons',
+ family: 'social-proof',
+ keywords: ['badges', 'trust', 'guarantee', 'licensed', 'accreditations'],
+ tree: section('Trust badges', [
+ columns(4, '25-25-25-25', [
+ badge('fa-shield', 'Licensed & insured', 'Fully covered for your peace of mind'),
+ badge('fa-star', 'Highly rated', 'Rated five stars by local customers'),
+ badge('fa-check-circle', 'Satisfaction guaranteed', 'We make it right or we refund it'),
+ badge('fa-clock-o', 'Established 2010', 'Over a decade serving the community'),
+ ], '16px'),
+ ], 'bg'),
+ },
+ {
+ id: 'social-proof-stats-row',
+ name: 'Stats - four numbers',
+ family: 'social-proof',
+ keywords: ['stats', 'numbers', 'counters', 'achievements', 'metrics'],
+ tree: section('Stats', [
+ columns(4, '25-25-25-25', [
+ stat('500+', 'Happy customers', 'primary', 'muted'),
+ stat('12', 'Years in business', 'primary', 'muted'),
+ stat('98%', 'Satisfaction rate', 'primary', 'muted'),
+ stat('24h', 'Average response time', 'primary', 'muted'),
+ ], '16px'),
+ ], 'bg'),
+ },
+ {
+ id: 'social-proof-stats-band',
+ name: 'Stats - bold colour band',
+ family: 'social-proof',
+ keywords: ['stats', 'numbers', 'band', 'highlights', 'colour'],
+ tree: section('Stats band', [
+ heading('Our track record', 'h2', 'bg', { marginBottom: '32px' }),
+ columns(3, '33-33-33', [
+ stat('500+', 'Projects completed', 'bg', 'bg'),
+ stat('12', 'Years of experience', 'bg', 'bg'),
+ stat('98%', 'Customers who return', 'bg', 'bg'),
+ ], '16px'),
+ ], 'primary'),
+ },
+];
diff --git a/craft/src/sections/families/team.ts b/craft/src/sections/families/team.ts
index 4995301..4871f8e 100644
--- a/craft/src/sections/families/team.ts
+++ b/craft/src/sections/families/team.ts
@@ -1,4 +1,114 @@
import type { SectionDef } from '../catalog';
+import type { TemplateComponent } from '../../templates/definitions';
+import { tokenRef } from '../../utils/tokens';
-/** Content task appends to TEAM_SECTIONS only (see hero.ts for the shape). */
-export const TEAM_SECTIONS: SectionDef[] = [];
+/** Team sections. Photos are placeholder ImageBlocks (omitted from the export
+ * until the user drops a photo in). */
+
+type Tok = 'bg' | 'primary' | 'text' | 'muted' | 'border';
+
+const section = (aiName: string, children: TemplateComponent[]): TemplateComponent => ({
+ type: 'Section',
+ props: {
+ aiName, anchorId: '', innerMaxWidth: '1100px',
+ style: { paddingTop: '72px', paddingBottom: '72px', paddingLeft: '20px', paddingRight: '20px', backgroundColor: tokenRef('bg') },
+ },
+ children,
+});
+
+const heading = (t: string, level: 'h2' | 'h3' | 'h4', align = 'center', extra: Record = {}): TemplateComponent => ({
+ type: 'Heading',
+ props: { text: t, level, style: { color: tokenRef('text'), fontFamily: tokenRef('heading-font'), textAlign: align, marginBottom: '8px', ...extra } },
+});
+
+const text = (t: string, color: Tok = 'muted', align = 'center', extra: Record = {}): TemplateComponent => ({
+ type: 'TextBlock',
+ props: { text: t, style: { color: tokenRef(color), fontFamily: tokenRef('body-font'), lineHeight: '1.6', textAlign: align, ...extra } },
+});
+
+const columns = (n: number, split: string, kids: TemplateComponent[], gap = '24px'): TemplateComponent => ({
+ type: 'ColumnLayout',
+ props: { columns: n, split, gap, anchorId: '' },
+ children: kids,
+});
+
+const box = (children: TemplateComponent[], style: Record = {}): TemplateComponent => ({
+ type: 'Container',
+ props: { tag: 'div', style: { paddingTop: '8px', paddingBottom: '8px', paddingLeft: '8px', paddingRight: '8px', ...style } },
+ children,
+});
+
+const photo = (alt: string, round: boolean): TemplateComponent => ({
+ type: 'ImageBlock',
+ props: {
+ alt,
+ style: round
+ ? { width: '120px', height: '120px', aspectRatio: '1 / 1', objectFit: 'cover', borderRadius: '50%', marginLeft: 'auto', marginRight: 'auto', marginBottom: '12px' }
+ : { width: '100%', height: 'auto', aspectRatio: '4 / 5', objectFit: 'cover', borderRadius: '12px', marginBottom: '16px' },
+ },
+});
+
+const member = (name: string, role: string, bio: string): TemplateComponent =>
+ box([
+ photo(`Portrait of ${name}`, false),
+ heading(name, 'h3', 'center', { fontSize: '20px' }),
+ text(role, 'primary', 'center', { fontWeight: '600', fontSize: '14px', marginBottom: '8px' }),
+ text(bio, 'muted', 'center', { fontSize: '15px' }),
+ ]);
+
+const compact = (name: string, role: string): TemplateComponent =>
+ box([
+ photo(`Portrait of ${name}`, true),
+ heading(name, 'h3', 'center', { fontSize: '18px', marginBottom: '2px' }),
+ text(role, 'muted', 'center', { fontSize: '14px' }),
+ ]);
+
+export const TEAM_SECTIONS: SectionDef[] = [
+ {
+ id: 'team-grid-three',
+ name: 'Team - three profiles with bios',
+ family: 'team',
+ keywords: ['staff', 'people', 'meet the team', 'bios', 'profiles'],
+ tree: section('Team', [
+ heading('Meet the team', 'h2'),
+ text('The friendly people behind your Business.', 'muted', 'center', { marginBottom: '40px' }),
+ columns(3, '33-33-33', [
+ member('Alex Taylor', 'Owner', 'A short line about experience, what they love about the work and how they can help you.'),
+ member('Jordan Lee', 'Senior specialist', 'A short line about experience, what they love about the work and how they can help you.'),
+ member('Sam Rivera', 'Customer care', 'A short line about experience, what they love about the work and how they can help you.'),
+ ], '32px'),
+ ]),
+ },
+ {
+ id: 'team-compact-four',
+ name: 'Team - four round portraits',
+ family: 'team',
+ keywords: ['staff', 'people', 'portraits', 'compact', 'avatars'],
+ tree: section('Team compact', [
+ heading('Our people', 'h2', 'center', { marginBottom: '36px' }),
+ columns(4, '25-25-25-25', [
+ compact('Alex Taylor', 'Owner'),
+ compact('Jordan Lee', 'Senior specialist'),
+ compact('Sam Rivera', 'Customer care'),
+ compact('Casey Morgan', 'Office manager'),
+ ], '16px'),
+ ]),
+ },
+ {
+ id: 'team-owner-feature',
+ name: 'Team - owner story beside photo',
+ family: 'team',
+ keywords: ['owner', 'founder', 'about', 'story', 'meet'],
+ tree: section('Meet the owner', [
+ columns(2, '40-60', [
+ box([photo('Portrait of the owner', false)]),
+ box([
+ text('MEET THE OWNER', 'primary', 'left', { fontSize: '13px', fontWeight: '700', letterSpacing: '2px', marginBottom: '8px' }),
+ heading('Hello, I am Alex', 'h2', 'left'),
+ text('I started this business to give local people a service they can trust. Tell visitors a little about your background, what drives you and what customers can expect when they work with you.', 'muted', 'left', { marginBottom: '16px' }),
+ text('Alex Taylor, Owner', 'text', 'left', { fontWeight: '700' }),
+ ], { paddingLeft: '16px' }),
+ ], '32px'),
+ ]),
+ },
+];