feat(site-builder): reusable global sections with per-page overrides

Make a top-level section global (context menu), place instances from the
Section library's new Global tab, edit the master once in its own zone
("Editing global section" banner, Done - update N instances) and every
instance on every page/header/footer is rebuilt from it. Instances are real
inlined subtrees (custom.global on the root, custom.gkey on every node), so
export and published output are unchanged static HTML.

- utils/global-sections.ts: pure model (createMaster, instantiate,
  syncInstances, collectOverridesFromInstance, applyOverrides, detach,
  findInstanceRoot, master zone round trip). Unchanged input returns the
  identical string; sync keeps the instance root id and the instance's own
  inner ids per gkey.
- PageContext: __global__:<id> zone (editGlobal / finishGlobal); leaving it
  commits the master (rev + 1 only when it changed), syncs every stored
  slot and applies the sync to the returned-to canvas as ONE undo step.
- GlobalSectionsContext: makeGlobal / insertInstance / detach / setOverride
  / resetOverride / remove (refused while used), one undo step each.
- Inline text edits inside an instance record an override (text + html)
  via useGlobalOverrideCommit; GuidedStyles shows a Global section panel
  (Edit master, Detach, content override fields) instead of style panels.
- RenderNode: Global badge, dashed outline, lock icon on inner nodes.
- buildSavePayload: a master zone on the canvas is never saved into a page.

Refs #57

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
shadowdaoandClaude Opus 5.5 committed 2026-10-07 12:28:53 -07:00
1 parent 48061d37ae
commit dd8b99be06
15 files changed
+1898 -29

No files matched your search

@@ -10,6 +10,10 @@ import {
insertSection, isTopLevelSection, nodeTreeToSerializedTree, searchSections, useSections,
} from '../../sections/catalog';
import { buildSectionPreviewDoc } from '../../sections/thumbnails';
import { exportToHtml } from '../../utils/html-export';
import { masterZoneState, type GlobalMaster } from '../../utils/global-sections';
import { useGlobalSections } from '../../state/GlobalSectionsContext';
import { usePages } from '../../state/PageContext';
// ---- open state: module-level so the command, the empty-state button and the
// Blocks panel button can open the modal without a provider ----
@@ -28,7 +32,7 @@ export function useSectionLibraryState(): LibraryState {
}
/** 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 LazyPreview: React.FC<{ def?: SectionDef; master?: GlobalMaster; title: string }> = ({ def, master, title }) => {
const { design } = useSiteDesign();
const ref = useRef<HTMLDivElement>(null);
const [seen, setSeen] = useState(typeof IntersectionObserver === 'undefined');
@@ -42,8 +46,11 @@ const LazyPreview: React.FC<{ def: SectionDef; title: string }> = ({ def, title
}, [seen]);
const doc = useMemo(() => {
if (!seen) return '';
try { return buildSectionPreviewDoc(def, design); } catch { return ''; }
}, [seen, def, design]);
try {
if (master) return exportToHtml(masterZoneState(master), { design, includeFonts: true }).html;
return def ? buildSectionPreviewDoc(def, design) : '';
} catch { return ''; }
}, [seen, def, master, design]);
return (
<div className="section-lib-thumb" ref={ref}>
{doc && (
@@ -68,6 +75,10 @@ export const SectionLibraryModal: React.FC = () => {
const [version, setVersion] = useState(0);
const sections = useSections(version, open);
const [family, setFamily] = useState<SectionFamily | null>(null);
// #57: the Global tab lists the site's global sections (instances, not copies).
const [globalTab, setGlobalTab] = useState(false);
const globals = useGlobalSections();
const { isEditingGlobal } = usePages();
const [q, setQ] = useState('');
const [saveName, setSaveName] = useState('');
@@ -91,7 +102,29 @@ export const SectionLibraryModal: React.FC = () => {
},
], []);
useEffect(() => { if (open) { setQ(''); setFamily(null); setSaveName(''); } }, [open]);
useEffect(() => { if (open) { setQ(''); setFamily(null); setGlobalTab(false); setSaveName(''); } }, [open]);
const usage = useMemo(() => (open && globalTab ? globals.usage() : {}), [open, globalTab, globals]);
const globalEntries = useMemo(() => {
const words = q.toLowerCase().split(/\s+/).filter(Boolean);
return Object.entries(globals.library)
.filter(([, m]) => words.every((w) => m.name.toLowerCase().includes(w)))
.sort((a, b) => a[1].name.localeCompare(b[1].name));
}, [globals.library, q]);
const onPickGlobal = (libraryId: string, name: string) => {
const id = globals.insertInstance(libraryId, selectedId ?? null);
if (!id) {
notify({ kind: 'error', message: isEditingGlobal ? 'A global section cannot be placed inside another one.' : 'Could not add that section.' });
return;
}
closeSectionLibrary();
notify({ kind: 'success', message: `Added global section "${name}"` });
};
const onDeleteGlobal = (libraryId: string, name: string) => {
if (!globals.remove(libraryId)) {
notify({ kind: 'info', message: `"${name}" is still used on the site. Delete or detach its copies first.` });
}
};
const results = useMemo(() => searchSections(q, family, sections), [q, family, sections]);
@@ -181,18 +214,48 @@ export const SectionLibraryModal: React.FC = () => {
</form>
)}
<div className="section-lib-chips" role="tablist" aria-label="Section families">
<button type="button" role="tab" aria-selected={family === null} className={`section-lib-chip${family === null ? ' active' : ''}`} onClick={() => setFamily(null)}>All</button>
<button type="button" role="tab" aria-selected={!globalTab && family === null} className={`section-lib-chip${!globalTab && family === null ? ' active' : ''}`} onClick={() => { setGlobalTab(false); setFamily(null); }}>All</button>
{SECTION_FAMILIES.map((f) => (
<button
key={f.id}
type="button"
role="tab"
aria-selected={family === f.id}
className={`section-lib-chip${family === f.id ? ' active' : ''}`}
onClick={() => setFamily(f.id)}
aria-selected={!globalTab && family === f.id}
className={`section-lib-chip${!globalTab && family === f.id ? ' active' : ''}`}
onClick={() => { setGlobalTab(false); setFamily(f.id); }}
>{f.label}</button>
))}
<button
type="button"
role="tab"
aria-selected={globalTab}
className={`section-lib-chip section-lib-chip-global${globalTab ? ' active' : ''}`}
onClick={() => setGlobalTab(true)}
><i className="fa fa-globe" aria-hidden /> Global</button>
</div>
{globalTab ? (
<div className="section-lib-grid">
{globalEntries.map(([id, m]) => (
<div key={id} className="section-lib-card" data-global-id={id}>
<button type="button" className="section-lib-pick" onClick={() => onPickGlobal(id, m.name)} aria-label={`Add ${m.name}`}>
<LazyPreview master={m} title={`${m.name} preview`} />
<span className="section-lib-name">{m.name}</span>
<span className="section-lib-usage" data-testid="global-usage">
Used {usage[id] ?? 0} {(usage[id] ?? 0) === 1 ? 'time' : 'times'}
</span>
</button>
<button type="button" className="section-lib-delete" aria-label={`Delete ${m.name}`} onClick={() => onDeleteGlobal(id, m.name)}>
<i className="fa fa-trash" aria-hidden />
</button>
</div>
))}
{globalEntries.length === 0 && (
<div className="section-lib-empty">
No global sections yet. Right-click a section on the canvas and choose "Make global section".
</div>
)}
</div>
) : (
<div className="section-lib-grid">
{results.map((def) => (
<div key={def.id} className="section-lib-card" data-section-id={def.id}>
@@ -213,6 +276,7 @@ export const SectionLibraryModal: React.FC = () => {
</div>
)}
</div>
)}
</div>
</Modal>
);