From 21c2776c882726283f23ddfbb849dc63f788f38d Mon Sep 17 00:00:00 2001 From: Josh Knapp Date: Wed, 7 Oct 2026 11:14:51 -0700 Subject: [PATCH] feat(site-builder): block registry, notices, local prefs, project extras, overlay slot Add an exported block registry (keywords, search, insertBlock) consumed by BlocksPanel, a NoticeProvider/NoticeHost toast stack, a guarded localStorage prefs helper, a ProjectExtras store persisted as project.json 'extras', and a CanvasOverlays mount point. Refs #38 Refs #41 Refs #49 Refs #48 Refs #57 Refs #58 Co-Authored-By: Claude Sonnet 5.5 --- craft/src/App.tsx | 8 + craft/src/editor/CanvasOverlays.tsx | 13 + craft/src/editor/NoticeHost.test.tsx | 77 ++++++ craft/src/editor/NoticeHost.tsx | 33 +++ craft/src/hooks/useWhpApi.load.test.tsx | 29 ++- craft/src/hooks/useWhpApi.save.test.ts | 11 + craft/src/hooks/useWhpApi.ts | 15 +- craft/src/panels/left/BlocksPanel.tsx | 219 +--------------- .../src/panels/left/blocks-registry.test.tsx | 95 +++++++ craft/src/panels/left/blocks-registry.tsx | 244 ++++++++++++++++++ craft/src/state/NoticeContext.tsx | 72 ++++++ craft/src/state/ProjectExtrasContext.test.tsx | 124 +++++++++ craft/src/state/ProjectExtrasContext.tsx | 53 ++++ craft/src/styles/editor.css | 42 +++ craft/src/utils/local-prefs.test.ts | 24 ++ craft/src/utils/local-prefs.ts | 25 ++ 16 files changed, 869 insertions(+), 215 deletions(-) create mode 100644 craft/src/editor/CanvasOverlays.tsx create mode 100644 craft/src/editor/NoticeHost.test.tsx create mode 100644 craft/src/editor/NoticeHost.tsx create mode 100644 craft/src/panels/left/blocks-registry.test.tsx create mode 100644 craft/src/panels/left/blocks-registry.tsx create mode 100644 craft/src/state/NoticeContext.tsx create mode 100644 craft/src/state/ProjectExtrasContext.test.tsx create mode 100644 craft/src/state/ProjectExtrasContext.tsx create mode 100644 craft/src/utils/local-prefs.test.ts create mode 100644 craft/src/utils/local-prefs.ts diff --git a/craft/src/App.tsx b/craft/src/App.tsx index d300f58..c3ee737 100644 --- a/craft/src/App.tsx +++ b/craft/src/App.tsx @@ -7,6 +7,9 @@ import { WhpConfig } from './types'; import { EditorConfigProvider } from './state/EditorConfigContext'; import { SiteDesignProvider } from './state/SiteDesignContext'; import { SaveGuardProvider, useSaveGuard } from './state/SaveGuardContext'; +import { NoticeProvider } from './state/NoticeContext'; +import { ProjectExtrasProvider } from './state/ProjectExtrasContext'; +import { CanvasOverlays } from './editor/CanvasOverlays'; import { PageProvider } from './state/PageContext'; import { SitesmithProvider, useSitesmithModal } from './state/SitesmithContext'; import { SitesmithModal } from './panels/sitesmith/SitesmithModal'; @@ -36,16 +39,21 @@ export const App: React.FC = ({ whpConfig }) => { return ( + + + + + ); diff --git a/craft/src/editor/CanvasOverlays.tsx b/craft/src/editor/CanvasOverlays.tsx new file mode 100644 index 0000000..eb99ed7 --- /dev/null +++ b/craft/src/editor/CanvasOverlays.tsx @@ -0,0 +1,13 @@ +import React from 'react'; +import { NoticeHost } from './NoticeHost'; + +/** + * Single mount point for editor-wide overlays rendered next to the shell. + * Wave-1 overlays (drop indicator, empty-container add, spacing handles) + * mount here, one line each. + */ +export const CanvasOverlays: React.FC = () => ( + <> + + +); diff --git a/craft/src/editor/NoticeHost.test.tsx b/craft/src/editor/NoticeHost.test.tsx new file mode 100644 index 0000000..c045685 --- /dev/null +++ b/craft/src/editor/NoticeHost.test.tsx @@ -0,0 +1,77 @@ +import { describe, test, expect, vi, afterEach, beforeEach } from 'vitest'; +import React from 'react'; +import { createRoot, Root } from 'react-dom/client'; +import { act } from 'react-dom/test-utils'; +import { NoticeProvider, useNotices } from '../state/NoticeContext'; +import { NoticeHost } from './NoticeHost'; + +let container: HTMLDivElement; +let root: Root; +let api: ReturnType; + +const Probe: React.FC = () => { api = useNotices(); return null; }; + +beforeEach(() => { + vi.useFakeTimers(); + container = document.createElement('div'); + document.body.appendChild(container); + act(() => { + root = createRoot(container); + root.render(); + }); +}); +afterEach(() => { + act(() => root.unmount()); + container.remove(); + vi.useRealTimers(); +}); + +describe('NoticeHost', () => { + test('success renders role=status and disappears after timeoutMs', () => { + act(() => { api.notify({ kind: 'success', message: 'Saved', timeoutMs: 1000 }); }); + const el = container.querySelector('[role="status"]'); + expect(el?.textContent).toContain('Saved'); + act(() => { vi.advanceTimersByTime(999); }); + expect(container.textContent).toContain('Saved'); + act(() => { vi.advanceTimersByTime(2); }); + expect(container.textContent).not.toContain('Saved'); + }); + + test('default timeout is 4000ms for info', () => { + act(() => { api.notify({ kind: 'info', message: 'FYI' }); }); + act(() => { vi.advanceTimersByTime(3900); }); + expect(container.textContent).toContain('FYI'); + act(() => { vi.advanceTimersByTime(200); }); + expect(container.textContent).not.toContain('FYI'); + }); + + test('error is role=alert and sticky until dismissed', () => { + let id = ''; + act(() => { id = api.notify({ kind: 'error', message: 'Boom' }); }); + expect(container.querySelector('[role="alert"]')?.textContent).toContain('Boom'); + act(() => { vi.advanceTimersByTime(60000); }); + expect(container.textContent).toContain('Boom'); + act(() => { (container.querySelector('.notice-dismiss') as HTMLButtonElement).click(); }); + expect(container.textContent).not.toContain('Boom'); + expect(id).toBeTruthy(); + }); + + test('action button invokes onAction', () => { + const onAction = vi.fn(); + act(() => { api.notify({ kind: 'info', message: 'Undo?', actionLabel: 'Undo', onAction, timeoutMs: 0 }); }); + act(() => { (container.querySelector('.notice-action') as HTMLButtonElement).click(); }); + expect(onAction).toHaveBeenCalledTimes(1); + }); + + test('without a provider notify is a console-only no-op', () => { + const spy = vi.spyOn(console, 'info').mockImplementation(() => {}); + let r: Root; const c = document.createElement('div'); + let a!: ReturnType; + const P: React.FC = () => { a = useNotices(); return null; }; + act(() => { r = createRoot(c); r.render(

); }); + expect(() => a.notify({ kind: 'info', message: 'x' })).not.toThrow(); + expect(spy).toHaveBeenCalled(); + spy.mockRestore(); + act(() => r.unmount()); + }); +}); diff --git a/craft/src/editor/NoticeHost.tsx b/craft/src/editor/NoticeHost.tsx new file mode 100644 index 0000000..52a548f --- /dev/null +++ b/craft/src/editor/NoticeHost.tsx @@ -0,0 +1,33 @@ +import React from 'react'; +import { useNotices } from '../state/NoticeContext'; + +/** Renders the active notice stack (bottom-left; above the bars on mobile). */ +export const NoticeHost: React.FC = () => { + const { notices, dismiss } = useNotices(); + if (notices.length === 0) return null; + return ( +

+ {notices.map((n) => ( +
+ {n.message} + {n.actionLabel && ( + + )} +