;
+const Grab: React.FC = () => { extrasApi = useProjectExtras(); return null; };
+
+function mount() {
+ h = renderEditorHarness({
+ wrap: (tree) => (
+
{tree}
+ ),
+ });
+ h.mountChild(<>
>);
+}
+const $ = (sel: string) => h.container.querySelector(sel) as HTMLElement | null;
+const $$ = (sel: string) => [...h.container.querySelectorAll(sel)] as HTMLElement[];
+const rootKids = () => h.query.node('ROOT').get().data.nodes as string[];
+const type = (el: HTMLInputElement, v: string) => {
+ const setter = Object.getOwnPropertyDescriptor(HTMLInputElement.prototype, 'value')!.set!;
+ h.act(() => { setter.call(el, v); el.dispatchEvent(new Event('input', { bubbles: true })); });
+};
+
+beforeEach(() => __resetCommandsForTests());
+afterEach(() => { h?.unmount(); closeSectionLibrary(); });
+
+describe('SectionLibraryModal', () => {
+ test('closed by default; Browse sections button in the Blocks panel opens it', () => {
+ mount();
+ expect($('.section-lib')).toBeNull();
+ h.act(() => { ($$('button').find((b) => b.textContent?.includes('Browse sections')))!.click(); });
+ expect($('.section-lib')).not.toBeNull();
+ expect($$('.section-lib-card').length).toBeGreaterThan(0);
+ });
+
+ test('thumbnails are sandboxed, script-less iframes in the site palette', () => {
+ mount();
+ h.act(() => openSectionLibrary());
+ const frame = $('iframe.section-lib-frame') as HTMLIFrameElement;
+ expect(frame.getAttribute('sandbox')).toBe('');
+ expect(frame.getAttribute('srcdoc')).toContain('--wsb-primary:');
+ });
+
+ test('family chip filters and search narrows', () => {
+ mount();
+ 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']);
+ h.act(() => chip('All').click());
+ type($('.section-lib-search') as HTMLInputElement, 'form');
+ expect($$('.section-lib-card').map((c) => c.dataset.sectionId)).toContain('contact-form');
+ type($('.section-lib-search') as HTMLInputElement, 'zzzz');
+ expect($$('.section-lib-card')).toHaveLength(0);
+ });
+
+ test('clicking a card inserts the section, selects it and closes', () => {
+ mount();
+ h.act(() => openSectionLibrary());
+ h.act(() => { ($('[data-section-id="hero-centered"] .section-lib-pick') as HTMLElement).click(); });
+ expect(rootKids()).toHaveLength(1);
+ expect(h.query.getEvent('selected').all()).toEqual([rootKids()[0]]);
+ expect($('.section-lib')).toBeNull();
+ });
+
+ test('registers insert.section-library and site.save-as-section (only for a top-level section)', () => {
+ mount();
+ expect(getCommand('insert.section-library')).toBeTruthy();
+ const save = getCommand('site.save-as-section')!;
+ const ctx = (ids: string[]) => ({ query: h.query, selectedIds: ids } as any);
+ expect(save.when!(ctx([]))).toBe(false);
+ h.act(() => openSectionLibrary());
+ h.act(() => { ($('[data-section-id="hero-centered"] .section-lib-pick') as HTMLElement).click(); });
+ const id = rootKids()[0];
+ expect(save.when!(ctx([id]))).toBe(true);
+ const inner = h.query.node(id).decendants(true)[0];
+ expect(save.when!(ctx([inner]))).toBe(false);
+ });
+
+ test('save current section -> extras.customSections, shown under My sections, deletable', () => {
+ mount();
+ h.act(() => openSectionLibrary());
+ h.act(() => { ($('[data-section-id="hero-centered"] .section-lib-pick') as HTMLElement).click(); });
+ h.act(() => openSectionLibrary());
+ h.act(() => { $$('button').find((b) => b.textContent?.includes('Save current section'))!.click(); });
+ type($('input[aria-label="Section name"]') as HTMLInputElement, 'My hero');
+ h.act(() => { $$('button').find((b) => b.textContent === 'Save')!.click(); });
+ const saved = extrasApi.get
('customSections', []);
+ expect(saved).toHaveLength(1);
+ expect(saved[0]).toMatchObject({ name: 'My hero', family: 'custom' });
+ expect(saved[0].tree.type.resolvedName).toBe('Section');
+ expect($$('.section-lib-card').map((c) => c.dataset.sectionId)).toEqual([saved[0].id]);
+ h.act(() => { ($('.section-lib-delete') as HTMLElement).click(); });
+ expect(extrasApi.get('customSections', [])).toHaveLength(0);
+ });
+});
diff --git a/craft/src/panels/sections/SectionLibraryModal.tsx b/craft/src/panels/sections/SectionLibraryModal.tsx
new file mode 100644
index 0000000..8383464
--- /dev/null
+++ b/craft/src/panels/sections/SectionLibraryModal.tsx
@@ -0,0 +1,219 @@
+import React, { useCallback, useEffect, useMemo, useRef, useState, useSyncExternalStore } from 'react';
+import { useEditor } from '@craftjs/core';
+import { Modal } from '../../ui/Modal';
+import { useSiteDesign } from '../../state/SiteDesignContext';
+import { useProjectExtras } from '../../state/ProjectExtrasContext';
+import { useNotices } from '../../state/NoticeContext';
+import { useRegisterCommands } from '../../editor/commands';
+import {
+ CUSTOM_SECTIONS_KEY, CustomSection, SECTION_FAMILIES, SectionDef, SectionFamily,
+ insertSection, isTopLevelSection, nodeTreeToSerializedTree, searchSections, useSections,
+} from '../../sections/catalog';
+import { buildSectionPreviewDoc } from '../../sections/thumbnails';
+
+// ---- open state: module-level so the command, the empty-state button and the
+// Blocks panel button can open the modal without a provider ----
+interface LibraryState { open: boolean; saving: boolean }
+let state: LibraryState = { open: false, saving: false };
+const listeners = new Set<() => void>();
+const setState = (s: LibraryState) => { state = s; listeners.forEach((l) => l()); };
+const subscribe = (cb: () => void) => { listeners.add(cb); return () => { listeners.delete(cb); }; };
+
+export function openSectionLibrary(opts: { saving?: boolean } = {}): void {
+ setState({ open: true, saving: !!opts.saving });
+}
+export function closeSectionLibrary(): void { setState({ open: false, saving: false }); }
+export function useSectionLibraryState(): LibraryState {
+ return useSyncExternalStore(subscribe, () => state, () => state);
+}
+
+/** Renders its iframe only once scrolled into view (60 live iframes at once is heavy). */
+const LazyPreview: React.FC<{ def: SectionDef; title: string }> = ({ def, title }) => {
+ const { design } = useSiteDesign();
+ const ref = useRef(null);
+ const [seen, setSeen] = useState(typeof IntersectionObserver === 'undefined');
+ useEffect(() => {
+ if (seen || !ref.current) return;
+ const io = new IntersectionObserver((entries) => {
+ if (entries.some((e) => e.isIntersecting)) { setSeen(true); io.disconnect(); }
+ });
+ io.observe(ref.current);
+ return () => io.disconnect();
+ }, [seen]);
+ const doc = useMemo(() => {
+ if (!seen) return '';
+ try { return buildSectionPreviewDoc(def, design); } catch { return ''; }
+ }, [seen, def, design]);
+ return (
+
+ {doc && (
+
+ )}
+
+ );
+};
+
+export const SectionLibraryModal: React.FC = () => {
+ const { open, saving } = useSectionLibraryState();
+ const { query, actions, selectedId } = useEditor((s) => ({ selectedId: Array.from(s.events.selected || [])[0] as string | undefined }));
+ const extras = useProjectExtras();
+ const { notify } = useNotices();
+ const [version, setVersion] = useState(0);
+ const sections = useSections(version);
+ const [family, setFamily] = useState(null);
+ const [q, setQ] = useState('');
+ const [saveName, setSaveName] = useState('');
+
+ const canSave = open && isTopLevelSection(query, selectedId);
+
+ useRegisterCommands([
+ {
+ id: 'insert.section-library',
+ title: 'Browse sections…',
+ group: 'insert',
+ keywords: ['section', 'library', 'hero', 'faq', 'template', 'add section'],
+ run: () => openSectionLibrary(),
+ },
+ {
+ id: 'site.save-as-section',
+ title: 'Save selection as section…',
+ group: 'site',
+ keywords: ['section', 'library', 'save', 'reusable'],
+ when: (ctx) => ctx.selectedIds.length === 1 && isTopLevelSection(ctx.query, ctx.selectedIds[0]),
+ run: () => openSectionLibrary({ saving: true }),
+ },
+ ], []);
+
+ useEffect(() => { if (open) { setQ(''); setFamily(null); setSaveName(''); } }, [open]);
+
+ const results = useMemo(() => searchSections(q, family, sections), [q, family, sections]);
+
+ const onPick = useCallback((def: SectionDef) => {
+ try {
+ const id = insertSection(query, actions, def, { afterSelection: true, select: true });
+ closeSectionLibrary();
+ notify({ kind: 'success', message: `Added "${def.name}"` });
+ requestAnimationFrame(() => requestAnimationFrame(() => {
+ try { query.node(id).get()?.dom?.scrollIntoView({ behavior: 'smooth', block: 'center' }); } catch { /* best effort */ }
+ }));
+ } catch (e) {
+ console.error('Failed to insert section:', e);
+ notify({ kind: 'error', message: 'Could not add that section.' });
+ }
+ }, [query, actions, notify]);
+
+ const onSave = () => {
+ const name = saveName.trim();
+ if (!name || !selectedId) return;
+ try {
+ const entry: CustomSection = {
+ id: `custom-${Date.now().toString(36)}-${Math.random().toString(36).slice(2, 6)}`,
+ name,
+ family: 'custom',
+ tree: nodeTreeToSerializedTree(query, selectedId),
+ createdAt: Date.now(),
+ };
+ extras.update(CUSTOM_SECTIONS_KEY, (prev) => [...prev, entry], []);
+ setVersion((v) => v + 1);
+ setSaveName('');
+ setFamily('custom');
+ openSectionLibrary({ saving: false });
+ notify({ kind: 'success', message: `Saved "${name}" to My sections` });
+ } catch (e) {
+ console.error('Failed to save section:', e);
+ notify({ kind: 'error', message: 'Could not save that section.' });
+ }
+ };
+
+ const onDelete = (id: string) => {
+ extras.update(CUSTOM_SECTIONS_KEY, (prev) => prev.filter((c) => c.id !== id), []);
+ setVersion((v) => v + 1);
+ };
+
+ return (
+
+
+
+
Add a section
+
+
+
+ setQ(e.target.value)}
+ />
+ {canSave && !saving && (
+
+ )}
+
+ {saving && canSave && (
+
+ )}
+
+
+ {SECTION_FAMILIES.map((f) => (
+
+ ))}
+
+
+ {results.map((def) => (
+
+
+ {def.family === 'custom' && (
+
+ )}
+
+ ))}
+ {results.length === 0 && (
+
+ {family === 'custom' ? 'Nothing saved yet. Select a section on the canvas and choose "Save current section".' : `No sections match "${q.trim()}"`}
+
+ )}
+
+
+
+ );
+};
diff --git a/craft/src/sections/catalog.test.tsx b/craft/src/sections/catalog.test.tsx
new file mode 100644
index 0000000..7bf6677
--- /dev/null
+++ b/craft/src/sections/catalog.test.tsx
@@ -0,0 +1,130 @@
+import { describe, test, expect, afterEach } from 'vitest';
+import { renderEditorHarness, EditorHarness } from '../test-utils/editorHarness';
+import { exportBodyHtml } from '../utils/html-export';
+import { flattenTreeForCraft } from '../utils/craft-tree';
+import { templateComponentToTreeNode } from '../templates/apply-template';
+import { DEFAULT_SITE_DESIGN } from '../state/SiteDesignContext';
+import {
+ SECTION_FAMILIES, getSections, insertSection, nodeTreeToSerializedTree, searchSections, type SectionDef, type CustomSection,
+} from './catalog';
+import { buildSectionPreviewDoc } from './thumbnails';
+import { HERO_SECTIONS } from './families';
+
+let h: EditorHarness;
+afterEach(() => h?.unmount());
+
+const hero = HERO_SECTIONS[0];
+const rootKids = () => h.query.node('ROOT').get().data.nodes as string[];
+const nameOf = (id: string) => h.query.node(id).get().data.displayName;
+
+function addBlank(): string {
+ const sec: SectionDef = { id: 'x', name: 'x', family: 'hero', keywords: [], tree: { type: 'Section', props: {}, children: [{ type: 'Heading', props: { text: 'blank' } }] } };
+ return insertSection(h.query, h.actions, sec, { select: false });
+}
+
+describe('insertSection (real editor)', () => {
+ test('inserts after the selected top-level section and selects the new root', () => {
+ h = renderEditorHarness();
+ const a = addBlank();
+ const b = addBlank();
+ expect(rootKids()).toEqual([a, b]);
+ h.act(() => { h.actions.selectNode(a); });
+ let id = '';
+ h.act(() => { id = insertSection(h.query, h.actions, hero); });
+ expect(rootKids()).toEqual([a, id, b]);
+ expect(h.query.getEvent('selected').all()).toEqual([id]);
+ });
+
+ test('a selection nested inside a section resolves to its top-level ancestor', () => {
+ h = renderEditorHarness();
+ const a = addBlank();
+ const b = addBlank();
+ const nested = Object.keys(h.query.getNodes()).find((k) => nameOf(k) === 'Heading' && h.query.node(k).ancestors().includes(a))!;
+ h.act(() => { h.actions.selectNode(nested); });
+ let id = '';
+ h.act(() => { id = insertSection(h.query, h.actions, hero); });
+ expect(rootKids()).toEqual([a, id, b]);
+ });
+
+ test('appends when nothing is selected', () => {
+ h = renderEditorHarness();
+ const a = addBlank();
+ let id = '';
+ h.act(() => { id = insertSection(h.query, h.actions, hero); });
+ expect(rootKids()).toEqual([a, id]);
+ });
+
+ test('seed hero is token-based: no seed hex in the tree or the exported body', () => {
+ expect(JSON.stringify(hero.tree)).not.toMatch(/#[0-9a-fA-F]{3,8}\b/);
+ h = renderEditorHarness();
+ let id = '';
+ h.act(() => { id = insertSection(h.query, h.actions, hero); });
+ const html = exportBodyHtml(h.getSerialized()).html;
+ expect(html).toContain('Welcome to Your Business');
+ expect(html).toContain('var(--wsb-bg)');
+ expect(html).not.toContain(DEFAULT_SITE_DESIGN.primaryColor);
+ expect(nameOf(id)).toBe('Section');
+ });
+});
+
+describe('nodeTreeToSerializedTree round trip', () => {
+ test('saved section re-inserts with the same displayName sequence and no node_id', () => {
+ h = renderEditorHarness();
+ let id = '';
+ h.act(() => { id = insertSection(h.query, h.actions, getSections().find((s) => s.id === 'services-grid')!); });
+ const names = (root: string) => [root, ...h.query.node(root).decendants(true)].map(nameOf);
+ const before = names(id);
+ const ser = nodeTreeToSerializedTree(h.query, id);
+ expect(JSON.stringify(ser)).not.toContain('node_id');
+ const custom: CustomSection = { id: 'c1', name: 'Mine', family: 'custom', tree: ser, createdAt: 1 };
+ const def = getSections([custom]).find((s) => s.id === 'c1')!;
+ let id2 = '';
+ h.act(() => { id2 = insertSection(h.query, h.actions, def); });
+ expect(id2).not.toBe(id);
+ expect(names(id2)).toEqual(before);
+ expect(before).toContain('Features Grid');
+ });
+});
+
+describe('search / families', () => {
+ test('family filter and keyword search', () => {
+ expect(searchSections('', 'hero').every((s) => s.family === 'hero')).toBe(true);
+ expect(searchSections('', 'hero').length).toBeGreaterThan(0);
+ expect(searchSections('banner').map((s) => s.id)).toContain('hero-centered');
+ expect(searchSections('banner', 'services')).toEqual([]);
+ expect(searchSections('zzzz-nothing')).toEqual([]);
+ });
+ test('unique ids and known families', () => {
+ const all = getSections();
+ expect(new Set(all.map((s) => s.id)).size).toBe(all.length);
+ const fam = new Set(SECTION_FAMILIES.map((f) => f.id));
+ for (const s of all) expect(fam.has(s.family)).toBe(true);
+ });
+});
+
+describe('every built-in section (validates content tasks after merge)', () => {
+ for (const def of getSections()) {
+ test(`${def.id} flattens, exports and previews`, () => {
+ expect(() => flattenTreeForCraft(templateComponentToTreeNode(def.tree))).not.toThrow();
+ const doc = buildSectionPreviewDoc(def, { ...DEFAULT_SITE_DESIGN, primaryColor: '#123456' });
+ expect(doc).toContain('--wsb-primary:#123456');
+ expect(doc).not.toContain('Export failed');
+ h = renderEditorHarness();
+ h.act(() => { insertSection(h.query, h.actions, def); });
+ const out = exportBodyHtml(h.getSerialized()).html;
+ expect(out.length).toBeGreaterThan(0);
+ expect(out).not.toContain('Export failed');
+ h.unmount();
+ });
+ }
+});
+
+describe('buildSectionPreviewDoc', () => {
+ test('carries the design tokens and the section heading text', () => {
+ const design = { ...DEFAULT_SITE_DESIGN, primaryColor: '#abcdef' };
+ const doc = buildSectionPreviewDoc(hero, design);
+ expect(doc).toContain('--wsb-primary:#abcdef');
+ expect(doc).toContain('Welcome to Your Business');
+ expect(doc).not.toContain('