diff --git a/craft/src/panels/sections/SectionLibraryModal.test.tsx b/craft/src/panels/sections/SectionLibraryModal.test.tsx
index 328e94c..bc76de8 100644
--- a/craft/src/panels/sections/SectionLibraryModal.test.tsx
+++ b/craft/src/panels/sections/SectionLibraryModal.test.tsx
@@ -53,7 +53,9 @@ describe('SectionLibraryModal', () => {
h.act(() => openSectionLibrary());
const chip = (l: string) => $$('.section-lib-chip').find((c) => c.textContent === l)!;
h.act(() => chip('Hero').click());
- expect($$('.section-lib-card').map((c) => c.dataset.sectionId)).toEqual(['hero-centered']);
+ const heroIds = $$('.section-lib-card').map((c) => c.dataset.sectionId);
+ expect(heroIds).toContain('hero-centered');
+ expect(heroIds.every((id) => id!.startsWith('hero-'))).toBe(true);
h.act(() => chip('All').click());
type($('.section-lib-search') as HTMLInputElement, 'form');
expect($$('.section-lib-card').map((c) => c.dataset.sectionId)).toContain('contact-form');
diff --git a/craft/src/sections/families/a-content.test.tsx b/craft/src/sections/families/a-content.test.tsx
new file mode 100644
index 0000000..43d3af3
--- /dev/null
+++ b/craft/src/sections/families/a-content.test.tsx
@@ -0,0 +1,94 @@
+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 { HERO_SECTIONS, SERVICES_SECTIONS, ABOUT_SECTIONS, CTA_SECTIONS } from './index';
+import type { TemplateComponent } from '../../templates/definitions';
+
+let h: EditorHarness;
+afterEach(() => h?.unmount());
+
+const FAMILIES: Array<[string, SectionDef[]]> = [
+ ['hero', HERO_SECTIONS],
+ ['services', SERVICES_SECTIONS],
+ ['about', ABOUT_SECTIONS],
+ ['cta', CTA_SECTIONS],
+];
+/** FeaturesGrid hardcodes card colours in its own render/toHtml (no colour props), so the
+ * skeleton's `services-grid` cannot be token-clean; `services-icon-cards` is the token-clean
+ * equivalent. Only the exported-HTML hex check is skipped (the tree itself is still checked). */
+const HEX_EXEMPT_EXPORT = new Set(['services-grid']);
+const ALL = FAMILIES.flatMap(([, defs]) => defs);
+
+function walk(node: TemplateComponent, fn: (n: TemplateComponent) => void) {
+ fn(node);
+ (node.children || []).forEach((c) => walk(c, fn));
+}
+
+/** Strip the places a hex colour is legitimate, then look for any left. */
+export function strayHex(html: string): string[] {
+ const cleaned = html
+ .replace(/data:image\/svg[^"')\s]*/g, '')
+ .replace(/linear-gradient\([^;"]*\)/g, '');
+ return cleaned.match(/#[0-9a-fA-F]{6}\b/g) || [];
+}
+
+describe('A-content section families', () => {
+ test('about 18+ sections overall, 3+ layouts per family', () => {
+ expect(ALL.length).toBeGreaterThanOrEqual(18);
+ for (const [fam, defs] of FAMILIES) {
+ expect(defs.length, fam).toBeGreaterThanOrEqual(3);
+ for (const d of defs) expect(d.family).toBe(fam);
+ }
+ });
+
+ test('ids are unique and prefixed by family; keywords >= 3', () => {
+ expect(new Set(ALL.map((s) => s.id)).size).toBe(ALL.length);
+ for (const s of ALL) {
+ expect(s.id.startsWith(`${s.family}-`), s.id).toBe(true);
+ expect(s.keywords.length, s.id).toBeGreaterThanOrEqual(3);
+ }
+ });
+
+ test('tree shape: top-level block, anchorId blank, no animation, relative links, alt on images, no hex in props', () => {
+ const tops = new Set(['Section', 'HeroSimple', 'CTASection', 'CallToAction']);
+ for (const s of ALL) {
+ expect(tops.has(s.tree.type), s.id).toBe(true);
+ expect(s.tree.props.anchorId, s.id).toBe('');
+ walk(s.tree, (n) => {
+ expect(n.props.animation, s.id).toBeUndefined();
+ if (n.type === 'ImageBlock') expect(String(n.props.alt || '').length, `${s.id} alt`).toBeGreaterThan(0);
+ for (const k of ['href', 'buttonUrl']) {
+ const v = n.props[k];
+ if (typeof v === 'string' && v) expect(v === '#' || /^[a-z0-9][a-z0-9\-_/.]*$/i.test(v), `${s.id} ${k}=${v}`).toBe(true);
+ }
+ for (const c of (n.props.ctas as Array<{ href: string }> | undefined) || []) {
+ expect(c.href === '#' || /^[a-z0-9][a-z0-9\-_/.]*$/i.test(c.href), `${s.id} cta ${c.href}`).toBe(true);
+ }
+ });
+ expect(strayHex(JSON.stringify(s.tree)), `${s.id} tree`).toEqual([]);
+ }
+ });
+
+ for (const def of ALL) {
+ test(`${def.id}: inserts and exports with no stray hex and alt on every image`, () => {
+ h = renderEditorHarness();
+ h.act(() => { insertSection(h.query, h.actions, def); });
+ expect(h.query.node('ROOT').get().data.nodes.length).toBe(1);
+ const out = exportBodyHtml(h.getSerialized()).html;
+ expect(out).not.toContain('Export failed');
+ expect(out.length).toBeGreaterThan(50);
+ if (!HEX_EXEMPT_EXPORT.has(def.id)) expect(strayHex(out), 'stray hex').toEqual([]);
+ for (const img of out.match(/
]*>/g) || []) expect(img, img).toMatch(/\balt="[^"]+"/);
+ expect(out).toContain('var(--wsb-');
+ for (const href of [...out.matchAll(/\shref="([^"]*)"/g)].map((m) => m[1])) {
+ expect(href === '#' || /^[a-z0-9][a-z0-9\-_/.]*$/i.test(href), `href ${href}`).toBe(true);
+ }
+ });
+ }
+
+ test('strayHex detector can fail (guards the guard)', () => {
+ expect(strayHex('
x
')).toEqual(['#ff0000']);
+ expect(strayHex('background:linear-gradient(135deg,#112233 0%,#445566 100%)')).toEqual([]);
+ });
+});
diff --git a/craft/src/sections/families/about.ts b/craft/src/sections/families/about.ts
index d8198a7..4b3993e 100644
--- a/craft/src/sections/families/about.ts
+++ b/craft/src/sections/families/about.ts
@@ -1,4 +1,131 @@
import type { SectionDef } from '../catalog';
+import type { TemplateComponent } from '../../templates/definitions';
+import { tokenRef } from '../../utils/tokens';
-/** Content task appends to ABOUT_SECTIONS only (see hero.ts for the shape). */
-export const ABOUT_SECTIONS: SectionDef[] = [];
+/** About sections. Content tasks append to ABOUT_SECTIONS only. */
+const pad = (v: string, h = '20px') => ({ paddingTop: v, paddingBottom: v, paddingLeft: h, paddingRight: h });
+const h2 = (text: string, align = 'left'): TemplateComponent => ({
+ type: 'Heading',
+ props: { text, level: 'h2', style: { textAlign: align, color: tokenRef('text'), fontFamily: tokenRef('heading-font'), marginBottom: '16px' } },
+});
+const h3 = (text: string, align = 'left'): TemplateComponent => ({
+ type: 'Heading',
+ props: { text, level: 'h3', style: { textAlign: align, color: tokenRef('text'), fontFamily: tokenRef('heading-font'), fontSize: '22px', marginBottom: '8px' } },
+});
+const p = (text: string, align = 'left'): TemplateComponent => ({
+ type: 'TextBlock',
+ props: { text, style: { textAlign: align, color: tokenRef('muted'), fontFamily: tokenRef('body-font'), lineHeight: '1.7', marginBottom: '12px' } },
+});
+const plain = (children: TemplateComponent[]): TemplateComponent => ({
+ type: 'Container', props: { style: { padding: '0px', minHeight: '0px' } }, children,
+});
+const photo = (alt: string): TemplateComponent => ({
+ type: 'ImageBlock',
+ props: { alt, style: { width: '100%', height: 'auto', aspectRatio: '4 / 3', objectFit: 'cover', borderRadius: '12px' } },
+});
+
+const BTN = { backgroundColor: tokenRef('primary'), color: tokenRef('bg'), padding: '12px 24px', borderRadius: '8px', fontWeight: '600', fontSize: '16px', border: 'none' };
+
+export const ABOUT_SECTIONS: SectionDef[] = [
+ {
+ id: 'about-story-photo',
+ name: 'About - story with photo',
+ family: 'about',
+ keywords: ['story', 'about us', 'photo', 'history', 'who we are'],
+ tree: {
+ type: 'Section',
+ props: { aiName: 'About', anchorId: '', style: { ...pad('72px'), backgroundColor: tokenRef('bg') } },
+ children: [
+ { type: 'ColumnLayout', props: { columns: 2, split: '50-50', gap: '48px', style: { alignItems: 'center' } }, children: [
+ photo('Our team at work'),
+ plain([
+ h2('Our story'),
+ p('Tell visitors how your business started and what you care about. A few honest sentences go a long way.'),
+ p('Mention how long you have been in the area and the kind of customers you love working with.'),
+ { type: 'ButtonLink', props: { text: 'Get in touch', href: 'contact', style: BTN } },
+ ]),
+ ] },
+ ],
+ },
+ },
+ {
+ id: 'about-story-photo-right',
+ name: 'About - photo on the right',
+ family: 'about',
+ keywords: ['story', 'about us', 'photo', 'owner', 'welcome'],
+ tree: {
+ type: 'Section',
+ props: { aiName: 'About', anchorId: '', style: { ...pad('72px'), backgroundColor: tokenRef('bg') } },
+ children: [
+ { type: 'ColumnLayout', props: { columns: 2, split: '60-40', gap: '48px', style: { alignItems: 'center' } }, children: [
+ plain([
+ h2('Meet the people behind the business'),
+ p('Introduce yourself and your team in a friendly, down-to-earth way. Say what you stand for.'),
+ p('Mon-Fri 9am-5pm, and always happy to answer your questions.'),
+ ]),
+ photo('Portrait of the owner'),
+ ] },
+ ],
+ },
+ },
+ {
+ id: 'about-stats',
+ name: 'About - numbers band',
+ family: 'about',
+ keywords: ['stats', 'numbers', 'counters', 'experience', 'achievements'],
+ tree: {
+ type: 'Section',
+ props: { aiName: 'About', anchorId: '', style: { ...pad('64px'), backgroundColor: tokenRef('bg') } },
+ children: [
+ h2('A track record you can trust', 'center'),
+ p('Real numbers that show how long we have been serving the community.', 'center'),
+ {
+ type: 'NumberCounter',
+ props: {
+ counters: [
+ { number: 15, suffix: '+', label: 'Years in business' },
+ { number: 500, suffix: '+', label: 'Happy customers' },
+ { number: 1200, suffix: '+', label: 'Jobs completed' },
+ { number: 100, suffix: '%', label: 'Local' },
+ ],
+ columns: 4,
+ style: { backgroundColor: tokenRef('bg') },
+ },
+ },
+ ],
+ },
+ },
+ {
+ id: 'about-values',
+ name: 'About - three values',
+ family: 'about',
+ keywords: ['values', 'principles', 'why choose us', 'three column'],
+ tree: {
+ type: 'Section',
+ props: { aiName: 'About', anchorId: '', style: { ...pad('72px'), backgroundColor: tokenRef('bg') } },
+ children: [
+ h2('What we stand for', 'center'),
+ { type: 'ColumnLayout', props: { columns: 3, split: '33-33-33', gap: '32px' }, children: [
+ plain([h3('Honest', 'center'), p('Clear prices and straight answers, every time.', 'center')]),
+ plain([h3('Reliable', 'center'), p('We turn up when we say we will and finish what we start.', 'center')]),
+ plain([h3('Local', 'center'), p('Proudly part of the community we serve.', 'center')]),
+ ] },
+ ],
+ },
+ },
+ {
+ id: 'about-mission',
+ name: 'About - mission statement',
+ family: 'about',
+ keywords: ['mission', 'statement', 'quote', 'centered', 'purpose'],
+ tree: {
+ type: 'Section',
+ props: { aiName: 'About', anchorId: '', innerMaxWidth: '760px', style: { ...pad('96px'), backgroundColor: tokenRef('bg') } },
+ children: [
+ { type: 'TextBlock', props: { text: 'OUR MISSION', style: { textAlign: 'center', color: tokenRef('primary'), fontFamily: tokenRef('body-font'), fontSize: '14px', fontWeight: '700', letterSpacing: '2px', marginBottom: '12px' } } },
+ { type: 'Heading', props: { text: 'To make life easier for our customers with work we are proud of.', level: 'h2', style: { textAlign: 'center', color: tokenRef('text'), fontFamily: tokenRef('heading-font'), fontSize: '32px', lineHeight: '1.4', marginBottom: '16px' } } },
+ p('A sentence or two expanding on what that means in practice.', 'center'),
+ ],
+ },
+ },
+];
diff --git a/craft/src/sections/families/cta.ts b/craft/src/sections/families/cta.ts
index 637fdf9..60e00a6 100644
--- a/craft/src/sections/families/cta.ts
+++ b/craft/src/sections/families/cta.ts
@@ -1,4 +1,111 @@
import type { SectionDef } from '../catalog';
+import type { TemplateComponent } from '../../templates/definitions';
+import { tokenRef } from '../../utils/tokens';
-/** Content task appends to CTA_SECTIONS only (see hero.ts for the shape). */
-export const CTA_SECTIONS: SectionDef[] = [];
+/** CTA sections. Content tasks append to CTA_SECTIONS only. */
+const gradient = `linear-gradient(135deg, ${tokenRef('primary')} 0%, ${tokenRef('secondary')} 100%)`;
+const btn = (text: string, href: string, kind: 'solid' | 'outline' = 'solid'): TemplateComponent => ({
+ type: 'ButtonLink',
+ props: {
+ text, href,
+ style: kind === 'solid'
+ ? { backgroundColor: tokenRef('bg'), color: tokenRef('primary'), padding: '14px 32px', borderRadius: '8px', fontWeight: '600', fontSize: '16px', border: 'none' }
+ : { backgroundColor: 'transparent', color: tokenRef('bg'), padding: '12px 30px', borderRadius: '8px', fontWeight: '600', fontSize: '16px', border: `2px solid ${tokenRef('bg')}` },
+ },
+});
+const onColour = (type: 'Heading' | 'TextBlock', text: string, extra: Record = {}): TemplateComponent => (
+ type === 'Heading'
+ ? { type, props: { text, level: 'h2', style: { textAlign: 'center', color: tokenRef('bg'), fontFamily: tokenRef('heading-font'), marginBottom: '12px', ...extra } } }
+ : { type, props: { text, style: { textAlign: 'center', color: tokenRef('bg'), fontFamily: tokenRef('body-font'), marginBottom: '24px', ...extra } } }
+);
+const gradientBand = { paddingTop: '80px', paddingBottom: '80px', paddingLeft: '20px', paddingRight: '20px', backgroundColor: tokenRef('primary'), backgroundImage: gradient, textAlign: 'center' };
+const buttonRow = (children: TemplateComponent[]): TemplateComponent => ({
+ type: 'Container', props: { style: { padding: '0px', minHeight: '0px', display: 'flex', justifyContent: 'center', gap: '16px', flexWrap: 'wrap' } }, children,
+});
+const plain = (children: TemplateComponent[]): TemplateComponent => ({
+ type: 'Container', props: { style: { padding: '0px', minHeight: '0px' } }, children,
+});
+
+export const CTA_SECTIONS: SectionDef[] = [
+ {
+ id: 'cta-gradient-band',
+ name: 'CTA - gradient band',
+ family: 'cta',
+ keywords: ['call to action', 'gradient', 'banner', 'contact', 'button'],
+ tree: {
+ type: 'Section',
+ props: { aiName: 'Call to action', anchorId: '', innerMaxWidth: '760px', style: gradientBand },
+ children: [
+ onColour('Heading', 'Ready to get started?'),
+ onColour('TextBlock', 'Get in touch today and we will get back to you within one working day.'),
+ btn('Contact us', 'contact'),
+ ],
+ },
+ },
+ {
+ id: 'cta-two-buttons',
+ name: 'CTA - two buttons',
+ family: 'cta',
+ keywords: ['call to action', 'buttons', 'quote', 'book', 'primary secondary'],
+ tree: {
+ type: 'Section',
+ props: { aiName: 'Call to action', anchorId: '', innerMaxWidth: '760px', style: { ...gradientBand, backgroundImage: 'none' } },
+ children: [
+ onColour('Heading', 'Let us help with your next project'),
+ onColour('TextBlock', 'Request a free quote or take a look at what we offer.'),
+ buttonRow([btn('Get a free quote', 'contact'), btn('See our services', 'services', 'outline')]),
+ ],
+ },
+ },
+ {
+ id: 'cta-strip',
+ name: 'CTA - slim strip',
+ family: 'cta',
+ keywords: ['call to action', 'strip', 'slim', 'inline', 'button'],
+ tree: {
+ type: 'Section',
+ props: { aiName: 'Call to action', anchorId: '', style: { paddingTop: '40px', paddingBottom: '40px', paddingLeft: '20px', paddingRight: '20px', backgroundColor: tokenRef('primary') } },
+ children: [
+ { type: 'ColumnLayout', props: { columns: 2, split: '70-30', gap: '24px', style: { alignItems: 'center' } }, children: [
+ plain([
+ { type: 'Heading', props: { text: 'Questions? We are happy to help.', level: 'h3', style: { color: tokenRef('bg'), fontFamily: tokenRef('heading-font'), fontSize: '26px', marginBottom: '4px' } } },
+ { type: 'TextBlock', props: { text: 'Mon-Fri 9am-5pm', style: { color: tokenRef('bg'), fontFamily: tokenRef('body-font') } } },
+ ]),
+ btn('Contact us', 'contact'),
+ ] },
+ ],
+ },
+ },
+ {
+ id: 'cta-card',
+ name: 'CTA - centered card',
+ family: 'cta',
+ keywords: ['call to action', 'card', 'boxed', 'stay in touch', 'button'],
+ tree: {
+ type: 'Section',
+ props: { aiName: 'Call to action', anchorId: '', innerMaxWidth: '720px', style: { paddingTop: '64px', paddingBottom: '64px', paddingLeft: '20px', paddingRight: '20px', backgroundColor: tokenRef('bg') } },
+ children: [
+ { type: 'Container', props: { style: { paddingTop: '40px', paddingBottom: '40px', paddingLeft: '32px', paddingRight: '32px', minHeight: '0px', border: `1px solid ${tokenRef('border')}`, borderRadius: '16px', backgroundColor: tokenRef('bg'), textAlign: 'center' } }, children: [
+ { type: 'Heading', props: { text: 'Stay in touch', level: 'h2', style: { textAlign: 'center', color: tokenRef('text'), fontFamily: tokenRef('heading-font'), marginBottom: '8px' } } },
+ { type: 'TextBlock', props: { text: 'Questions, quotes or just a hello. We would love to hear from you.', style: { textAlign: 'center', color: tokenRef('muted'), fontFamily: tokenRef('body-font'), marginBottom: '20px' } } },
+ { type: 'ButtonLink', props: { text: 'Send a message', href: 'contact', style: { backgroundColor: tokenRef('primary'), color: tokenRef('bg'), padding: '12px 24px', borderRadius: '8px', fontWeight: '600', fontSize: '16px', border: 'none' } } },
+ ] },
+ ],
+ },
+ },
+ {
+ id: 'cta-call-now',
+ name: 'CTA - call us',
+ family: 'cta',
+ keywords: ['phone', 'call', 'call now', 'contact', 'urgent'],
+ tree: {
+ type: 'Section',
+ props: { aiName: 'Call to action', anchorId: '', innerMaxWidth: '760px', style: { paddingTop: '72px', paddingBottom: '72px', paddingLeft: '20px', paddingRight: '20px', backgroundColor: tokenRef('bg'), textAlign: 'center' } },
+ children: [
+ { type: 'Heading', props: { text: 'Need a hand? Give us a call', level: 'h2', style: { textAlign: 'center', color: tokenRef('text'), fontFamily: tokenRef('heading-font'), marginBottom: '12px' } } },
+ { type: 'TextBlock', props: { text: 'Speak to a real person, Mon-Fri 9am-5pm.', style: { textAlign: 'center', color: tokenRef('muted'), fontFamily: tokenRef('body-font'), marginBottom: '20px' } } },
+ { type: 'ButtonLink', props: { text: 'Call 555-0100', href: 'contact', style: { backgroundColor: tokenRef('primary'), color: tokenRef('bg'), padding: '12px 24px', borderRadius: '8px', fontWeight: '600', fontSize: '16px', border: 'none' } } },
+ ],
+ },
+ },
+];
diff --git a/craft/src/sections/families/hero.ts b/craft/src/sections/families/hero.ts
index de4003c..c719670 100644
--- a/craft/src/sections/families/hero.ts
+++ b/craft/src/sections/families/hero.ts
@@ -1,4 +1,5 @@
import type { SectionDef } from '../catalog';
+import type { TemplateComponent } from '../../templates/definitions';
import { tokenRef } from '../../utils/tokens';
/**
@@ -7,6 +8,17 @@ import { tokenRef } from '../../utils/tokens';
* on the site's palette and fonts, and set no `animation` (the thumbnail
* iframe is script-less).
*/
+const h1 = (text: string, align: 'center' | 'left' = 'center', color = tokenRef('text')): TemplateComponent => ({
+ type: 'Heading',
+ props: { text, level: 'h1', style: { textAlign: align, color, fontFamily: tokenRef('heading-font'), fontSize: '48px', marginBottom: '16px' } },
+});
+const lead = (text: string, align: 'center' | 'left' = 'center', color = tokenRef('muted')): TemplateComponent => ({
+ type: 'TextBlock',
+ props: { text, style: { textAlign: align, color, fontFamily: tokenRef('body-font'), fontSize: '18px', lineHeight: '1.7', maxWidth: '640px', marginBottom: '24px', ...(align === 'center' ? { marginLeft: 'auto', marginRight: 'auto' } : {}) } },
+});
+
+const BTN = { backgroundColor: tokenRef('primary'), color: tokenRef('bg'), padding: '12px 24px', borderRadius: '8px', fontWeight: '600', fontSize: '16px', border: 'none' };
+
export const HERO_SECTIONS: SectionDef[] = [
{
id: 'hero-centered',
@@ -15,11 +27,106 @@ export const HERO_SECTIONS: SectionDef[] = [
keywords: ['headline', 'banner', 'intro', 'welcome', 'landing'],
tree: {
type: 'Section',
- props: { aiName: 'Hero', style: { padding: '96px 20px', backgroundColor: tokenRef('bg'), textAlign: 'center' } },
+ props: { aiName: 'Hero', anchorId: '', style: { padding: '96px 20px', backgroundColor: tokenRef('bg'), textAlign: 'center' } },
children: [
{ type: 'Heading', props: { text: 'Welcome to Your Business', level: 'h1', style: { textAlign: 'center', color: tokenRef('text'), fontFamily: tokenRef('heading-font'), marginBottom: '16px' } } },
{ type: 'TextBlock', props: { text: 'Tell visitors in one or two sentences what you do and who you do it for.', style: { textAlign: 'center', maxWidth: '640px', margin: '0 auto 24px', color: tokenRef('muted'), lineHeight: '1.7' } } },
- { type: 'ButtonLink', props: { text: 'Get in touch', href: 'contact' } },
+ { type: 'ButtonLink', props: { text: 'Get in touch', href: 'contact', style: BTN } },
+ ],
+ },
+ },
+ {
+ id: 'hero-split-photo',
+ name: 'Hero - split with photo',
+ family: 'hero',
+ keywords: ['image', 'photo', 'two column', 'split', 'headline'],
+ tree: {
+ type: 'Section',
+ props: { aiName: 'Hero', anchorId: '', style: { paddingTop: '80px', paddingBottom: '80px', paddingLeft: '20px', paddingRight: '20px', backgroundColor: tokenRef('bg') } },
+ children: [
+ {
+ type: 'ColumnLayout',
+ props: { columns: 2, split: '50-50', gap: '48px', style: { alignItems: 'center' } },
+ children: [
+ { type: 'Container', props: { style: { padding: '0px', minHeight: '0px' } }, children: [
+ h1('Friendly service you can count on', 'left'),
+ lead('A short sentence about what makes your business different and why customers keep coming back.', 'left'),
+ { type: 'ButtonLink', props: { text: 'Book a visit', href: 'contact', style: BTN } },
+ ] },
+ { type: 'ImageBlock', props: { alt: 'Photo of your business', style: { width: '100%', height: 'auto', aspectRatio: '4 / 3', objectFit: 'cover', borderRadius: '12px' } } },
+ ],
+ },
+ ],
+ },
+ },
+ {
+ id: 'hero-gradient-banner',
+ name: 'Hero - gradient banner',
+ family: 'hero',
+ keywords: ['gradient', 'bold', 'banner', 'buttons', 'colour'],
+ tree: {
+ type: 'HeroSimple',
+ props: {
+ anchorId: '',
+ heading: 'Quality you can see, service you can trust',
+ subtitle: 'Add a short line that explains your offer and invites visitors to take the next step.',
+ ctas: [
+ { text: 'Get a quote', href: 'contact', variant: 'primary' },
+ { text: 'Our services', href: 'services', variant: 'outline' },
+ ],
+ bgType: 'gradient',
+ bgGradientFrom: tokenRef('primary'),
+ bgGradientTo: tokenRef('secondary'),
+ bgGradientAngle: 135,
+ overlayColor: tokenRef('text'),
+ overlayOpacity: 0,
+ textColor: tokenRef('bg'),
+ buttonBgColor: tokenRef('accent'),
+ buttonTextColor: tokenRef('text'),
+ minHeight: '480px',
+ style: {},
+ },
+ },
+ },
+ {
+ id: 'hero-solid-banner',
+ name: 'Hero - solid colour banner',
+ family: 'hero',
+ keywords: ['solid', 'colour', 'banner', 'call', 'headline'],
+ tree: {
+ type: 'HeroSimple',
+ props: {
+ anchorId: '',
+ heading: 'Local, reliable and ready to help',
+ subtitle: 'Call us today or send a message and we will get back to you quickly.',
+ ctas: [
+ { text: 'Contact us', href: 'contact', variant: 'primary' },
+ ],
+ bgType: 'color',
+ bgColor: tokenRef('primary'),
+ overlayColor: tokenRef('text'),
+ overlayOpacity: 0,
+ textColor: tokenRef('bg'),
+ buttonBgColor: tokenRef('bg'),
+ buttonTextColor: tokenRef('primary'),
+ minHeight: '420px',
+ style: {},
+ },
+ },
+ },
+ {
+ id: 'hero-minimal',
+ name: 'Hero - minimal headline',
+ family: 'hero',
+ keywords: ['minimal', 'simple', 'headline', 'text only', 'clean'],
+ tree: {
+ type: 'Section',
+ props: { aiName: 'Hero', anchorId: '', innerMaxWidth: '800px', style: { paddingTop: '120px', paddingBottom: '96px', paddingLeft: '20px', paddingRight: '20px', backgroundColor: tokenRef('bg') } },
+ children: [
+ { type: 'TextBlock', props: { text: 'YOUR BUSINESS', style: { color: tokenRef('primary'), fontFamily: tokenRef('body-font'), fontSize: '14px', fontWeight: '700', letterSpacing: '2px', marginBottom: '12px' } } },
+ h1('Simple, honest work done well', 'left'),
+ lead('One clear sentence about what you offer and where you work.', 'left'),
+ { type: 'ButtonLink', props: { text: 'See how we can help', href: 'services', style: BTN } },
],
},
},
diff --git a/craft/src/sections/families/services.ts b/craft/src/sections/families/services.ts
index 1d0929b..be98deb 100644
--- a/craft/src/sections/families/services.ts
+++ b/craft/src/sections/families/services.ts
@@ -1,7 +1,36 @@
import type { SectionDef } from '../catalog';
+import type { TemplateComponent } from '../../templates/definitions';
import { tokenRef } from '../../utils/tokens';
/** Services sections. Content tasks append to SERVICES_SECTIONS only. */
+const pad = (v: string, h = '20px') => ({ paddingTop: v, paddingBottom: v, paddingLeft: h, paddingRight: h });
+const title = (text: string): TemplateComponent => ({
+ type: 'Heading',
+ props: { text, level: 'h2', style: { textAlign: 'center', color: tokenRef('text'), fontFamily: tokenRef('heading-font'), marginBottom: '32px' } },
+});
+const h3 = (text: string, align = 'left'): TemplateComponent => ({
+ type: 'Heading',
+ props: { text, level: 'h3', style: { textAlign: align, color: tokenRef('text'), fontFamily: tokenRef('heading-font'), fontSize: '22px', marginBottom: '8px' } },
+});
+const p = (text: string, align = 'left'): TemplateComponent => ({
+ type: 'TextBlock',
+ props: { text, style: { textAlign: align, color: tokenRef('muted'), fontFamily: tokenRef('body-font'), lineHeight: '1.6', marginBottom: '8px' } },
+});
+const emoji = (e: string): TemplateComponent => ({
+ type: 'TextBlock',
+ props: { text: e, style: { fontSize: '36px', lineHeight: '1.2', marginBottom: '8px' } },
+});
+const card = (children: TemplateComponent[]): TemplateComponent => ({
+ type: 'Container',
+ props: { style: { paddingTop: '28px', paddingBottom: '28px', paddingLeft: '28px', paddingRight: '28px', minHeight: '0px', border: `1px solid ${tokenRef('border')}`, borderRadius: '12px', backgroundColor: tokenRef('bg') } },
+ children,
+});
+const plain = (children: TemplateComponent[]): TemplateComponent => ({
+ type: 'Container', props: { style: { padding: '0px', minHeight: '0px' } }, children,
+});
+
+const BTN = { backgroundColor: tokenRef('primary'), color: tokenRef('bg'), padding: '12px 24px', borderRadius: '8px', fontWeight: '600', fontSize: '16px', border: 'none' };
+
export const SERVICES_SECTIONS: SectionDef[] = [
{
id: 'services-grid',
@@ -10,7 +39,7 @@ export const SERVICES_SECTIONS: SectionDef[] = [
keywords: ['features', 'offerings', 'what we do', 'benefits'],
tree: {
type: 'Section',
- props: { aiName: 'Services', style: { padding: '72px 20px', backgroundColor: tokenRef('bg') } },
+ props: { aiName: 'Services', anchorId: '', style: { padding: '72px 20px', backgroundColor: tokenRef('bg') } },
children: [
{ type: 'Heading', props: { text: 'Our Services', level: 'h2', style: { textAlign: 'center', color: tokenRef('text'), fontFamily: tokenRef('heading-font'), marginBottom: '24px' } } },
{
@@ -27,4 +56,101 @@ export const SERVICES_SECTIONS: SectionDef[] = [
],
},
},
+ {
+ id: 'services-icon-cards',
+ name: 'Services - icon cards',
+ family: 'services',
+ keywords: ['icons', 'cards', 'three column', 'what we do', 'benefits'],
+ tree: {
+ type: 'Section',
+ props: { aiName: 'Services', anchorId: '', style: { ...pad('72px'), backgroundColor: tokenRef('bg') } },
+ children: [
+ title('Our Services'),
+ { type: 'ColumnLayout', props: { columns: 3, split: '33-33-33', gap: '24px' }, children: [
+ card([emoji('⭐'), h3('Service one'), p('A short description of what this service includes.')]),
+ card([emoji('🛠️'), h3('Service two'), p('A short description of what this service includes.')]),
+ card([emoji('💬'), h3('Service three'), p('A short description of what this service includes.')]),
+ ] },
+ ],
+ },
+ },
+ {
+ id: 'services-cards-2x2',
+ name: 'Services - 2x2 cards',
+ family: 'services',
+ keywords: ['cards', 'four', 'grid', 'offerings', 'what we do'],
+ tree: {
+ type: 'Section',
+ props: { aiName: 'Services', anchorId: '', style: { ...pad('72px'), backgroundColor: tokenRef('bg') } },
+ children: [
+ title('What we do'),
+ { type: 'ColumnLayout', props: { columns: 2, split: '50-50', gap: '24px' }, children: [
+ card([emoji('🔧'), h3('Repairs and maintenance'), p('Fast, tidy work to keep everything running as it should.')]),
+ card([emoji('📋'), h3('Planning and advice'), p('Clear recommendations and honest quotes before we start.')]),
+ ] },
+ { type: 'Spacer', props: { height: '24px' } },
+ { type: 'ColumnLayout', props: { columns: 2, split: '50-50', gap: '24px' }, children: [
+ card([emoji('🏠'), h3('On-site visits'), p('We come to you, on a day and time that suits you.')]),
+ card([emoji('🤝'), h3('Ongoing support'), p('Regular check-ins and a friendly voice when you need one.')]),
+ ] },
+ ],
+ },
+ },
+ {
+ id: 'services-alternating-rows',
+ name: 'Services - alternating rows',
+ family: 'services',
+ keywords: ['rows', 'image', 'detail', 'alternating', 'zigzag'],
+ tree: {
+ type: 'Section',
+ props: { aiName: 'Services', anchorId: '', style: { ...pad('72px'), backgroundColor: tokenRef('bg') } },
+ children: [
+ title('How we can help'),
+ { type: 'ColumnLayout', props: { columns: 2, split: '50-50', gap: '48px', style: { alignItems: 'center' } }, children: [
+ { type: 'ImageBlock', props: { alt: 'First service', style: { width: '100%', height: 'auto', aspectRatio: '4 / 3', objectFit: 'cover', borderRadius: '12px' } } },
+ plain([h3('Our main service'), p('Describe the service, who it is for and the result customers can expect.'), { type: 'ButtonLink', props: { text: 'Learn more', href: 'contact', style: BTN } }]),
+ ] },
+ { type: 'Spacer', props: { height: '48px' } },
+ { type: 'ColumnLayout', props: { columns: 2, split: '50-50', gap: '48px', style: { alignItems: 'center' } }, children: [
+ plain([h3('Our second service'), p('Explain what is included and what makes your approach different.'), { type: 'ButtonLink', props: { text: 'Learn more', href: 'contact', style: BTN } }]),
+ { type: 'ImageBlock', props: { alt: 'Second service', style: { width: '100%', height: 'auto', aspectRatio: '4 / 3', objectFit: 'cover', borderRadius: '12px' } } },
+ ] },
+ ],
+ },
+ },
+ {
+ id: 'services-steps',
+ name: 'Services - how it works steps',
+ family: 'services',
+ keywords: ['process', 'steps', 'how it works', 'numbered'],
+ tree: {
+ type: 'Section',
+ props: { aiName: 'Process', anchorId: '', style: { ...pad('72px'), backgroundColor: tokenRef('bg') } },
+ children: [
+ title('How it works'),
+ { type: 'ColumnLayout', props: { columns: 3, split: '33-33-33', gap: '32px' }, children: [
+ plain([{ type: 'Heading', props: { text: '1', level: 'h3', style: { color: tokenRef('primary'), fontFamily: tokenRef('heading-font'), fontSize: '40px', textAlign: 'center', marginBottom: '4px' } } }, h3('Get in touch', 'center'), p('Tell us what you need and we will reply quickly.', 'center')]),
+ plain([{ type: 'Heading', props: { text: '2', level: 'h3', style: { color: tokenRef('primary'), fontFamily: tokenRef('heading-font'), fontSize: '40px', textAlign: 'center', marginBottom: '4px' } } }, h3('We make a plan', 'center'), p('A clear quote and a date that works for you.', 'center')]),
+ plain([{ type: 'Heading', props: { text: '3', level: 'h3', style: { color: tokenRef('primary'), fontFamily: tokenRef('heading-font'), fontSize: '40px', textAlign: 'center', marginBottom: '4px' } } }, h3('Job done', 'center'), p('We finish on time and check you are happy.', 'center')]),
+ ] },
+ ],
+ },
+ },
+ {
+ id: 'services-list-columns',
+ name: 'Services - simple list',
+ family: 'services',
+ keywords: ['list', 'compact', 'offerings', 'two column'],
+ tree: {
+ type: 'Section',
+ props: { aiName: 'Services', anchorId: '', innerMaxWidth: '900px', style: { ...pad('64px'), backgroundColor: tokenRef('bg') } },
+ children: [
+ title('Services we offer'),
+ { type: 'ColumnLayout', props: { columns: 2, split: '50-50', gap: '40px' }, children: [
+ plain([h3('Service one'), p('One line about this service.'), h3('Service two'), p('One line about this service.'), h3('Service three'), p('One line about this service.')]),
+ plain([h3('Service four'), p('One line about this service.'), h3('Service five'), p('One line about this service.'), h3('Service six'), p('One line about this service.')]),
+ ] },
+ ],
+ },
+ },
];