Merge integration-followups

This commit is contained in:
shadowdao committed 2026-10-07 13:01:14 -07:00
commit c35a73bb12
35 files changed
+1272 -107

No files matched your search

+37 -1
View File
@@ -1,7 +1,7 @@
import { describe, test, expect, afterEach } from 'vitest';
import React from 'react';
import { renderEditorHarness, EditorHarness } from '../test-utils/editorHarness';
import { EmptyContainerAdd } from './EmptyContainerAdd';
import { EmptyContainerAdd, clampPopoverPosition } from './EmptyContainerAdd';
import { insertBlock } from '../panels/left/blocks-registry';
let h: EditorHarness;
@@ -86,3 +86,39 @@ describe('EmptyContainerAdd', () => {
expect(plus()).toBeNull();
});
});
describe('EmptyContainerAdd popover viewport clamp (#41 follow-up)', () => {
const vp = { width: 1000, height: 800 };
const size = { width: 240, height: 96 };
test('centred below the "+" when it fits', () => {
expect(clampPopoverPosition({ x: 500, y: 300 }, size, vp)).toEqual({ left: 380, top: 322 });
});
test('clamped at the left and right edges', () => {
expect(clampPopoverPosition({ x: 20, y: 300 }, size, vp).left).toBe(8);
expect(clampPopoverPosition({ x: 990, y: 300 }, size, vp).left).toBe(1000 - 240 - 8);
});
test('flips above the "+" near the bottom edge', () => {
expect(clampPopoverPosition({ x: 500, y: 760 }, size, vp).top).toBe(760 - 22 - 96);
});
test('never above the top edge on a tiny viewport', () => {
const p = clampPopoverPosition({ x: 50, y: 40 }, size, { width: 200, height: 100 });
expect(p.left).toBe(8);
expect(p.top).toBe(8);
});
test('the rendered popover uses the clamped position (near the right edge)', () => {
seed();
const { innerId } = (() => {
let secondId = '';
h.act(() => { secondId = insertBlock(h.query, h.actions, 'section', { parentId: 'ROOT', select: false })!; });
return { innerId: Object.values(h.query.node(secondId).get().data.linkedNodes as Record<string, string>)[0] };
})();
const dom = h.query.node(innerId).get().dom as HTMLElement;
const right = window.innerWidth - 4;
dom.getBoundingClientRect = () => ({ left: right - 20, top: 10, width: 20, height: 20, right, bottom: 30, x: right - 20, y: 10, toJSON() {} }) as DOMRect;
h.act(() => { h.actions.selectNode(innerId); });
h.act(() => { plus()!.click(); });
const pop = document.querySelector('.empty-container-add-pop') as HTMLElement;
expect(parseFloat(pop.style.left)).toBe(window.innerWidth - 240 - 8);
expect(parseFloat(pop.style.left) + 240).toBeLessThanOrEqual(window.innerWidth);
});
});
+39 -1
View File
@@ -6,6 +6,31 @@ import { BLOCKS, insertBlock } from '../panels/left/blocks-registry';
const QUICK_BLOCK_IDS = ['heading', 'text', 'button', 'image', 'columns-2', 'spacer'];
const QUICK_LABELS: Record<string, string> = { 'columns-2': 'Columns' };
/** Fallback popover size before it has been measured (3x2 grid). */
const POP_FALLBACK = { width: 240, height: 96 };
const POP_MARGIN = 8;
const POP_GAP = 22;
/**
* Where the block popover goes for a "+" centred at `anchor`: centred below
* it, flipped above when it would run off the bottom, and clamped so it stays
* inside the viewport (#41 follow-up).
*/
export function clampPopoverPosition(
anchor: { x: number; y: number },
size: { width: number; height: number },
viewport: { width: number; height: number },
): { left: number; top: number } {
const maxLeft = Math.max(POP_MARGIN, viewport.width - size.width - POP_MARGIN);
const left = Math.min(Math.max(anchor.x - size.width / 2, POP_MARGIN), maxLeft);
let top = anchor.y + POP_GAP;
if (top + size.height > viewport.height - POP_MARGIN) {
const above = anchor.y - POP_GAP - size.height;
top = above >= POP_MARGIN ? above : viewport.height - size.height - POP_MARGIN;
}
return { left, top: Math.max(POP_MARGIN, top) };
}
/**
* Inline "+" for an empty container/section/column: shown for the hovered or
* selected canvas node that has zero children (never ROOT). Click opens a
@@ -32,6 +57,8 @@ export const EmptyContainerAdd: React.FC = () => {
const [keepId, setKeepId] = useState<string | null>(null);
const [open, setOpen] = useState(false);
const rootRef = useRef<HTMLDivElement>(null);
const popRef = useRef<HTMLDivElement>(null);
const [popSize, setPopSize] = useState(POP_FALLBACK);
const stillEmpty = (id: string | null): id is string => {
if (!id) return false;
@@ -79,6 +106,15 @@ export const EmptyContainerAdd: React.FC = () => {
};
}, [open, close]);
// Measure the open popover so the clamp uses its real size.
useLayoutEffect(() => {
if (!open || !popRef.current) return;
const r = popRef.current.getBoundingClientRect();
if (r.width > 0 && r.height > 0 && (r.width !== popSize.width || r.height !== popSize.height)) {
setPopSize({ width: r.width, height: r.height });
}
}, [open, pos, popSize]);
// Target vanished (filled, dragged, deselected): drop popover state.
useEffect(() => { if (!nodeId && open) setOpen(false); }, [nodeId, open]);
@@ -89,6 +125,8 @@ export const EmptyContainerAdd: React.FC = () => {
close();
};
const popPos = clampPopoverPosition(pos, popSize, { width: window.innerWidth, height: window.innerHeight });
const blocks = QUICK_BLOCK_IDS
.map((id) => BLOCKS.find((b) => b.id === id))
.filter((b): b is NonNullable<typeof b> => !!b);
@@ -108,7 +146,7 @@ export const EmptyContainerAdd: React.FC = () => {
<i className="fa fa-plus" aria-hidden />
</button>
{open && (
<div className="empty-container-add-pop" role="menu" style={{ left: pos.x - 120, top: pos.y + 22 }}>
<div ref={popRef} className="empty-container-add-pop" role="menu" style={{ left: popPos.left, top: popPos.top }}>
{blocks.map((b) => (
<button
key={b.id}
+36 -1
View File
@@ -1,4 +1,4 @@
import { useCallback, useEffect, useMemo, useRef } from 'react';
import { useCallback, useEffect, useMemo, useReducer, useRef } from 'react';
import { useEditor } from '@craftjs/core';
import type { DeviceMode } from '../types';
import { useDevice } from '../state/DeviceContext';
@@ -171,6 +171,22 @@ function focusOutsideCanvas(el: Element | null): boolean {
return !!el.closest('button, a[href], summary, [role="button"], [role="menuitem"], [role="tab"]');
}
/** Anything that is a dialog or modal in the editor chrome. Matches inside the
* canvas frame are page content (an HtmlBlock, a Gallery lightbox), not UI. */
const DIALOG_SELECTOR = '[role="dialog"], [role="alertdialog"], [aria-modal="true"], dialog[open], .modal-backdrop';
const isChromeDialog = (d: Element) => !d.closest('.canvas-device-frame');
/** A dialog/modal is open (modal or not), or focus is inside one. */
export function editorDialogOpen(focused: Element | null = document.activeElement): boolean {
const inside = focused && focused !== document.body ? focused.closest(DIALOG_SELECTOR) : null;
if (inside && isChromeDialog(inside)) return true;
return Array.from(document.querySelectorAll(DIALOG_SELECTOR)).some(isChromeDialog);
}
/** Single printable key with no modifier ('1', '2', '3'): these belong to the
* canvas only, never to an open dialog (#39 follow-up). */
const isBareCharShortcut = (spec: string) => spec.length === 1;
function shortcutsOf(cmd: Command): string[] {
return cmd.shortcut ? [cmd.shortcut, ...(cmd.shortcuts ?? [])] : [];
}
@@ -193,6 +209,7 @@ export function useCommandShortcuts(): void {
const hit = shortcutsOf(cmd).find((spec) => matchShortcut(e, spec));
if (!hit) continue;
if ((hit === 'tab' || hit === 'shift+tab' || hit === 'escape') && focusOutsideCanvas(document.activeElement)) continue;
if (isBareCharShortcut(hit) && editorDialogOpen(document.activeElement)) continue;
if (typing && !cmd.allowWhileTyping) continue;
if (!isEnabled(cmd, c)) continue;
e.preventDefault();
@@ -213,3 +230,21 @@ export function useCommandShortcuts(): void {
export function useRegisterCommands(cmds: Command[], deps: unknown[]): void {
useEffect(() => registerCommands(cmds), deps); // eslint-disable-line react-hooks/exhaustive-deps
}
/**
* One registry command as a UI affordance (context-menu entry, header button):
* whether it is registered at all, whether its `when()` passes for the live
* selection, and a runner. Re-renders when the registry or the selection changes.
*/
export function useCommandState(id: string): { registered: boolean; enabled: boolean; run: () => Promise<boolean> } {
const ctx = useCommandContext();
const runCommand = useRunCommand();
const [, bump] = useReducer((n: number) => n + 1, 0);
useEffect(() => subscribeCommands(bump), []);
const cmd = getCommand(id);
return {
registered: !!cmd,
enabled: !!cmd && isEnabled(cmd, liveContext(ctx)),
run: () => runCommand(id),
};
}
@@ -134,6 +134,51 @@ describe('device keys 1/2/3', () => {
key('3', mod);
expect(device).toBe('desktop');
});
// #39 follow-up: bare keys never fire while any dialog/modal is open.
test.each([
['an aria-modal dialog', { role: 'dialog', 'aria-modal': 'true' }],
['a non-modal role=dialog (e.g. a popover)', { role: 'dialog' }],
['an alertdialog', { role: 'alertdialog' }],
['a Modal backdrop without a role', { class: 'modal-backdrop' }],
])('are ignored while %s is open, even with focus on the body', (_label, attrs) => {
const d = document.createElement('div');
for (const [k, v] of Object.entries(attrs)) d.setAttribute(k, v);
document.body.appendChild(d);
try {
key('3');
expect(device).toBe('desktop');
} finally { d.remove(); }
key('3');
expect(device).toBe('mobile');
});
test('are ignored while focus is inside a dialog', () => {
const d = document.createElement('div');
d.setAttribute('role', 'dialog');
const btn = document.createElement('button');
d.appendChild(btn);
document.body.appendChild(d);
btn.focus();
try {
key('2');
expect(device).toBe('desktop');
} finally { d.remove(); }
});
test('a role=dialog inside the canvas frame (page content) does not block them', () => {
const frame = document.createElement('div');
frame.className = 'canvas-device-frame';
const d = document.createElement('div');
d.setAttribute('role', 'dialog');
d.setAttribute('aria-modal', 'true');
frame.appendChild(d);
document.body.appendChild(frame);
try {
key('2');
expect(device).toBe('tablet');
} finally { frame.remove(); }
});
});
describe('site commands (mod+s)', () => {
+35
View File
@@ -89,6 +89,41 @@ describe('useStockPhotos.search', () => {
});
});
describe('useStockPhotos: stale responses and 404 scope (#55 review)', () => {
test('a cache-hit search aborts an older in-flight request and clears loading', async () => {
fetchMock.mockReturnValueOnce(json({ success: true, results: [PHOTO], next_page: null }));
await act(async () => { await api.search('two'); });
const signals: AbortSignal[] = [];
fetchMock.mockImplementation((_u: string, init: any) => {
signals.push(init.signal);
return new Promise(() => { /* never resolves */ });
});
act(() => { void api.search('one'); });
expect(api.loading).toBe(true);
let hit: any;
await act(async () => { hit = await api.search('two'); });
expect(hit.results).toEqual([PHOTO]);
expect(signals).toHaveLength(1);
expect(signals[0].aborted).toBe(true);
expect(api.loading).toBe(false);
});
test('a 404 on stock_import is an ordinary error, not "stock unavailable"', async () => {
fetchMock.mockReturnValue(json({ success: false, error: 'Photo not found' }, 404));
let res: any = 'x';
await act(async () => { res = await api.importPhoto(999); });
expect(res).toBeNull();
expect(api.availability).toBe('unknown');
expect(api.error).toBe('Photo not found');
});
test('an explicit invalid-action error on stock_import still disables the tab', async () => {
fetchMock.mockReturnValue(json({ success: false, error: 'Invalid action. Supported: get_sites, list' }, 400));
await act(async () => { await api.importPhoto(7); });
expect(api.availability).toBe('disabled');
});
});
describe('useStockPhotos.importPhoto', () => {
test('POSTs provider/id/size with the CSRF header and resolves the asset url', async () => {
fetchMock.mockReturnValue(json({ success: true, url: '/api/assets-proxy/?site_id=5&filename=fox.jpg', name: 'fox.jpg', type: 'image/jpeg', credit: PHOTO.credit }));
+15 -6
View File
@@ -55,9 +55,12 @@ function getCfg(): { apiUrl: string; siteId: number; csrfToken: string } | null
}
/** The backend answers `stock_disabled` with no key; an old backend without
* the action answers 404 or an "unknown action" error -- both hide the tab. */
function isDisabledSignal(status: number, data: any): boolean {
if (status === 404) return true;
* the action answers an "unknown/invalid action" error -- both hide the tab.
* A bare 404 only counts for the `stock_search` availability probe
* (`probe`): on stock_import a 404 is an ordinary failure (e.g. a photo id
* that no longer exists) and must not hide the tab. */
function isDisabledSignal(status: number, data: any, probe: boolean): boolean {
if (probe && status === 404) return true;
const err = String(data?.error || '');
return err === 'stock_disabled' || /unknown action|invalid action/i.test(err);
}
@@ -85,7 +88,13 @@ export function useStockPhotos() {
if (!cfg || !query) return null;
const key = `${query}\u0000${page}`;
const hit = cache.current.get(key);
if (hit) { setError(null); return hit; }
if (hit) {
// A cache hit is the newest request: drop any older in-flight search so
// its (stale) response can never land after this one.
if (abortRef.current) { abortRef.current.abort(); abortRef.current = null; setLoading(false); }
setError(null);
return hit;
}
abortRef.current?.abort();
const ctrl = new AbortController();
@@ -97,7 +106,7 @@ export function useStockPhotos() {
const resp = await fetch(url, { headers: { 'X-CSRF-Token': cfg.csrfToken }, signal: ctrl.signal });
let data: any = null;
try { data = await resp.json(); } catch { /* non-JSON body */ }
if (isDisabledSignal(resp.status, data)) { setSharedAvailability('disabled'); return null; }
if (isDisabledSignal(resp.status, data, true)) { setSharedAvailability('disabled'); return null; }
if (!resp.ok || !data?.success || !Array.isArray(data.results)) {
throw new Error(data?.error || `Search failed: ${resp.status}`);
}
@@ -130,7 +139,7 @@ export function useStockPhotos() {
});
let data: any = null;
try { data = await resp.json(); } catch { /* non-JSON body */ }
if (isDisabledSignal(resp.status, data)) { setSharedAvailability('disabled'); return null; }
if (isDisabledSignal(resp.status, data, false)) { setSharedAvailability('disabled'); return null; }
if (!resp.ok || !data?.success || !data.url) throw new Error(data?.error || `Import failed: ${resp.status}`);
return { url: data.url, name: data.name || '', type: data.type || 'image', credit: data.credit };
} catch (e) {
+167
View File
@@ -0,0 +1,167 @@
import { describe, test, expect, vi, beforeEach, afterEach } from 'vitest';
import React from 'react';
import { createRoot, Root } from 'react-dom/client';
import { act } from 'react-dom/test-utils';
/**
* Integration follow-ups #4/#47 (items 11 + 12):
* - a save / publish / load request that never completes is abandoned after
* API_TIMEOUT_MS with a readable error, so it cannot block the save queue;
* - a failed response whose JSON only has `error` (PHP's readable text)
* shows that text instead of "server returned HTTP 503".
*/
vi.mock('@craftjs/core', () => ({
useEditor: (collector?: (state: unknown, query: unknown) => Record<string, unknown>) => {
const query = { serialize: () => '{}', history: { canUndo: () => false, canRedo: () => false } };
const actions = { history: { undo: vi.fn(), redo: vi.fn(), clear: vi.fn() }, deserialize: vi.fn() };
return { actions, query, ...(collector ? collector({}, query) : {}) };
},
}));
import { LoadGate } from '../editor/LoadGate';
import { EditorConfigProvider } from '../state/EditorConfigContext';
import { SaveGuardProvider, useSaveGuard } from '../state/SaveGuardContext';
import { PageProvider } from '../state/PageContext';
import { SiteDesignProvider } from '../state/SiteDesignContext';
import { useWhpApi, API_TIMEOUT_MS } from './useWhpApi';
import { WhpConfig } from '../types';
const whpConfig: WhpConfig = {
user: 'u', apiUrl: '/api', csrfToken: 't', siteId: 1,
siteDomain: 'example.com', siteName: 'Example', backUrl: '/', isRoot: false,
};
const jsonResp = (body: unknown, status = 200) => ({ ok: status >= 200 && status < 300, status, json: async () => body });
const never = () => new Promise(() => { /* the server never answers */ });
let container: HTMLDivElement;
let root: Root;
let fetchMock: ReturnType<typeof vi.fn>;
let api: ReturnType<typeof useWhpApi>;
let guard: ReturnType<typeof useSaveGuard>;
const Probe: React.FC = () => { api = useWhpApi(); guard = useSaveGuard(); return null; };
const flush = () => act(async () => { for (let i = 0; i < 10; i++) await Promise.resolve(); });
const calls = (action: string) => fetchMock.mock.calls.filter(([u]) => String(u).includes(`action=${action}`));
async function mount(withLoadGate = true) {
container = document.createElement('div');
document.body.appendChild(container);
act(() => {
root = createRoot(container);
root.render(
<EditorConfigProvider config={whpConfig}>
<SaveGuardProvider><SiteDesignProvider><PageProvider>
{withLoadGate && <LoadGate />}
<Probe />
</PageProvider></SiteDesignProvider></SaveGuardProvider>
</EditorConfigProvider>,
);
});
await flush();
}
beforeEach(() => {
vi.useFakeTimers();
fetchMock = vi.fn();
vi.stubGlobal('fetch', fetchMock);
vi.spyOn(console, 'error').mockImplementation(() => {});
});
afterEach(() => {
act(() => { root.unmount(); });
container.remove();
vi.useRealTimers();
vi.unstubAllGlobals();
vi.restoreAllMocks();
});
describe('request timeout (item 11)', () => {
test('a hung save resolves with a readable timeout error and releases the save queue', async () => {
let saveN = 0;
fetchMock.mockImplementation((url: string) => {
if (String(url).includes('action=load')) return Promise.resolve(jsonResp({ success: true, project: null, rev: 'r1' }));
saveN += 1;
return saveN === 1 ? never() : Promise.resolve(jsonResp({ success: true, rev: 'r2' }));
});
await mount();
act(() => { guard.markDirty(); });
let first: any = 'pending';
let second: any = 'pending';
await act(async () => { void api.save().then((r) => { first = r; }); });
await act(async () => { void api.save().then((r) => { second = r; }); });
await flush();
expect(calls('save')).toHaveLength(1); // the second waits behind the hung one
const signal: AbortSignal = calls('save')[0][1].signal;
expect(signal.aborted).toBe(false);
await act(async () => { vi.advanceTimersByTime(API_TIMEOUT_MS - 1); });
await flush();
expect(first).toBe('pending');
await act(async () => { vi.advanceTimersByTime(1); });
await flush();
expect(signal.aborted).toBe(true);
expect(first).toMatchObject({ success: false, error: 'timeout' });
expect(first.message).toMatch(/Save failed: the server did not respond within 30 seconds/);
// Queue released: the waiting save went out and succeeded.
expect(calls('save')).toHaveLength(2);
expect(second).toMatchObject({ success: true, rev: 'r2' });
});
test('a hung publish times out with a readable error', async () => {
fetchMock.mockImplementation((url: string) => {
const u = String(url);
if (u.includes('action=load')) return Promise.resolve(jsonResp({ success: true, project: null, rev: 'r1' }));
if (u.includes('action=save')) return Promise.resolve(jsonResp({ success: true, rev: 'r2' }));
return never();
});
await mount();
let res: any = 'pending';
await act(async () => { void api.publish().then((r) => { res = r; }); });
await flush();
expect(calls('publish')).toHaveLength(1);
await act(async () => { vi.advanceTimersByTime(API_TIMEOUT_MS); });
await flush();
expect(res).toMatchObject({ success: false, error: 'timeout' });
expect(res.message).toMatch(/^Publish failed: the server did not respond/);
});
test('a hung load rejects with a readable error', async () => {
fetchMock.mockImplementation(() => never());
await mount(false);
let err: any = null;
await act(async () => { void api.load().catch((e) => { err = e; }); });
await act(async () => { vi.advanceTimersByTime(API_TIMEOUT_MS); });
await flush();
expect(err).toBeInstanceOf(Error);
expect(err.message).toMatch(/^Load failed: the server did not respond within 30 seconds/);
});
});
describe('readApiResult error text (item 12)', () => {
test('a 503 whose JSON has only `error` shows that text', async () => {
const text = 'Another save of this site is still in progress. Please try again in a moment.';
fetchMock.mockImplementation((url: string) => {
if (String(url).includes('action=load')) return Promise.resolve(jsonResp({ success: true, project: null, rev: 'r1' }));
return Promise.resolve(jsonResp({ success: false, error: text }, 503));
});
await mount();
let res: any = null;
await act(async () => { res = await api.save(); });
expect(res).toMatchObject({ success: false, message: text });
});
test('`message` still wins when present; neither -> the HTTP status text', async () => {
let body: any = { success: false, error: 'code', message: 'Readable' };
fetchMock.mockImplementation((url: string) => {
if (String(url).includes('action=load')) return Promise.resolve(jsonResp({ success: true, project: null, rev: 'r1' }));
return Promise.resolve(jsonResp(body, 500));
});
await mount();
let res: any = null;
await act(async () => { res = await api.save(); });
expect(res.message).toBe('Readable');
body = { success: false };
await act(async () => { res = await api.save(); });
expect(res.message).toBe('Save failed: server returned HTTP 500');
});
});
+112 -55
View File
@@ -263,6 +263,38 @@ export interface ApiResult {
[key: string]: unknown;
}
/** How long save / publish / load may take before the request is abandoned. */
export const API_TIMEOUT_MS = 30_000;
/** A request that got no complete answer within API_TIMEOUT_MS. */
export class ApiTimeoutError extends Error {}
/**
* Runs `fn` (a fetch plus reading its body) with an AbortSignal that fires
* after `ms`. Racing the timeout as well means a stalled body read can't hang
* the caller either: without this a request that never completes holds the
* save queue forever.
*/
async function withTimeout<T>(action: string, fn: (signal: AbortSignal) => Promise<T>, ms = API_TIMEOUT_MS): Promise<T> {
const ctrl = new AbortController();
let timer: ReturnType<typeof setTimeout> | undefined;
const timeout = new Promise<never>((_, reject) => {
timer = setTimeout(() => {
ctrl.abort();
reject(new ApiTimeoutError(
`${action} failed: the server did not respond within ${Math.round(ms / 1000)} seconds. Check your connection and try again.`,
));
}, ms);
});
try {
return await Promise.race([fn(ctrl.signal), timeout]);
} finally {
clearTimeout(timer);
}
}
const timeoutResult = (e: ApiTimeoutError): ApiResult => ({ success: false, error: 'timeout', message: e.message });
/**
* Reads a save/publish response without ever throwing on the body: a non-2xx
* status or a non-JSON body (a 502 HTML page, a PHP fatal) becomes a
@@ -278,7 +310,12 @@ async function readApiResult(resp: Response, action: 'Save' | 'Publish'): Promis
const obj = body && typeof body === 'object' ? (body as ApiResult) : null;
if (resp.ok && obj) return obj;
if (obj) {
return { ...obj, success: false, message: obj.message || `${action} failed: server returned HTTP ${resp.status}` };
// PHP handlers often put their readable text in `error` only ("Another
// save of this site is still in progress..."): prefer it over the status.
const text = (typeof obj.message === 'string' && obj.message.trim())
|| (typeof obj.error === 'string' && obj.error.trim())
|| `${action} failed: server returned HTTP ${resp.status}`;
return { ...obj, success: false, message: text };
}
return {
success: false,
@@ -357,40 +394,49 @@ export function useWhpApi() {
extras: projectExtras.all(),
});
const resp = await fetch(`${whpConfig.apiUrl}?action=save`, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'X-CSRF-Token': whpConfig.csrfToken,
},
body: JSON.stringify({
...payload,
// Present on every save so the server can detect a newer copy; left
// out only when load never reported a rev (an older backend).
...(baseRev !== undefined ? { base_rev: baseRev } : {}),
...(forceEmpty ? { force_empty: true } : {}),
...(forceOverwrite ? { force_overwrite: true } : {}),
}),
});
let result: ApiResult;
try {
result = await withTimeout('Save', async (signal) => {
const resp = await fetch(`${whpConfig.apiUrl}?action=save`, {
signal,
method: 'POST',
headers: {
'Content-Type': 'application/json',
'X-CSRF-Token': whpConfig.csrfToken,
},
body: JSON.stringify({
...payload,
// Present on every save so the server can detect a newer copy; left
// out only when load never reported a rev (an older backend).
...(baseRev !== undefined ? { base_rev: baseRev } : {}),
...(forceEmpty ? { force_empty: true } : {}),
...(forceOverwrite ? { force_overwrite: true } : {}),
}),
});
// 409 empty_overwrite_refused is an expected outcome the UI handles, so
// return it as a structured result instead of throwing.
if (resp.status === 409) {
const body = await resp.json().catch(() => null);
if (body?.error === 'empty_overwrite_refused') {
return {
success: false,
error: 'empty_overwrite_refused',
message: body.message || 'This would replace your saved site with an empty page.',
};
}
// Opens the conflict modal (here, so the save inside publish raises it too).
const message = body?.message || 'This site was changed in another tab or device since you opened it.';
setConflict(message);
return { ...(body ?? {}), success: false, error: 'conflict', message };
// 409 empty_overwrite_refused is an expected outcome the UI handles, so
// return it as a structured result instead of throwing.
if (resp.status === 409) {
const body = await resp.json().catch(() => null);
if (body?.error === 'empty_overwrite_refused') {
return {
success: false,
error: 'empty_overwrite_refused',
message: body.message || 'This would replace your saved site with an empty page.',
};
}
// Opens the conflict modal (here, so the save inside publish raises it too).
const message = body?.message || 'This site was changed in another tab or device since you opened it.';
setConflict(message);
return { ...(body ?? {}), success: false, error: 'conflict', message };
}
return readApiResult(resp, 'Save');
});
} catch (e) {
if (e instanceof ApiTimeoutError) return timeoutResult(e);
throw e;
}
const result = await readApiResult(resp, 'Save');
// The one-shot force_empty is spent only once a save actually went through.
if (result.success) {
if (forceEmpty) forceEmptyNextSave.current = false;
@@ -466,33 +512,44 @@ export function useWhpApi() {
}
// Then publish from staging to live
const resp = await fetch(
`${whpConfig.apiUrl}?action=publish&site_id=${whpConfig.siteId}`,
{
method: 'POST',
headers: {
'Content-Type': 'application/json',
'X-CSRF-Token': whpConfig.csrfToken,
},
body: JSON.stringify({ site_id: whpConfig.siteId }),
},
);
return readApiResult(resp, 'Publish');
try {
return await withTimeout('Publish', async (signal) => {
const resp = await fetch(
`${whpConfig.apiUrl}?action=publish&site_id=${whpConfig.siteId}`,
{
signal,
method: 'POST',
headers: {
'Content-Type': 'application/json',
'X-CSRF-Token': whpConfig.csrfToken,
},
body: JSON.stringify({ site_id: whpConfig.siteId }),
},
);
return readApiResult(resp, 'Publish');
});
} catch (e) {
if (e instanceof ApiTimeoutError) return timeoutResult(e);
throw e;
}
}, [isWHP, whpConfig, loadState, save]);
const load = useCallback(async () => {
if (!isWHP || !whpConfig) return null;
const resp = await fetch(
`${whpConfig.apiUrl}?action=load&site_id=${whpConfig.siteId}`,
);
if (!resp.ok) throw new Error(`Load failed: HTTP ${resp.status}`);
let data;
try {
data = await resp.json();
} catch {
throw new Error('Load failed: response was not valid JSON');
}
// A timeout rejects with ApiTimeoutError (an Error with a readable message).
const data = await withTimeout('Load', async (signal) => {
const resp = await fetch(
`${whpConfig.apiUrl}?action=load&site_id=${whpConfig.siteId}`,
{ signal },
);
if (!resp.ok) throw new Error(`Load failed: HTTP ${resp.status}`);
try {
return await resp.json();
} catch {
throw new Error('Load failed: response was not valid JSON');
}
});
if (!data || data.success !== true) {
throw new Error(`Load failed: ${data?.message || data?.error || 'server reported failure'}`);
}
@@ -10,6 +10,8 @@ import { SaveGuardProvider } from '../../state/SaveGuardContext';
import { SiteDesignProvider, useSiteDesign, SiteDesignContextValue } from '../../state/SiteDesignContext';
import { CanvasTokenStyle } from '../../editor/CanvasTokenStyle';
import { setStyleClipboard, getStyleClipboard, extractStyle } from '../../utils/style-clipboard';
import { registerCommand } from '../../editor/commands';
import { isTopLevelSection } from '../../sections/catalog';
let h: EditorHarness;
beforeEach(() => setStyleClipboard(null));
@@ -122,3 +124,45 @@ describe('ContextMenu style items', () => {
expect(document.querySelector('.wsb-presets-popover')!.textContent).toContain('No saved presets');
});
});
describe('ContextMenu section commands (#52 / #58)', () => {
const offs: Array<() => void> = [];
afterEach(() => { offs.splice(0).forEach((off) => off()); });
const register = (id: string, run: (ctx: { selectedIds: string[] }) => void) =>
offs.push(registerCommand({ id, title: id, group: 'site', when: (ctx) => ctx.selectedIds.length === 1 && isTopLevelSection(ctx.query, ctx.selectedIds[0]), run }));
test('entries are absent while the commands are not registered', () => {
h = renderEditorHarness();
const a = add(React.createElement(Heading, {}));
h.act(() => { h.actions.selectNode(a); });
mountMenu(a);
expect(btn('Save as section')).toBeUndefined();
expect(btn('Regenerate this section')).toBeUndefined();
});
test('"Save as section" and "Regenerate this section" invoke their registry commands for the selected top-level node', () => {
const saved: string[][] = [];
const regenerated: string[][] = [];
register('site.save-as-section', (ctx) => { saved.push(ctx.selectedIds); });
register('edit.regenerate-section', (ctx) => { regenerated.push(ctx.selectedIds); });
h = renderEditorHarness();
const a = add(React.createElement(Heading, {}));
h.act(() => { h.actions.selectNode(a); });
mountMenu(a);
expect(btn('Save as section').disabled).toBe(false);
expect(btn('Regenerate this section').disabled).toBe(false);
click('Save as section');
click('Regenerate this section');
expect(saved).toEqual([[a]]);
expect(regenerated).toEqual([[a]]);
});
test('disabled when the command\'s when() is false (ROOT)', () => {
register('site.save-as-section', () => {});
register('edit.regenerate-section', () => {});
h = renderEditorHarness();
mountMenu('ROOT');
expect(btn('Save as section').disabled).toBe(true);
expect(btn('Regenerate this section').disabled).toBe(true);
});
});
+20 -1
View File
@@ -4,7 +4,7 @@ import { useSitesmithModal } from '../../state/SitesmithContext';
import { buildSitesmithTarget } from '../../utils/sitesmith-target';
import { regenerateTreeIds } from '../../utils/craft-tree';
import { getClipboardTree, setClipboardTree } from '../../hooks/clipboard';
import { getCommand } from '../../editor/commands';
import { getCommand, useCommandState } from '../../editor/commands';
import { formatShortcut } from '../../utils/shortcuts';
import { useNodeActions } from '../../hooks/useNodeActions';
import { useNotices } from '../../state/NoticeContext';
@@ -128,6 +128,9 @@ export const ContextMenu: React.FC<ContextMenuProps> = ({
// the exact same behavior. Wrapped here purely to also `onClose()` the
// menu after each action, same as before the extraction.
const nodeActions = useNodeActions(nodeId);
// #52 / #58: section commands live in the registry; the menu only invokes them.
const saveAsSection = useCommandState('site.save-as-section');
const regenerateSection = useCommandState('edit.regenerate-section');
const duplicate = useCallback(() => {
nodeActions.duplicate();
@@ -361,6 +364,22 @@ export const ContextMenu: React.FC<ContextMenuProps> = ({
disabled: isRoot || !!instance,
dividerAfter: true,
},
...(saveAsSection.registered
? [{
label: 'Save as section\u2026',
icon: 'archive',
action: () => { void saveAsSection.run(); onClose(); },
disabled: isRoot || !saveAsSection.enabled,
}]
: []),
...(regenerateSection.registered
? [{
label: 'Regenerate this section',
icon: 'refresh',
action: () => { void regenerateSection.run(); onClose(); },
disabled: isRoot || !regenerateSection.enabled,
}]
: []),
...(instance
? [
{ label: 'Edit master', icon: 'globe', action: editMaster },
@@ -271,6 +271,38 @@ describe('LayersPanel drag / rename / hide (#47)', () => {
hn.unmount();
});
test('a disallowed target keeps dropEffect "move" so the browser still fires drop (refusal notice shows)', () => {
const hn = mount();
const target = rowOf(hn, 'inner');
target.getBoundingClientRect = () => ({ top: 0, height: 30, left: 0, right: 0, bottom: 30, width: 0, x: 0, y: 0, toJSON() {} }) as DOMRect;
hn.act(() => { fire(rowOf(hn, 'sec'), 'dragstart'); });
let over: any;
hn.act(() => { over = fire(target, 'dragover', { clientY: 15 }); });
expect(over.defaultPrevented).toBe(true);
expect(over.dataTransfer.dropEffect).toBe('move');
// ...but no drop line is drawn for the refused target.
expect(hn.container.querySelector('[data-drop]')).toBeNull();
hn.unmount();
});
test('renaming to the unchanged default name stores no custom.label; to the default again clears one', () => {
const hn = mount();
const rename = (value: string) => {
hn.act(() => { rowOf(hn, 'h').dispatchEvent(new MouseEvent('dblclick', { bubbles: true })); });
const input = hn.container.querySelector('.layer-rename-input') as HTMLInputElement;
input.value = value;
hn.act(() => { input.dispatchEvent(new KeyboardEvent('keydown', { key: 'Enter', bubbles: true })); });
};
rename('Heading');
expect('label' in hn.query.node('h').get().data.custom).toBe(false);
expect(JSON.parse(hn.getSerialized()).h.custom).toEqual({});
rename('Pricing');
expect(hn.query.node('h').get().data.custom.label).toBe('Pricing');
rename(' Heading ');
expect('label' in hn.query.node('h').get().data.custom).toBe(false);
hn.unmount();
});
test('linked slots are not draggable; normal rows are', () => {
const hn = mount();
expect(rowOf(hn, 'inner').draggable).toBe(false);
@@ -357,3 +389,22 @@ describe('LayersPanel drag / rename / hide (#47)', () => {
hn.unmount();
});
});
describe('LayersPanel type icons for local-business blocks (#60)', () => {
test.each([
['OpeningHours', 'Opening Hours', 'fa-clock-o'],
['StickyCallBar', 'Call Bar', 'fa-phone'],
['ReviewsStrip', 'Reviews', 'fa-star'],
['BookingEmbed', 'Booking', 'fa-calendar'],
])('%s (%s) row uses %s', (resolvedName, displayName, icon) => {
const n = {
type: { resolvedName }, isCanvas: false, props: {}, displayName,
custom: {}, hidden: false, nodes: [], linkedNodes: {}, parent: 'ROOT',
};
const harness = renderEditorHarness({ initialState: stateWith({ b: n }, ['b']) });
harness.mountChild(<LayerFocusProvider><LayersPanel /></LayerFocusProvider>);
const row = harness.container.querySelector('.layer-node-row[data-layer-id="b"]')!;
expect(row.querySelector(`i.${icon}`)).not.toBeNull();
harness.unmount();
});
});
+14 -3
View File
@@ -63,6 +63,11 @@ const TYPE_ICONS: Record<string, string> = {
'Submit Button': 'fa-paper-plane',
'Contact Form': 'fa-envelope',
'Subscribe Form': 'fa-paper-plane',
// Local business (#60)
'Opening Hours': 'fa-clock-o',
'Call Bar': 'fa-phone',
Reviews: 'fa-star',
Booking: 'fa-calendar',
};
const DEFAULT_ICON = 'fa-cube';
@@ -294,12 +299,15 @@ const LayerNodeImpl: React.FC<LayerNodeProps> = ({ nodeId, depth }) => {
const commitRename = useCallback((value: string) => {
ui.set({ renamingId: null });
const v = value.trim();
const typed = value.trim();
// Typing the default name back means "no custom label": never store a
// redundant custom.label equal to the type name.
const v = typed === displayName ? '' : typed;
if (v === customLabel) return;
actions.setCustom(nodeId, (c: any) => {
if (v) c.label = v; else delete c.label;
});
}, [ui, actions, nodeId, customLabel]);
}, [ui, actions, nodeId, customLabel, displayName]);
const toggleHidden = useCallback((e: React.SyntheticEvent) => {
e.stopPropagation();
@@ -338,7 +346,10 @@ const LayerNodeImpl: React.FC<LayerNodeProps> = ({ nodeId, depth }) => {
e.preventDefault();
const drop = resolveDrop(e);
const ok = !!drop && dragId !== nodeId && moveLayerAllowed(query, dragId, drop.parentId);
if (e.dataTransfer) e.dataTransfer.dropEffect = ok ? 'move' : 'none';
// Always 'move': with dropEffect 'none' the browser never fires `drop`, so
// a refused target could not explain itself. Disallowed targets get no drop
// line here and are refused (with a notice) in handleDrop via moveLayer.
if (e.dataTransfer) e.dataTransfer.dropEffect = 'move';
ui.set(ok && drop ? { dropTargetId: nodeId, dropWhere: drop.where } : { dropTargetId: null, dropWhere: null });
};
const handleDrop = (e: React.DragEvent<HTMLElement>) => {
@@ -0,0 +1,93 @@
import { describe, test, expect, afterEach } from 'vitest';
import React from 'react';
import { renderEditorHarness, EditorHarness } from '../../test-utils/editorHarness';
import { SiteDesignProvider } from '../../state/SiteDesignContext';
import { PageProvider } from '../../state/PageContext';
import { SitesmithProvider } from '../../state/SitesmithContext';
import { insertBlock } from '../left/blocks-registry';
import { Heading } from '../../components/basic/Heading';
import { Container } from '../../components/layout/Container';
import { registerCommand } from '../../editor/commands';
import { isTopLevelSection } from '../../sections/catalog';
import { GuidedStyles } from './GuidedStyles';
/** Integration follow-ups: #60 business-panel classification and the #58 Regenerate button. */
let h: EditorHarness;
const offs: Array<() => void> = [];
afterEach(() => { h?.unmount(); offs.splice(0).forEach((off) => off()); });
function mount() {
h = renderEditorHarness();
h.mountChild(
<PageProvider><SiteDesignProvider><SitesmithProvider>
<GuidedStyles />
</SitesmithProvider></SiteDesignProvider></PageProvider>,
);
}
const select = (id: string) => h.act(() => h.actions.selectNode(id));
const header = () => h.container.querySelector('[data-testid="type-header"]')?.textContent?.trim();
const text = () => h.container.textContent || '';
const regenBtn = () => h.container.querySelector<HTMLButtonElement>('button[aria-label="Regenerate this section"]');
describe('GuidedStyles: local-business blocks use BusinessStylePanel (#60)', () => {
test.each([
['opening-hours', 'Opening Hours', 'Label color'],
['call-bar', 'Call Bar', 'Call button label'],
['reviews-strip', 'Reviews', 'Card background'],
['booking-embed', 'Booking', 'Booking URL (Calendly / Square)'],
])('%s -> header "%s" and the business control "%s"', (blockId, displayName, label) => {
mount();
let id: string | null = null;
h.act(() => { id = insertBlock(h.query, h.actions, blockId); });
select(id!);
expect(header()).toBe(displayName);
expect(text()).toContain(label);
expect(h.container.querySelector('.guided-type-header .fa-briefcase')).not.toBeNull();
});
});
describe('GuidedStyles: Regenerate button (#58)', () => {
const add = (el: React.ReactElement, parent = 'ROOT'): string => {
let id = '';
h.act(() => {
const tree = h.query.parseReactElement(el).toNodeTree();
h.actions.addNodeTree(tree, parent);
id = tree.rootNodeId;
});
return id;
};
const registerRegenerate = (runs: string[][]) =>
offs.push(registerCommand({
id: 'edit.regenerate-section', title: 'Regenerate this section', group: 'edit',
when: (ctx) => ctx.selectedIds.length === 1 && isTopLevelSection(ctx.query, ctx.selectedIds[0]),
run: (ctx) => { runs.push(ctx.selectedIds); },
}));
test('shown for a top-level section and runs the registry command', () => {
const runs: string[][] = [];
registerRegenerate(runs);
mount();
const section = add(<Container />);
select(section);
expect(regenBtn()).not.toBeNull();
h.act(() => { regenBtn()!.click(); });
expect(runs).toEqual([[section]]);
});
test('hidden when the command\'s when() is false (nested node)', () => {
registerRegenerate([]);
mount();
const section = add(<Container />);
const inner = add(<Heading />, section);
select(inner);
expect(header()).toBe('Heading');
expect(regenBtn()).toBeNull();
});
test('hidden when the command is not registered (standalone, no Sitesmith)', () => {
mount();
const section = add(<Container />);
select(section);
expect(regenBtn()).toBeNull();
});
});
+22 -1
View File
@@ -18,10 +18,12 @@ import {
BackgroundSectionStylePanel,
GenericPropsEditor,
HtmlStylePanel,
BusinessStylePanel,
} from './styles';
import { DeviceBanner } from './styles/responsive-panel';
import { setBulkSelection } from './styles/shared';
import { CORE_COMMANDS } from '../../editor/core-commands';
import { useCommandState } from '../../editor/commands';
import { useGlobalSections } from '../../state/GlobalSectionsContext';
import type { OverridePatch } from '../../utils/global-sections';
@@ -152,6 +154,8 @@ export const GuidedStyles: React.FC<{
emptyFallback?: 'site' | 'placeholder';
}> = ({ emptyFallback = 'site' }) => {
const { open: openSitesmith } = useSitesmithModal();
// #58: Regenerate lives in the registry (Sitesmith, WHP only); hidden when its when() is false.
const regenerate = useCommandState('edit.regenerate-section');
const { query } = useEditor();
const { selected, selectedType, nodeProps, selectedIds, mixedTypes, inInstance } = useEditor((state) => {
@@ -245,6 +249,8 @@ export const GuidedStyles: React.FC<{
// Utility types that need minimal controls. HTML is deliberately NOT here
// -- it gets HtmlStylePanel, which shows the code editor and nothing else.
const isUtility = /^divider$|^spacer$/i.test(typeName);
// #60 local-business blocks (displayNames in components/business/*).
const isBusiness = /^opening hours$|^call bar$|^reviews$|^booking$/i.test(typeName);
// Icon for the type badge
const typeIcon = isText ? 'fa-font'
@@ -260,6 +266,7 @@ export const GuidedStyles: React.FC<{
: isSection ? 'fa-th-large'
: isHtml ? 'fa-code'
: isUtility ? 'fa-ellipsis-h'
: isBusiness ? 'fa-briefcase'
: 'fa-cube';
const handleAskSitesmith = () => {
@@ -289,6 +296,17 @@ export const GuidedStyles: React.FC<{
>
<i className="fa fa-magic" /> Ask Sitesmith
</button>
{regenerate.enabled && (
<button
type="button"
className="guided-regenerate-btn"
onClick={() => { void regenerate.run(); }}
title="Regenerate this section with Sitesmith"
aria-label="Regenerate this section"
>
<i className="fa fa-refresh" aria-hidden /> Regenerate
</button>
)}
</div>
{inInstance && <GlobalInstancePanel selectedId={selected} nodeProps={nodeProps} />}
{!inInstance && <>
@@ -336,8 +354,11 @@ export const GuidedStyles: React.FC<{
{/* UTILITY (Divider, Spacer) -- use generic but it works well for these */}
{isUtility && <GenericPropsEditor selectedId={selected} nodeProps={nodeProps} typeName={typeName} />}
{/* LOCAL BUSINESS (Opening Hours, Call Bar, Reviews, Booking) */}
{isBusiness && <BusinessStylePanel selectedId={selected} nodeProps={nodeProps} />}
{/* FALLBACK: Anything not matched above */}
{!isText && !isButton && !isImage && !isBgSection && !isContainer && !isHero && !isNav && !isMedia && !isForm && !isSocial && !isPricing && !isSection && !isUtility && !isHtml && (
{!isText && !isButton && !isImage && !isBgSection && !isContainer && !isHero && !isNav && !isMedia && !isForm && !isSocial && !isPricing && !isSection && !isUtility && !isHtml && !isBusiness && (
<GenericPropsEditor selectedId={selected} nodeProps={nodeProps} typeName={typeName} />
)}
</>}
@@ -38,7 +38,7 @@ export const ContainerStylePanel: React.FC<StylePanelProps> = ({ selectedId, nod
const dot = (key: string | readonly string[]) => <OverrideDot nodeProps={nodeProps} selectedId={selectedId} styleKey={key} />;
const { device } = useDevice();
const { setProp, setPropStyle, setProps, setPropContinuous, setPropStyleContinuous, setPropStylesContinuous } = useNodeProp(selectedId);
const { setProp, setPropStyle, setProps, setPropStyles, setPropContinuous, setPropStyleContinuous, setPropStylesContinuous } = useNodeProp(selectedId);
// ColumnLayout only ever carries `columns`/`split` props -- Container and
// Section never set them -- so checking either alone distinguishes the
@@ -153,8 +153,8 @@ export const ContainerStylePanel: React.FC<StylePanelProps> = ({ selectedId, nod
)}
{/* Box model + border/effects + animation/visibility rollout */}
<BoxModelSection style={style} setPropStyle={setPropStyleContinuous} setPropStyles={setPropStylesContinuous} labelAddon={dot} />
<BorderEffectsSection style={style} setPropStyle={setPropStyleContinuous} />
<BoxModelSection style={style} setPropStyle={setPropStyleContinuous} setPropStyles={setPropStylesContinuous} labelAddon={dot} setPropStylesDiscrete={setPropStyles} />
<BorderEffectsSection style={style} setPropStyle={setPropStyleContinuous} setPropStyleDiscrete={setPropStyle} />
<AnimVisSection nodeProps={nodeProps} setProps={setProps} />
</>
);
@@ -118,8 +118,8 @@ export const ImageStylePanel: React.FC<StylePanelProps> = ({ selectedId, nodePro
</div>
{/* Box model + animation/visibility rollout */}
<BoxModelSection style={style} setPropStyle={setPropStyleContinuous} labelAddon={dot} />
<BorderEffectsSection style={style} setPropStyle={setPropStyleContinuous} />
<BoxModelSection style={style} setPropStyle={setPropStyleContinuous} labelAddon={dot} setPropStylesDiscrete={setPropStyles} />
<BorderEffectsSection style={style} setPropStyle={setPropStyleContinuous} setPropStyleDiscrete={setPropStyle} />
<AnimVisSection nodeProps={nodeProps} setProps={setProps} />
</>
);
@@ -279,8 +279,8 @@ export const MediaStylePanel: React.FC<StylePanelProps> = ({ selectedId, nodePro
{/* Box model + animation/visibility rollout (Video/Gallery/Map/Slider
all carry these props now, so it's safe to render unconditionally). */}
<BoxModelSection style={style} setPropStyle={setPropStyleContinuous} labelAddon={dot} />
<BorderEffectsSection style={style} setPropStyle={setPropStyleContinuous} />
<BoxModelSection style={style} setPropStyle={setPropStyleContinuous} labelAddon={dot} setPropStylesDiscrete={setPropStyles} />
<BorderEffectsSection style={style} setPropStyle={setPropStyleContinuous} setPropStyleDiscrete={setPropStyle} />
<AnimVisSection nodeProps={nodeProps} setProps={setProps} />
</>
);
@@ -0,0 +1,70 @@
import { describe, test, expect, afterEach } from 'vitest';
import React from 'react';
import { useEditor } from '@craftjs/core';
import { renderEditorHarness, EditorHarness } from '../../../test-utils/editorHarness';
import { DeviceProvider } from '../../../state/DeviceContext';
import type { DeviceMode } from '../../../types';
import { NavStylePanel } from './NavStylePanel';
import { closeContinuousBurst } from './shared';
/**
* Integration follow-up (#45 review): NavStylePanel reads the per-device
* resolved style and shows override dots on Margin / Padding, the same
* conversion #45 applied to the other panels.
*/
let h: EditorHarness | null = null;
afterEach(() => { closeContinuousBurst(); h?.unmount(); h = null; });
const node = (type: string, props: Record<string, unknown>, nodes: string[] = [], extra: Record<string, unknown> = {}) => ({
type: { resolvedName: type }, isCanvas: type === 'Container', props, displayName: type,
custom: {}, hidden: false, nodes, linkedNodes: {}, ...extra,
});
const state = (extra: Record<string, unknown> = {}) => JSON.stringify({
ROOT: node('Container', { tag: 'div', style: {} }, ['logo']),
logo: node('Logo', {
type: 'text', text: 'Acme',
style: { marginTop: '0', marginRight: '0', marginBottom: '0', marginLeft: '0', paddingTop: '8px', paddingRight: '8px', paddingBottom: '8px', paddingLeft: '8px' },
...extra,
}, [], { parent: 'ROOT' }),
});
const Host: React.FC = () => {
const { nodeProps } = useEditor((s) => ({ nodeProps: s.nodes.logo?.data.props || {} }));
return <NavStylePanel selectedId="logo" nodeProps={nodeProps} />;
};
function mount(device: DeviceMode, initialState = state()) {
h = renderEditorHarness({ initialState, wrap: (t) => <DeviceProvider initialDevice={device}>{t}</DeviceProvider> });
h.mountChild(<Host />);
}
const props = () => h!.query.node('logo').get().data.props;
const control = (label: string) =>
Array.from(h!.container.querySelectorAll('[data-testid="spacing-control"]'))
.find((el) => (el.querySelector('.guided-section-label')?.textContent || '').startsWith(label)) as HTMLElement;
const active = (label: string) => control(label).querySelector('button.preset-btn.active')?.textContent ?? null;
const click = (el: Element) => h!.act(() => { (el as HTMLElement).click(); });
describe('NavStylePanel per-device spacing (#45 follow-up)', () => {
test('mobile reads the mobile override, not the desktop style', () => {
mount('mobile', state({ styleMobile: { marginTop: '16px', marginRight: '16px', marginBottom: '16px', marginLeft: '16px' } }));
expect(active('Margin')).toBe('M');
expect(control('Margin').querySelector('[data-testid="override-dot"]')).not.toBeNull();
expect(control('Padding').querySelector('[data-testid="override-dot-inherited"]')).not.toBeNull();
});
test('a mobile Margin edit lands in styleMobile, reads back, and its dot resets it', () => {
mount('mobile');
expect(active('Margin')).toBe('None');
click(Array.from(control('Margin').querySelectorAll('button.preset-btn')).find((b) => b.textContent === 'M')!);
expect(props().styleMobile?.marginTop).toBe('16px');
expect(props().style.marginTop).toBe('0');
expect(active('Margin')).toBe('M');
click(control('Margin').querySelector('[data-testid="override-dot"]')!);
expect(props().styleMobile?.marginTop).toBeUndefined();
expect(active('Margin')).toBe('None');
});
test('desktop shows no dots', () => {
mount('desktop', state({ styleMobile: { marginTop: '16px' } }));
expect(h!.container.querySelector('.wsb-override-dot')).toBeNull();
});
});
@@ -26,6 +26,8 @@ import {
continuousSetProp,
discreteSetProp,
} from './shared';
import { useResolvedStyle, MARGIN_KEYS, PADDING_KEYS } from './responsive-panel';
import { OverrideDot } from './OverrideDot';
import { AssetPicker } from '../../../ui/AssetPicker';
import { usePages } from '../../../state/PageContext';
import { PageData } from '../../../types';
@@ -329,7 +331,10 @@ export const NavStylePanel: React.FC<StylePanelProps> = ({ selectedId, nodeProps
component's .craft.props), so -- unlike the Links/Colors sections above,
which are shared across a genuinely disparate prop schema -- these
sections render unconditionally rather than gating on presence. */
const style = nodeProps.style || {};
// #45: the per-device resolved style (desktop ⊕ tablet ⊕ mobile), so a
// tablet/mobile edit reads back what it wrote, plus override dots.
const style = useResolvedStyle(nodeProps) as Record<string, any>;
const dot = (key: string | readonly string[]) => <OverrideDot nodeProps={nodeProps} selectedId={selectedId} styleKey={key} />;
return (
<>
@@ -544,12 +549,12 @@ export const NavStylePanel: React.FC<StylePanelProps> = ({ selectedId, nodeProps
previous behavior exactly). */}
<CollapsibleSection title="Spacing">
<SpacingControl
label="Margin"
label={<>Margin{dot(MARGIN_KEYS)}</>}
value={{ top: style.marginTop, right: style.marginRight, bottom: style.marginBottom, left: style.marginLeft }}
onChange={(side, v) => setPropStyleContinuous(`margin${capitalize(side)}`, v)}
/>
<SpacingControl
label="Padding"
label={<>Padding{dot(PADDING_KEYS)}</>}
value={{ top: style.paddingTop, right: style.paddingRight, bottom: style.paddingBottom, left: style.paddingLeft }}
onChange={(side, v) => setPropStyleContinuous(`padding${capitalize(side)}`, v)}
presets={SPACING_PRESETS}
@@ -0,0 +1,96 @@
import { describe, test, expect, afterEach } from 'vitest';
import React from 'react';
import { useEditor } from '@craftjs/core';
import { renderEditorHarness, EditorHarness } from '../../../test-utils/editorHarness';
import { DeviceProvider } from '../../../state/DeviceContext';
import { ContainerStylePanel } from './ContainerStylePanel';
import { ImageStylePanel } from './ImageStylePanel';
import { closeContinuousBurst } from './shared';
/**
* #7 deferred minor: preset buttons in BoxModelSection / BorderEffectsSection
* are discrete edits. A keyboard-activated preset (Enter/Space: no pointerup,
* no focusout) must never merge into the previous edit of the same key.
* `.click()` here is exactly that: a click with no pointer events around it.
*/
let h: EditorHarness | null = null;
afterEach(() => { closeContinuousBurst(); h?.unmount(); h = null; });
const node = (type: string, props: Record<string, unknown>, nodes: string[] = [], extra: Record<string, unknown> = {}) => ({
type: { resolvedName: type }, isCanvas: type === 'Container', props, displayName: type === 'ImageBlock' ? 'Image' : type,
custom: {}, hidden: false, nodes, linkedNodes: {}, ...extra,
});
const state = () => JSON.stringify({
ROOT: node('Container', { tag: 'div', style: {} }, ['box', 'img']),
box: node('Container', { tag: 'div', style: { paddingTop: '4px', paddingRight: '4px', paddingBottom: '4px', paddingLeft: '4px', marginTop: '0', marginRight: '0', marginBottom: '0', marginLeft: '0' } }, [], { parent: 'ROOT' }),
img: node('ImageBlock', { src: 'a.png', alt: 'a', style: { marginTop: '0', marginRight: '0', marginBottom: '0', marginLeft: '0' } }, [], { parent: 'ROOT' }),
});
const Host: React.FC<{ id: string; Panel: React.FC<any> }> = ({ id, Panel }) => {
const { nodeProps } = useEditor((s) => ({ nodeProps: s.nodes[id]?.data.props || {} }));
return <Panel selectedId={id} nodeProps={nodeProps} />;
};
function mount(id: string, Panel: React.FC<any>) {
h = renderEditorHarness({ initialState: state(), wrap: (t) => <DeviceProvider initialDevice="desktop">{t}</DeviceProvider> });
h.mountChild(<Host id={id} Panel={Panel} />);
h.act(() => { h!.actions.history.clear(); });
}
const style = (id: string) => h!.query.node(id).get().data.props.style;
const control = (label: string) =>
Array.from(h!.container.querySelectorAll('[data-testid="spacing-control"]'))
.find((el) => (el.querySelector('.guided-section-label')?.textContent || '').startsWith(label)) as HTMLElement;
const preset = (root: Element, text: string) =>
Array.from(root.querySelectorAll('button.preset-btn')).find((b) => b.textContent === text) as HTMLButtonElement;
const shadowSection = () => {
if (!Array.from(h!.container.querySelectorAll('.guided-section-label')).some((l) => l.textContent === 'Shadow')) {
const toggle = Array.from(h!.container.querySelectorAll('button')).find((b) => b.textContent?.includes('Border & Effects'))!;
h!.act(() => { toggle.click(); });
}
const label = Array.from(h!.container.querySelectorAll('.guided-section-label')).find((l) => l.textContent === 'Shadow')!;
return label.closest('.guided-section') as HTMLElement;
};
const press = (b: HTMLButtonElement) => h!.act(() => { b.click(); });
const undo = () => h!.act(() => { h!.actions.history.undo(); });
describe('box-model / border-effects presets are discrete undo steps', () => {
test('Container: two padding presets in a row are two undo steps', () => {
mount('box', ContainerStylePanel);
press(preset(control('Padding'), 'S'));
press(preset(control('Padding'), 'L'));
expect(style('box').paddingTop).toBe('24px');
undo();
expect(style('box').paddingTop).toBe('8px');
expect(style('box').paddingLeft).toBe('8px');
});
test('Container: two margin presets in a row are two undo steps, each one step for all sides', () => {
mount('box', ContainerStylePanel);
press(preset(control('Margin'), 'S'));
press(preset(control('Margin'), 'M'));
undo();
expect([style('box').marginTop, style('box').marginRight, style('box').marginBottom, style('box').marginLeft]).toEqual(['8px', '8px', '8px', '8px']);
undo();
expect(style('box').marginTop).toBe('0');
expect(h!.query.history.canUndo()).toBe(false);
});
test('Image: a margin preset is ONE undo step for all four sides', () => {
mount('img', ImageStylePanel);
press(preset(control('Margin'), 'M'));
expect(style('img').marginLeft).toBe('16px');
undo();
expect([style('img').marginTop, style('img').marginRight, style('img').marginBottom, style('img').marginLeft]).toEqual(['0', '0', '0', '0']);
expect(h!.query.history.canUndo()).toBe(false);
});
test('shadow presets in a row are separate undo steps (Container and Image)', () => {
for (const [id, Panel] of [['box', ContainerStylePanel], ['img', ImageStylePanel]] as const) {
mount(id, Panel);
press(preset(shadowSection(), 'S'));
press(preset(shadowSection(), 'L'));
undo();
expect(style(id).boxShadow).toBe('0 1px 2px rgba(0,0,0,0.08)');
h!.unmount(); h = null; closeContinuousBurst();
}
});
});
@@ -81,13 +81,23 @@ export interface BoxModelSectionProps {
setPropStyles: (patch: Record<string, string>, controlKey: string) => void;
/** Per-device marker after the Margin / Padding labels (#45 OverrideDot). */
labelAddon?: (keys: readonly string[]) => React.ReactNode;
/** DISCRETE multi-key write (one undo step, closes any open burst) for the
* preset buttons, so a keyboard-activated preset never merges into the
* previous edit of the same key. Falls back to the continuous setters. */
setPropStylesDiscrete?: (patch: Record<string, string>) => void;
}
const sidesPatch = (kind: 'margin' | 'padding', v: SpacingSideValues): Record<string, string> => {
const patch: Record<string, string> = {};
SIDES.forEach((s) => { patch[`${kind}${capitalize(s)}`] = v[s] ?? ''; });
return patch;
};
/** Margin + Padding, per-side, via the shared SpacingControl. Padding is the
* only padding control: a legacy shorthand `style.padding` (with no
* longhands yet) is shown expanded and migrated to the four longhands on the
* first edit, in the same history step. */
export const BoxModelSection: React.FC<BoxModelSectionProps> = ({ style, setPropStyle, setPropStyles, labelAddon }) => {
export const BoxModelSection: React.FC<BoxModelSectionProps> = ({ style, setPropStyle, setPropStyles, labelAddon, setPropStylesDiscrete }) => {
const marginSetter = (side: SpacingSide, value: string) => setPropStyle(`margin${capitalize(side)}`, value);
const legacyShorthand = hasValue(style.padding) && PADDING_KEYS.every((k) => !hasValue(style[k]));
@@ -111,12 +121,14 @@ export const BoxModelSection: React.FC<BoxModelSectionProps> = ({ style, setProp
label={<>Margin{labelAddon?.(MARGIN_KEYS)}</>}
value={{ top: style.marginTop, right: style.marginRight, bottom: style.marginBottom, left: style.marginLeft }}
onChange={marginSetter}
onPreset={setPropStylesDiscrete ? (all) => setPropStylesDiscrete(sidesPatch('margin', all)) : undefined}
/>
<SpacingControl
label={<>Padding{labelAddon?.(PADDING_GROUP)}</>}
value={padding}
onChange={(side, value) => setPropStyles(paddingPatch({ ...padding, [side]: value }), `style.padding${capitalize(side)}`)}
onChangeAll={(all) => setPropStyles(paddingPatch(all), 'style.padding*')}
onPreset={setPropStylesDiscrete ? (all) => setPropStylesDiscrete(paddingPatch(all)) : undefined}
/>
</CollapsibleSection>
);
@@ -134,10 +146,12 @@ function opacityPercent(v: unknown): number {
export interface BorderEffectsSectionProps {
style: Record<string, any>;
setPropStyle: (prop: string, value: string) => void;
/** DISCRETE single-key write for the shadow preset buttons (see BoxModelSection). */
setPropStyleDiscrete?: (prop: string, value: string) => void;
}
/** Border (width/style/color) + box-shadow preset + opacity slider. */
export const BorderEffectsSection: React.FC<BorderEffectsSectionProps> = ({ style, setPropStyle }) => (
export const BorderEffectsSection: React.FC<BorderEffectsSectionProps> = ({ style, setPropStyle, setPropStyleDiscrete }) => (
<CollapsibleSection title="Border & Effects" defaultOpen={false}>
<BorderControl
value={parseBorderShorthand(style.border)}
@@ -145,7 +159,7 @@ export const BorderEffectsSection: React.FC<BorderEffectsSectionProps> = ({ styl
/>
<div className="guided-section">
<SectionLabel>Shadow</SectionLabel>
<PresetButtonGrid presets={SHADOW_PRESETS} activeValue={style.boxShadow} onSelect={(v) => setPropStyle('boxShadow', v)} />
<PresetButtonGrid presets={SHADOW_PRESETS} activeValue={style.boxShadow} onSelect={(v) => (setPropStyleDiscrete ?? setPropStyle)('boxShadow', v)} />
</div>
<div style={sectionGap}>
<label style={labelStyle}>Opacity: {opacityPercent(style.opacity)}%</label>
@@ -45,12 +45,25 @@ export interface BoxModelSectionProps {
setPropStyle: (prop: string, value: string) => void;
/** Per-device marker after the Margin / Padding labels (#45 OverrideDot). */
labelAddon?: (keys: readonly string[]) => React.ReactNode;
/** DISCRETE multi-key write (one undo step, closes any open burst) for the
* preset buttons, so a keyboard-activated preset never merges into the
* previous edit of the same key. Falls back to per-side setPropStyle. */
setPropStylesDiscrete?: (patch: Record<string, string>) => void;
}
const SIDES: SpacingSide[] = ['top', 'right', 'bottom', 'left'];
/** Margin + Padding, per-side, via the shared SpacingControl. */
export const BoxModelSection: React.FC<BoxModelSectionProps> = ({ style, setPropStyle, labelAddon }) => {
export const BoxModelSection: React.FC<BoxModelSectionProps> = ({ style, setPropStyle, labelAddon, setPropStylesDiscrete }) => {
const sideSetter = (kind: 'margin' | 'padding') => (side: SpacingSide, value: string) =>
setPropStyle(`${kind}${capitalize(side)}`, value);
const presetSetter = (kind: 'margin' | 'padding') => (setPropStylesDiscrete
? (all: Record<SpacingSide, string>) => {
const patch: Record<string, string> = {};
SIDES.forEach((s) => { patch[`${kind}${capitalize(s)}`] = all[s]; });
setPropStylesDiscrete(patch);
}
: undefined);
return (
<CollapsibleSection title="Spacing">
@@ -58,11 +71,13 @@ export const BoxModelSection: React.FC<BoxModelSectionProps> = ({ style, setProp
label={<>Margin{labelAddon?.(MARGIN_KEYS)}</>}
value={{ top: style.marginTop, right: style.marginRight, bottom: style.marginBottom, left: style.marginLeft }}
onChange={sideSetter('margin')}
onPreset={presetSetter('margin')}
/>
<SpacingControl
label={<>Padding{labelAddon?.(PADDING_KEYS)}</>}
value={{ top: style.paddingTop, right: style.paddingRight, bottom: style.paddingBottom, left: style.paddingLeft }}
onChange={sideSetter('padding')}
onPreset={presetSetter('padding')}
/>
</CollapsibleSection>
);
@@ -80,10 +95,12 @@ function opacityPercent(v: unknown): number {
export interface BorderEffectsSectionProps {
style: Record<string, any>;
setPropStyle: (prop: string, value: string) => void;
/** DISCRETE single-key write for the shadow preset buttons (see BoxModelSection). */
setPropStyleDiscrete?: (prop: string, value: string) => void;
}
/** Border (width/style/color) + box-shadow preset + opacity slider. */
export const BorderEffectsSection: React.FC<BorderEffectsSectionProps> = ({ style, setPropStyle }) => (
export const BorderEffectsSection: React.FC<BorderEffectsSectionProps> = ({ style, setPropStyle, setPropStyleDiscrete }) => (
<CollapsibleSection title="Border & Effects" defaultOpen={false}>
<BorderControl
value={parseBorderShorthand(style.border)}
@@ -91,7 +108,7 @@ export const BorderEffectsSection: React.FC<BorderEffectsSectionProps> = ({ styl
/>
<div className="guided-section">
<SectionLabel>Shadow</SectionLabel>
<PresetButtonGrid presets={SHADOW_PRESETS} activeValue={style.boxShadow} onSelect={(v) => setPropStyle('boxShadow', v)} />
<PresetButtonGrid presets={SHADOW_PRESETS} activeValue={style.boxShadow} onSelect={(v) => (setPropStyleDiscrete ?? setPropStyle)('boxShadow', v)} />
</div>
<div style={sectionGap}>
<label style={labelStyle}>Opacity: {opacityPercent(style.opacity)}%</label>
+10 -2
View File
@@ -925,10 +925,14 @@ export interface SpacingControlProps {
/** Optional: linked/preset edits call this once with all four sides instead
* of calling `onChange` four times, so a consumer can make them one undo step. */
onChangeAll?: (values: Required<SpacingSideValues>) => void;
/** Optional: a preset button pick, all four sides. Wire to a DISCRETE setter
* so a keyboard-activated preset is always its own undo step; falls back
* to the same path as the linked input when omitted. */
onPreset?: (values: Required<SpacingSideValues>) => void;
presets?: { label: string; value: string }[];
units?: string[];
}
export const SpacingControl: React.FC<SpacingControlProps> = ({ label, value, onChange, onChangeAll, presets = SPACING_PRESETS, units = ['px', '%', 'em', 'rem'] }) => {
export const SpacingControl: React.FC<SpacingControlProps> = ({ label, value, onChange, onChangeAll, onPreset, presets = SPACING_PRESETS, units = ['px', '%', 'em', 'rem'] }) => {
const [linked, setLinked] = useState(true);
const applyToAllSides = (v: string) => (onChangeAll
@@ -950,7 +954,11 @@ export const SpacingControl: React.FC<SpacingControlProps> = ({ label, value, on
<i className={`fa fa-link${linked ? '' : '-slash'}`} />
</button>
</div>
<PresetButtonGrid presets={presets} activeValue={isAllEqual ? linkedValue : undefined} onSelect={applyToAllSides} />
<PresetButtonGrid
presets={presets}
activeValue={isAllEqual ? linkedValue : undefined}
onSelect={onPreset ? (v) => onPreset({ top: v, right: v, bottom: v, left: v }) : applyToAllSides}
/>
{linked ? (
<div style={{ marginTop: 6 }}>
<NumericUnitInput value={linkedValue} onChange={applyToAllSides} units={units} testId="spacing-linked" />
@@ -0,0 +1,25 @@
import { describe, test, expect, afterEach } from 'vitest';
import React from 'react';
import { renderEditorHarness, EditorHarness } from '../../test-utils/editorHarness';
import { TemplateModal } from './TemplateModal';
/** #44 integration follow-up: the "apply design" checkbox copy says what unchecked now means. */
let h: EditorHarness | null = null;
afterEach(() => { h?.unmount(); h = null; document.body.innerHTML = ''; });
describe('TemplateModal apply-design copy', () => {
test('label and help text follow the checkbox state', () => {
h = renderEditorHarness();
h.mountChild(<TemplateModal open onClose={() => {}} />);
const card = document.querySelector('[role="button"]') as HTMLElement;
expect(card).not.toBeNull();
h.act(() => { card.click(); });
const help = () => document.querySelector('[data-testid="template-apply-design-help"]')?.textContent;
expect(document.body.textContent).toContain("Use the template's colors and fonts");
expect(help()).toBe("Replaces your site's colors and fonts with the template's.");
const box = document.querySelector('input[type="checkbox"]') as HTMLInputElement;
h.act(() => { box.click(); });
expect(box.checked).toBe(false);
expect(help()).toBe("Keeps your site's colors and fonts: the template's colors follow your site palette.");
});
});
+10 -2
View File
@@ -354,9 +354,17 @@ export const TemplateModal: React.FC<TemplateModalProps> = ({ open, onClose }) =
type="checkbox"
checked={applyDesign}
onChange={(e) => setApplyDesign(e.target.checked)}
style={{ width: 16, height: 16, accentColor: '#3b82f6', cursor: 'pointer' }}
style={{ width: 16, height: 16, accentColor: '#3b82f6', cursor: 'pointer', flexShrink: 0 }}
/>
Apply template colors and fonts to site design
<span style={{ display: 'flex', flexDirection: 'column', gap: 2 }}>
<span>Use the template's colors and fonts</span>
{/* #44: templates reference site tokens, so unchecked = the site's own palette. */}
<span data-testid="template-apply-design-help" style={{ fontSize: 12, color: 'var(--color-text-muted)' }}>
{applyDesign
? "Replaces your site's colors and fonts with the template's."
: "Keeps your site's colors and fonts: the template's colors follow your site palette."}
</span>
</span>
</label>
<div style={{ display: 'flex', gap: 10, justifyContent: 'flex-end' }}>
+44 -2
View File
@@ -34,12 +34,12 @@ import { act } from 'react-dom/test-utils';
// #54: what the pre-publish checks see. Default (no pages) means no checks, so
// every pre-existing test publishes directly.
const checkFixture: { pages: any[]; liveState: string; activePageId: string } = { pages: [], liveState: '{}', activePageId: 'home' };
const checkFixture: { pages: any[]; liveState: string; activePageId: string; footerState: string | null } = { pages: [], liveState: '{}', activePageId: 'home', footerState: null };
vi.mock('../../state/PageContext', () => ({
usePages: () => ({
pages: checkFixture.pages,
headerPage: { id: '__header__', name: 'Header', slug: '__header__', craftState: null },
footerPage: { id: '__footer__', name: 'Footer', slug: '__footer__', craftState: null },
footerPage: { id: '__footer__', name: 'Footer', slug: '__footer__', craftState: checkFixture.footerState },
activePageId: checkFixture.activePageId,
switchPage: vi.fn(),
}),
@@ -71,6 +71,8 @@ vi.mock('../../hooks/useWhpApi', () => ({
vi.mock('./TemplateModal', () => ({ TemplateModal: () => null }));
vi.mock('./HeadCodeModal', () => ({ HeadCodeModal: () => null }));
vi.mock('../sitesmith/SitesmithButton', () => ({ SitesmithButton: () => null }));
const openPaletteMock = vi.fn();
vi.mock('../command-palette/CommandPalette', () => ({ openCommandPalette: () => openPaletteMock() }));
import { TopBar } from './TopBar';
import { EditorConfigProvider } from '../../state/EditorConfigContext';
@@ -130,6 +132,8 @@ afterEach(() => {
checkFixture.pages = [];
checkFixture.liveState = '{}';
checkFixture.activePageId = 'home';
checkFixture.footerState = null;
openPaletteMock.mockReset();
});
describe('TopBar publish-warnings wiring', () => {
@@ -315,3 +319,41 @@ describe('TopBar pre-publish checklist', () => {
expect(container.querySelector('[data-testid="publish-warnings"]')!.textContent).toContain('Generated sitemap.xml and robots.txt.');
});
});
describe('TopBar search button (#49 integration)', () => {
test('opens the command palette and carries the shortcut tooltip', () => {
render();
const btn = container.querySelector<HTMLButtonElement>('button[aria-label="Search"]');
expect(btn).not.toBeNull();
expect(btn!.getAttribute('data-tooltip')).toBe('Search (Ctrl/Cmd+K)');
act(() => { btn!.dispatchEvent(new MouseEvent('click', { bubbles: true })); });
expect(openPaletteMock).toHaveBeenCalledTimes(1);
});
});
describe('TopBar Preview head parity (item 14)', () => {
const rootState = (style: Record<string, string> = {}) => JSON.stringify({
ROOT: { type: { resolvedName: 'Container' }, isCanvas: true, props: { tag: 'div', style }, displayName: 'Container', custom: {}, hidden: false, nodes: [], linkedNodes: {} },
});
test('scans the composed header+body+footer for preset fonts and trims metaTitle', async () => {
checkFixture.liveState = rootState();
checkFixture.footerState = rootState({ fontFamily: 'Merriweather, serif' });
checkFixture.pages = [{ id: 'home', name: 'Home', slug: 'index', craftState: null, seo: { metaTitle: ' ' } }];
let blob: Blob | null = null;
const createUrl = vi.spyOn(URL, 'createObjectURL').mockImplementation((b) => { blob = b as Blob; return 'blob:x'; });
const openSpy = vi.spyOn(window, 'open').mockImplementation(() => null);
try {
render();
const btn = container.querySelector<HTMLButtonElement>('button[aria-label="Preview"]')!;
await act(async () => { btn.dispatchEvent(new MouseEvent('click', { bubbles: true })); });
await vi.waitFor(() => expect(blob).not.toBeNull());
const html = await blob!.text();
expect(html).toContain('family=Inter:wght@300;400;500;600;700&family=Merriweather:wght@300;400;700&display=swap');
expect(html).toContain('<title>Home</title>');
} finally {
createUrl.mockRestore();
openSpy.mockRestore();
}
});
});
+12 -1
View File
@@ -24,6 +24,7 @@ import { SitesmithButton } from '../sitesmith/SitesmithButton';
import { useSitesmithModal } from '../../state/SitesmithContext';
import { useRegisterCommands } from '../../editor/commands';
import { TopBarCommandMenu } from './TopBarCommandMenu';
import { openCommandPalette } from '../command-palette/CommandPalette';
interface TopBarProps {
/** Item 10: canvas dashed guide outlines, default ON. */
@@ -306,7 +307,7 @@ export const TopBar: React.FC<TopBarProps> = ({ showGuides, onToggleGuides }) =>
// html-export's buildSeoMeta/og:title fallback both key off it.
const activePage = pages.find((p) => p.id === activePageId);
const seo = activePage?.seo;
const title = seo?.metaTitle || activePage?.name || whpConfig?.siteName || 'Preview';
const title = seo?.metaTitle?.trim() || activePage?.name || whpConfig?.siteName || 'Preview';
const result = exportToHtml(serialized, {
title,
@@ -323,6 +324,8 @@ export const TopBar: React.FC<TopBarProps> = ({ showGuides, onToggleGuides }) =>
? `https://${whpConfig.siteDomain}/${activePage.slug === 'index' ? '' : activePage.slug}`
: undefined,
design,
// Fonts: scan header+body+footer like the published head does (head parity).
fontScanHtml: composedBody,
});
// Replace the body in the full document with our composed version.
@@ -528,6 +531,14 @@ export const TopBar: React.FC<TopBarProps> = ({ showGuides, onToggleGuides }) =>
>
<i className="fa fa-code" />
</button>
<button
className="topbar-btn icon-only"
aria-label="Search"
data-tooltip="Search (Ctrl/Cmd+K)"
onClick={() => openCommandPalette()}
>
<i className="fa fa-search" aria-hidden />
</button>
<button className="topbar-btn icon-only" aria-label="Preview" data-tooltip="Preview" onClick={handlePreview}>
<i className="fa fa-eye" />
</button>
+11
View File
@@ -3324,3 +3324,14 @@ body {
}
.section-lib-chip-global i { margin-right: 3px; }
.section-lib-usage { display: block; font-size: 11px; color: var(--color-text-muted); }
/* #followups -- integration follow-ups (#58 Regenerate button in the type header) */
.guided-regenerate-btn {
display: inline-flex; align-items: center; gap: 4px;
padding: 4px 8px; font-size: 11px; font-weight: 600;
color: var(--color-text); background: transparent;
border: 1px solid var(--color-border); border-radius: var(--radius-sm); cursor: pointer;
}
.guided-regenerate-btn:hover { background: var(--color-bg-hover); }
.guided-regenerate-btn:focus-visible { outline: 2px solid var(--color-accent); outline-offset: 1px; }
/* /#followups */
+27
View File
@@ -101,3 +101,30 @@ describe('StockPhotoTab', () => {
expect(q('stock-error')!.textContent).toBe('download failed');
});
});
describe('StockPhotoTab: stale responses (#55 review)', () => {
test('an older search resolving after a newer cache-hit search does not replace or clear the grid', async () => {
mount();
// "fox" -> cached.
await searchAndSelect();
expect(q('stock-thumb-7')).not.toBeNull();
// "cat" stays in flight; resolves (or rejects on abort) only when we say so.
let release!: () => void;
fetchMock.mockImplementationOnce((_u: string, init: any) => new Promise((resolve, reject) => {
init.signal.addEventListener('abort', () => reject(new DOMException('aborted', 'AbortError')));
release = () => resolve({ ok: true, status: 200, json: () => Promise.resolve({ success: true, results: [{ ...PHOTO, id: 99, alt: 'cat' }], next_page: null }) } as Response);
}));
setValueIn('stock-search-input', 'cat');
const form = container.querySelector('form')!;
await act(async () => { form.dispatchEvent(new Event('submit', { bubbles: true, cancelable: true })); });
// Back to "fox" while "cat" is pending: a cache hit (form submit bypasses the disabled button).
setValueIn('stock-search-input', 'fox');
await act(async () => { form.dispatchEvent(new Event('submit', { bubbles: true, cancelable: true })); });
await flush();
release?.();
await flush();
expect(q('stock-thumb-7')).not.toBeNull();
expect(q('stock-thumb-99')).toBeNull();
expect(fetchMock).toHaveBeenCalledTimes(2);
});
});
+8 -1
View File
@@ -1,4 +1,4 @@
import React, { useState, useCallback } from 'react';
import React, { useState, useCallback, useRef } from 'react';
import { useStockPhotos, StockPhoto, StockCredit } from '../hooks/useStockPhotos';
export interface PickedAsset {
@@ -25,20 +25,27 @@ export const StockPhotoTab: React.FC<Props> = ({ onPick }) => {
const [selected, setSelected] = useState<StockPhoto | null>(null);
const [importing, setImporting] = useState(false);
const [searched, setSearched] = useState(false);
// Only the newest request may update the grid: an older (aborted or slow)
// one resolving late must not clear or replace newer results.
const seq = useRef(0);
const runSearch = useCallback(async () => {
const q = query.trim();
if (!q) return;
const mine = ++seq.current;
setSubmitted(q);
setSelected(null);
const res = await search(q, 1);
if (mine !== seq.current) return;
if (res) { setPhotos(res.results); setNextPage(res.nextPage); setSearched(true); }
else setPhotos([]);
}, [query, search]);
const loadMore = useCallback(async () => {
if (nextPage == null) return;
const mine = ++seq.current;
const res = await search(submitted, nextPage);
if (mine !== seq.current) return;
if (res) { setPhotos((p) => [...p, ...res.results]); setNextPage(res.nextPage); }
}, [nextPage, search, submitted]);
+12
View File
@@ -113,4 +113,16 @@ describe('buildSnapshotPreviewHtml', () => {
test('throws for a missing page index', () => {
expect(() => buildSnapshotPreviewHtml(snap(), 9, 'https://x')).toThrow();
});
test('head parity: fonts are scanned from header+body+footer, and metaTitle is trimmed', () => {
const footer = JSON.stringify({
ROOT: { type: { resolvedName: 'Container' }, isCanvas: true, props: { style: { fontFamily: 'Merriweather, serif' } }, displayName: 'Container', custom: {}, hidden: false, nodes: [], linkedNodes: {}, parent: null },
});
const html = buildSnapshotPreviewHtml(
{ pages_craft_state: [{ id: 'a', name: 'Page A', slug: 'index', craftState: state('Body'), seo: { metaTitle: ' ' } }], footer_craft_state: footer },
0,
'https://panel.example',
);
expect(html).toContain('family=Inter:wght@300;400;500;600;700&family=Merriweather:wght@300;400;700&display=swap');
expect(html).toContain('<title>Page A</title>');
});
});
+3 -1
View File
@@ -143,7 +143,7 @@ export function buildSnapshotPreviewHtml(snapshot: Snapshot, pageIndex: number,
const fullDesign = { ...DEFAULT_SITE_DESIGN, ...(design || {}) } as SiteDesign;
const seo = page.seo;
let html = exportToHtml(state, {
title: seo?.metaTitle || page.name || 'Preview',
title: seo?.metaTitle?.trim() || page.name || 'Preview',
includeFonts: true,
headCode: fullDesign.headCode,
description: seo?.metaDescription,
@@ -153,6 +153,8 @@ export function buildSnapshotPreviewHtml(snapshot: Snapshot, pageIndex: number,
noindex: seo?.noindex,
favicon: fullDesign.favicon,
design: fullDesign,
// Fonts: scan header+body+footer like the published head does (head parity).
fontScanHtml: composedBody,
}).html;
// Function replacer: user content may contain `$&` / `$1`.
@@ -0,0 +1,114 @@
/**
* Head parity, frontend half (backend-followups / integration-followups item 14).
*
* The PINNED_HEAD strings below are copied VERBATIM from WHP
* scripts/test-site-builder-head-parity.php ($FE_HEAD_*), which asserts that the
* backend generateCompiledHTML() emits exactly these bytes for the same inputs.
* This test asserts that the frontend Preview head (exportToHtml/wrapInDocument)
* emits the SAME bytes, so Preview head === Published head is pinned from both
* sides. Never hand-edit these strings: if wrapInDocument changes, regenerate them
* here AND in the WHP script together.
*/
import { describe, expect, test } from 'vitest';
import { exportToHtml, exportBodyHtml, buildFontsLink, type ExportOptions } from './html-export';
import { DEFAULT_SITE_DESIGN, type SiteDesign } from '../state/SiteDesignContext';
const PINNED_HEAD: Record<string, string> = {
A_defaults: "<!DOCTYPE html>\n<html lang=\"en\">\n<head>\n <meta charset=\"UTF-8\">\n <meta name=\"viewport\" content=\"width=device-width, initial-scale=1.0\">\n <title>Joe's Pizza &amp; Grill</title>\n <meta property=\"og:title\" content=\"Joe&#39;s Pizza &amp; Grill\">\n <meta property=\"og:type\" content=\"website\">\n <link rel=\"canonical\" href=\"https://example.com/\">\n <link rel=\"preconnect\" href=\"https://fonts.googleapis.com\">\n <link rel=\"preconnect\" href=\"https://fonts.gstatic.com\" crossorigin>\n <link href=\"https://fonts.googleapis.com/css2?family=Inter:wght@300;400;500;600;700&display=swap\" rel=\"stylesheet\">\n <style>*,*::before,*::after{box-sizing:border-box;margin:0;padding:0}html,body{overflow-x:clip}body{font-family:'Inter',-apple-system,BlinkMacSystemFont,'Segoe UI',Roboto,sans-serif;line-height:1.6;color:#1f2937;-webkit-font-smoothing:antialiased}img{max-width:100%;height:auto;display:block}a{color:inherit}@media(max-width:767px){[style*=\"display: flex\"][style*=\"flex-direction: row\"],[style*=\"display:flex\"][style*=\"flex-direction:row\"]{flex-direction:column!important}}@media(min-width:992px){[data-hide-desktop]{display:none!important}}@media(min-width:768px) and (max-width:991px){[data-hide-tablet]{display:none!important}}@media(max-width:767px){[data-hide-mobile]{display:none!important}}:root{--wsb-primary:#3b82f6;--wsb-secondary:#8b5cf6;--wsb-accent:#10b981;--wsb-link:#3b82f6;--wsb-success:#10b981;--wsb-warning:#f59e0b;--wsb-error:#ef4444;--wsb-bg:#ffffff;--wsb-text:#1f2937;--wsb-muted:#6b7280;--wsb-border:#e5e7eb;--wsb-radius:8px;--wsb-heading-font:Inter, sans-serif;--wsb-body-font:Inter, sans-serif;--wsb-button-font:Inter, sans-serif;--wsb-button-radius:8px}body{font-family:var(--wsb-body-font);color:var(--wsb-text);background:var(--wsb-bg)}h1,h2,h3,h4,h5,h6{font-family:var(--wsb-heading-font)}a{color:var(--wsb-link)}a:focus-visible,button:focus-visible,input:focus-visible,textarea:focus-visible,select:focus-visible,[tabindex]:focus-visible{outline:2px solid var(--wsb-accent);outline-offset:2px}@media(prefers-reduced-motion:reduce){*,*::before,*::after{animation-duration:.001ms!important;animation-iteration-count:1!important;transition-duration:.001ms!important;scroll-behavior:auto!important}}</style>\n</head>",
B_full: "<!DOCTYPE html>\n<html lang=\"en\">\n<head>\n <meta charset=\"UTF-8\">\n <meta name=\"viewport\" content=\"width=device-width, initial-scale=1.0\">\n <meta name=\"robots\" content=\"noindex, nofollow\">\n <title>About &quot;Us&quot;</title>\n <meta name=\"description\" content=\"We&#39;re &lt;the&gt; best &amp; &quot;only&quot; bakery\">\n <meta property=\"og:title\" content=\"About Joe&#39;s\">\n <meta property=\"og:description\" content=\"We&#39;re &lt;the&gt; best &amp; &quot;only&quot; bakery\">\n <meta property=\"og:image\" content=\"https://cdn.example.com/og.png\">\n <meta property=\"og:type\" content=\"website\">\n <meta name=\"twitter:card\" content=\"summary\">\n <link rel=\"icon\" href=\"https://cdn.example.com/favicon.ico\">\n <link rel=\"canonical\" href=\"https://example.com/about\">\n <link rel=\"preconnect\" href=\"https://fonts.googleapis.com\">\n <link rel=\"preconnect\" href=\"https://fonts.gstatic.com\" crossorigin>\n <link href=\"https://fonts.googleapis.com/css2?family=Playfair+Display:wght@400;600;700&family=Roboto:wght@300;400;500;700&display=swap\" rel=\"stylesheet\">\n <style>*,*::before,*::after{box-sizing:border-box;margin:0;padding:0}html,body{overflow-x:clip}body{font-family:'Inter',-apple-system,BlinkMacSystemFont,'Segoe UI',Roboto,sans-serif;line-height:1.6;color:#1f2937;-webkit-font-smoothing:antialiased}img{max-width:100%;height:auto;display:block}a{color:inherit}@media(max-width:767px){[style*=\"display: flex\"][style*=\"flex-direction: row\"],[style*=\"display:flex\"][style*=\"flex-direction:row\"]{flex-direction:column!important}}@media(min-width:992px){[data-hide-desktop]{display:none!important}}@media(min-width:768px) and (max-width:991px){[data-hide-tablet]{display:none!important}}@media(max-width:767px){[data-hide-mobile]{display:none!important}}@keyframes fadeIn{from{opacity:0}to{opacity:1}}@keyframes slideUp{from{opacity:0;transform:translateY(30px)}to{opacity:1;transform:translateY(0)}}@keyframes slideLeft{from{opacity:0;transform:translateX(-30px)}to{opacity:1;transform:translateX(0)}}@keyframes slideRight{from{opacity:0;transform:translateX(30px)}to{opacity:1;transform:translateX(0)}}@keyframes zoomIn{from{opacity:0;transform:scale(.9)}to{opacity:1;transform:scale(1)}}@keyframes bounce{0%{opacity:0;transform:translateY(40px)}40%{opacity:1;transform:translateY(-12px)}60%{transform:translateY(6px)}80%{transform:translateY(-3px)}100%{opacity:1;transform:translateY(0)}}[data-animation]{opacity:0}[data-animation].animated{animation-duration:.6s;animation-fill-mode:both}[data-animation=\"fade-in\"].animated{animation-name:fadeIn}[data-animation=\"slide-up\"].animated{animation-name:slideUp}[data-animation=\"slide-left\"].animated{animation-name:slideLeft}[data-animation=\"slide-right\"].animated{animation-name:slideRight}[data-animation=\"zoom-in\"].animated{animation-name:zoomIn}[data-animation=\"bounce\"].animated{animation-name:bounce}:root{--wsb-primary:#ff0066;--wsb-secondary:#8b5cf6;--wsb-accent:#10b981;--wsb-link:#3b82f6;--wsb-success:#10b981;--wsb-warning:#f59e0b;--wsb-error:#ef4444;--wsb-bg:#ffffff;--wsb-text:#1f2937;--wsb-muted:#6b7280;--wsb-border:#e5e7eb;--wsb-radius:12px;--wsb-heading-font:Playfair Display, serif;--wsb-body-font:Roboto, sans-serif;--wsb-button-font:Roboto, sans-serif;--wsb-button-radius:999px}body{font-family:var(--wsb-body-font);color:var(--wsb-text);background:var(--wsb-bg)}h1,h2,h3,h4,h5,h6{font-family:var(--wsb-heading-font)}a{color:var(--wsb-link)}a:focus-visible,button:focus-visible,input:focus-visible,textarea:focus-visible,select:focus-visible,[tabindex]:focus-visible{outline:2px solid var(--wsb-accent);outline-offset:2px}@media(prefers-reduced-motion:reduce){*,*::before,*::after{animation-duration:.001ms!important;animation-iteration-count:1!important;transition-duration:.001ms!important;scroll-behavior:auto!important}}</style>\n <noscript><style>[data-animation]{opacity:1}</style></noscript>\n <script async src=\"https://www.googletagmanager.com/gtag/js?id=G-X\"></script>\n<meta name=\"google-site-verification\" content=\"abc123\">\n</head>",
C_unknown_font: "<!DOCTYPE html>\n<html lang=\"en\">\n<head>\n <meta charset=\"UTF-8\">\n <meta name=\"viewport\" content=\"width=device-width, initial-scale=1.0\">\n <title>Contact</title>\n <meta name=\"description\" content=\"Get in touch\">\n <meta property=\"og:title\" content=\"Contact\">\n <meta property=\"og:description\" content=\"Get in touch\">\n <meta property=\"og:type\" content=\"website\">\n <meta name=\"twitter:card\" content=\"summary\">\n <link rel=\"canonical\" href=\"https://example.com/contact\">\n <link rel=\"preconnect\" href=\"https://fonts.googleapis.com\">\n <link rel=\"preconnect\" href=\"https://fonts.gstatic.com\" crossorigin>\n <link href=\"https://fonts.googleapis.com/css2?family=Inter:wght@300;400;500;600;700&family=Roboto:wght@300;400;500;700&family=Open+Sans:wght@300;400;600;700&family=Poppins:wght@300;400;500;600;700&family=Montserrat:wght@300;400;500;600;700&family=Playfair+Display:wght@400;600;700&family=Merriweather:wght@300;400;700&family=Source+Code+Pro:wght@400;500;600&display=swap\" rel=\"stylesheet\">\n <style>*,*::before,*::after{box-sizing:border-box;margin:0;padding:0}html,body{overflow-x:clip}body{font-family:'Inter',-apple-system,BlinkMacSystemFont,'Segoe UI',Roboto,sans-serif;line-height:1.6;color:#1f2937;-webkit-font-smoothing:antialiased}img{max-width:100%;height:auto;display:block}a{color:inherit}@media(max-width:767px){[style*=\"display: flex\"][style*=\"flex-direction: row\"],[style*=\"display:flex\"][style*=\"flex-direction:row\"]{flex-direction:column!important}}@media(min-width:992px){[data-hide-desktop]{display:none!important}}@media(min-width:768px) and (max-width:991px){[data-hide-tablet]{display:none!important}}@media(max-width:767px){[data-hide-mobile]{display:none!important}}:root{--wsb-primary:#3b82f6;--wsb-secondary:#8b5cf6;--wsb-accent:#10b981;--wsb-link:#3b82f6;--wsb-success:#10b981;--wsb-warning:#f59e0b;--wsb-error:#ef4444;--wsb-bg:#ffffff;--wsb-text:#1f2937;--wsb-muted:#6b7280;--wsb-border:#e5e7eb;--wsb-radius:8px;--wsb-heading-font:Comic Sans MS, cursive;--wsb-body-font:Inter, sans-serif;--wsb-button-font:Inter, sans-serif;--wsb-button-radius:8px}body{font-family:var(--wsb-body-font);color:var(--wsb-text);background:var(--wsb-bg)}h1,h2,h3,h4,h5,h6{font-family:var(--wsb-heading-font)}a{color:var(--wsb-link)}a:focus-visible,button:focus-visible,input:focus-visible,textarea:focus-visible,select:focus-visible,[tabindex]:focus-visible{outline:2px solid var(--wsb-accent);outline-offset:2px}@media(prefers-reduced-motion:reduce){*,*::before,*::after{animation-duration:.001ms!important;animation-iteration-count:1!important;transition-duration:.001ms!important;scroll-behavior:auto!important}}</style>\n</head>",
D_body_font_and_blank_tokens: "<!DOCTYPE html>\n<html lang=\"en\">\n<head>\n <meta charset=\"UTF-8\">\n <meta name=\"viewport\" content=\"width=device-width, initial-scale=1.0\">\n <title>Services</title>\n <meta property=\"og:title\" content=\"Services\">\n <meta property=\"og:type\" content=\"website\">\n <link rel=\"canonical\" href=\"https://example.com/services\">\n <link rel=\"preconnect\" href=\"https://fonts.googleapis.com\">\n <link rel=\"preconnect\" href=\"https://fonts.gstatic.com\" crossorigin>\n <link href=\"https://fonts.googleapis.com/css2?family=Inter:wght@300;400;500;600;700&family=Montserrat:wght@300;400;500;600;700&display=swap\" rel=\"stylesheet\">\n <style>*,*::before,*::after{box-sizing:border-box;margin:0;padding:0}html,body{overflow-x:clip}body{font-family:'Inter',-apple-system,BlinkMacSystemFont,'Segoe UI',Roboto,sans-serif;line-height:1.6;color:#1f2937;-webkit-font-smoothing:antialiased}img{max-width:100%;height:auto;display:block}a{color:inherit}@media(max-width:767px){[style*=\"display: flex\"][style*=\"flex-direction: row\"],[style*=\"display:flex\"][style*=\"flex-direction:row\"]{flex-direction:column!important}}@media(min-width:992px){[data-hide-desktop]{display:none!important}}@media(min-width:768px) and (max-width:991px){[data-hide-tablet]{display:none!important}}@media(max-width:767px){[data-hide-mobile]{display:none!important}}:root{--wsb-primary:#3b82f6;--wsb-secondary:#8b5cf6;--wsb-accent:#10b981;--wsb-link:#3b82f6;--wsb-success:#10b981;--wsb-warning:#f59e0b;--wsb-error:#ef4444;--wsb-bg:#ffffff;--wsb-text:#111111;--wsb-muted:#6b7280;--wsb-border:#e5e7eb;--wsb-radius:8px;--wsb-heading-font:Inter, sans-serif;--wsb-body-font:Inter, sans-serif;--wsb-button-font:Inter, sans-serif;--wsb-button-radius:8px}body{font-family:var(--wsb-body-font);color:var(--wsb-text);background:var(--wsb-bg)}h1,h2,h3,h4,h5,h6{font-family:var(--wsb-heading-font)}a{color:var(--wsb-link)}a:focus-visible,button:focus-visible,input:focus-visible,textarea:focus-visible,select:focus-visible,[tabindex]:focus-visible{outline:2px solid var(--wsb-accent);outline-offset:2px}@media(prefers-reduced-motion:reduce){*,*::before,*::after{animation-duration:.001ms!important;animation-iteration-count:1!important;transition-duration:.001ms!important;scroll-behavior:auto!important}}</style>\n</head>",
};
const PINNED_BODY: Record<string, string> = {
A_defaults: "<div></div>",
B_full: "<div data-animation=\"fade-in\" data-animation-delay=\"0.3\"></div>",
C_unknown_font: "<div></div>",
D_body_font_and_blank_tokens: "<div style=\"font-family:Montserrat, sans-serif\"></div>",
};
const root = (props: Record<string, unknown>) => JSON.stringify({
ROOT: {
type: { resolvedName: 'Container' }, isCanvas: true, props: { tag: 'div', style: {}, ...props },
displayName: 'Container', custom: {}, hidden: false, nodes: [], linkedNodes: {},
},
});
const U = 'https://example.com/';
const merged = (partial: Partial<SiteDesign>): SiteDesign => ({ ...DEFAULT_SITE_DESIGN, ...partial });
// Same inputs as the WHP script's $cases. `title` is the RESOLVED title
// (seo.metaTitle || page title), as the Preview callers pass it.
const CASES: Record<string, { state: string; opts: ExportOptions }> = {
A_defaults: {
state: root({}),
opts: { title: "Joe's Pizza & Grill", includeFonts: true, canonicalUrl: U },
},
B_full: {
state: root({ animation: 'fade-in', animationDelay: '0.3' }),
opts: {
title: 'About "Us"',
includeFonts: true,
headCode: '<script async src="https://www.googletagmanager.com/gtag/js?id=G-X"></script>\n<meta name="google-site-verification" content="abc123">',
description: "We're <the> best & \"only\" bakery",
ogTitle: "About Joe's",
ogImage: 'https://cdn.example.com/og.png',
twitterCard: 'summary',
noindex: true,
favicon: 'https://cdn.example.com/favicon.ico',
canonicalUrl: U + 'about',
design: merged({
headingFont: 'Playfair Display, serif', bodyFont: 'Roboto, sans-serif', buttonFont: 'Roboto, sans-serif',
primaryColor: '#ff0066', borderRadius: '12px', buttonRadius: '999px',
}),
},
},
C_unknown_font: {
state: root({}),
opts: {
title: 'Contact', includeFonts: true, description: 'Get in touch', canonicalUrl: U + 'contact',
design: merged({ headingFont: 'Comic Sans MS, cursive' }),
},
},
D_body_font_and_blank_tokens: {
state: root({ style: { fontFamily: 'Montserrat, sans-serif' } }),
opts: {
title: 'Services', includeFonts: true, description: ' ', ogTitle: ' ', canonicalUrl: U + 'services',
design: merged({ accentColor: '', textColor: ' #111111 ' }),
},
},
};
const headOf = (doc: string) => doc.slice(0, doc.indexOf('</head>') + '</head>'.length);
describe('Preview head === pinned published head (WHP test-site-builder-head-parity.php)', () => {
for (const [name, { state, opts }] of Object.entries(CASES)) {
test(`${name}: body input matches the backend case`, () => {
expect(exportBodyHtml(state).html).toBe(PINNED_BODY[name]);
});
test(`${name}: COMPLETE head is byte-identical`, () => {
expect(headOf(exportToHtml(state, opts).html)).toBe(PINNED_HEAD[name]);
});
}
});
describe('buildFontsLink per-element presets (item 14/15)', () => {
const inter = merged({});
test('a Montserrat element under an all-Inter design adds Montserrat to the subset', () => {
const link = buildFontsLink(inter, '<p style="font-family:Montserrat, sans-serif">x</p>');
expect(link).toContain('css2?family=Inter:wght@300;400;500;600;700&family=Montserrat:wght@300;400;500;600;700&display=swap');
});
test('fontScanHtml (composed header+body+footer) is what gets scanned, not just the body', () => {
const state = root({});
const footer = '<footer style="font-family:Merriweather, serif"></footer>';
const bodyOnly = exportToHtml(state, { title: 'T', design: inter }).html;
expect(bodyOnly).not.toContain('family=Merriweather');
const composed = exportToHtml(state, { title: 'T', design: inter, fontScanHtml: '<div></div>' + footer }).html;
expect(composed).toContain('family=Inter:wght@300;400;500;600;700&family=Merriweather:wght@300;400;700&display=swap');
});
test('an inherited Object.prototype name (constructor) is NOT a preset -> full link, like PHP isset', () => {
const link = buildFontsLink(merged({ headingFont: 'constructor, serif' }));
expect(link).toContain('family=Source+Code+Pro'); // the full 8-font link
expect(link).not.toContain('undefined');
});
});
+18 -4
View File
@@ -579,11 +579,22 @@ describe('export head unchanged by the TOKEN_VARS move (W0-tokens-core)', () =>
expect(html.split(from).length).toBe(2);
return html.replace(from, '@media(max-width:768px){[style*=');
};
// Head parity (backend-followups) added html,body{overflow-x:clip} to the reset.
const undoClip = (html: string) => {
const clip = 'html,body{overflow-x:clip}';
expect(html.split(clip).length).toBe(2);
return html.replace(clip, '');
};
test('default design document hash matches the pre-move baseline', async () => {
const html = undo767(exportToHtml(emptyRoot, { title: 'T' }).html);
expect(html.length).toBe(2164);
expect(await sha(html)).toBe('f50f2940798bda064c11e65d111004355034acd240e03cfd7e6fd5cfe98bf3b6');
// Re-baselined for the head-parity reset rule html,body{overflow-x:clip}.
expect(html.length).toBe(2190);
expect(await sha(html)).toBe('d180b901f8e06ea7d23e195f24cc7f37658ba3cc31d16ecb941a88bbe3ad917b');
// ...and that rule is the ONLY change: removing it gives the pre-move bytes.
const pre = undoClip(html);
expect(pre.length).toBe(2164);
expect(await sha(pre)).toBe('f50f2940798bda064c11e65d111004355034acd240e03cfd7e6fd5cfe98bf3b6');
});
test('custom + hostile design document hash matches the pre-move baseline', async () => {
@@ -594,8 +605,11 @@ describe('export head unchanged by the TOKEN_VARS move (W0-tokens-core)', () =>
accentColor: 'red}</style><script>',
};
const html = undo767(exportToHtml(emptyRoot, { title: 'T', design }).html);
expect(html.length).toBe(2452);
expect(await sha(html)).toBe('c561538b7ca64c8bac8b2cd02ff65053a866f7891ceeed97c956ea79153d1338');
expect(html.length).toBe(2478);
expect(await sha(html)).toBe('cb23ffb02d0e85085f31dce530a9c2bfc2328c63d9a8b9f67737d1b5624dae5d');
const pre = undoClip(html);
expect(pre.length).toBe(2452);
expect(await sha(pre)).toBe('c561538b7ca64c8bac8b2cd02ff65053a866f7891ceeed97c956ea79153d1338');
});
test('default :root token block is the exact pre-move string', () => {
+30 -9
View File
@@ -27,6 +27,10 @@ export interface ExportOptions {
/** Absolute page URL for `<link rel="canonical">` (#54): `https://<fqdn>/` for the
* landing page, `https://<fqdn>/<slug>` otherwise (no `.html`). Omitted => no tag. */
canonicalUrl?: string;
/** HTML scanned for per-element preset fonts (TextStylePanel fontFamily); defaults to
* the body. Preview passes the composed header+body+footer so it matches the
* published head, which scans all three (head parity). */
fontScanHtml?: string;
/** Full site design tokens -- drives the :root{} CSS-variable block (contract §2).
* Falls back to DEFAULT_SITE_DESIGN when omitted so every export (including legacy
* callers that don't pass it) still emits the token/base/a11y CSS -- see §7. */
@@ -190,7 +194,7 @@ function renderNode(
return { html: allChildrenHtml };
}
const CSS_RESET = `*,*::before,*::after{box-sizing:border-box;margin:0;padding:0}body{font-family:'Inter',-apple-system,BlinkMacSystemFont,'Segoe UI',Roboto,sans-serif;line-height:1.6;color:#1f2937;-webkit-font-smoothing:antialiased}img{max-width:100%;height:auto;display:block}a{color:inherit}`;
const CSS_RESET = `*,*::before,*::after{box-sizing:border-box;margin:0;padding:0}html,body{overflow-x:clip}body{font-family:'Inter',-apple-system,BlinkMacSystemFont,'Segoe UI',Roboto,sans-serif;line-height:1.6;color:#1f2937;-webkit-font-smoothing:antialiased}img{max-width:100%;height:auto;display:block}a{color:inherit}`;
const CSS_RESET_PRETTY = `*, *::before, *::after {
box-sizing: border-box;
@@ -198,6 +202,10 @@ const CSS_RESET_PRETTY = `*, *::before, *::after {
padding: 0;
}
html, body {
overflow-x: clip;
}
body {
font-family: 'Inter', -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;
line-height: 1.6;
@@ -250,14 +258,22 @@ function extractFontName(value: string | undefined): string {
* display=swap -- whenever any of those three fonts isn't one of the 8 known
* presets, so a font a component actually uses is never silently dropped.
*/
export function buildFontsLink(design?: SiteDesign): string {
export function buildFontsLink(design?: SiteDesign, scanHtml = ''): string {
if (!design) return GOOGLE_FONTS_LINK;
const families = [design.headingFont, design.bodyFont, design.buttonFont]
.map(extractFontName)
.filter((f, i, arr) => f !== '' && arr.indexOf(f) === i);
if (families.length === 0) return GOOGLE_FONTS_LINK;
const params = families.map((f) => GOOGLE_FONT_PARAMS[f]).filter((p): p is string => !!p);
if (params.length !== families.length) return GOOGLE_FONTS_LINK;
// Own-key check (not truthiness) so a name like `constructor` is "not a preset",
// exactly like the backend's isset(SB_GOOGLE_FONT_PARAMS[$f]). (Object.hasOwn
// semantics; spelled via hasOwnProperty.call because tsconfig lib is ES2020.)
if (families.some((f) => !Object.prototype.hasOwnProperty.call(GOOGLE_FONT_PARAMS, f))) return GOOGLE_FONTS_LINK;
// Per-element presets (TextStylePanel fontFamily) the page actually uses, in
// preset order -- mirrors sb_fonts_link() in WHP's SiteBuilderHead.php.
for (const f of Object.keys(GOOGLE_FONT_PARAMS)) {
if (!families.includes(f) && scanHtml.includes(f)) families.push(f);
}
const params = families.map((f) => GOOGLE_FONT_PARAMS[f]);
return `<link rel="preconnect" href="https://fonts.googleapis.com">
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
<link href="https://fonts.googleapis.com/css2?${params.join('&')}&display=swap" rel="stylesheet">`;
@@ -286,7 +302,12 @@ const A11Y_PERF_CSS = `a:focus-visible,button:focus-visible,input:focus-visible,
* element itself.
*/
export function buildTokenCss(design: SiteDesign): string {
const vars = TOKEN_VARS.map(([varName, key]) => `${varName}:${cssValue(design[key] as string)}`).join(';');
// A blank/whitespace token falls back to its default: an empty custom property
// would invalidate every var() that reads it (mirrors the backend sb_token_css).
const vars = TOKEN_VARS.map(([varName, key]) => {
const raw = String(design[key] ?? '').trim();
return `${varName}:${cssValue(raw !== '' ? raw : (DEFAULT_SITE_DESIGN[key] as string))}`;
}).join(';');
return `:root{${vars}}${TOKEN_BASE_CSS}${A11Y_PERF_CSS}`;
}
@@ -312,12 +333,12 @@ export function buildSeoMeta(options: ExportOptions, title: string): string {
lines.push(`<title>${escapeHtml(title)}</title>`);
const description = options.description || '';
const description = (options.description || '').trim();
if (description) {
lines.push(`<meta name="description" content="${escapeAttr(description)}">`);
}
const ogTitle = options.ogTitle || title;
const ogTitle = (options.ogTitle || '').trim() || title;
lines.push(`<meta property="og:title" content="${escapeAttr(ogTitle)}">`);
if (description) {
@@ -332,7 +353,7 @@ export function buildSeoMeta(options: ExportOptions, title: string): string {
lines.push('<meta property="og:type" content="website">');
if (ogImage || description) {
const card = options.twitterCard || (ogImage ? 'summary_large_image' : 'summary');
const card = (options.twitterCard || '').trim() || (ogImage ? 'summary_large_image' : 'summary');
lines.push(`<meta name="twitter:card" content="${escapeAttr(card)}">`);
}
@@ -428,7 +449,7 @@ function wrapInDocument(bodyHtml: string, options: ExportOptions): string {
// every project already has design defaults, so DEFAULT_SITE_DESIGN is the
// correct stand-in rather than skipping the block entirely.
const design = options.design || DEFAULT_SITE_DESIGN;
const fonts = options.includeFonts !== false ? `\n ${buildFontsLink(design)}` : '';
const fonts = options.includeFonts !== false ? `\n ${buildFontsLink(design, options.fontScanHtml ?? bodyHtml)}` : '';
const headCode = options.headCode ? `\n ${options.headCode}` : '';
const seoMeta = buildSeoMeta(options, title);
const tokenCss = buildTokenCss(design);