diff --git a/craft/src/editor/EmptyContainerAdd.test.tsx b/craft/src/editor/EmptyContainerAdd.test.tsx index 17cefd0..7c4f11f 100644 --- a/craft/src/editor/EmptyContainerAdd.test.tsx +++ b/craft/src/editor/EmptyContainerAdd.test.tsx @@ -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)[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); + }); +}); diff --git a/craft/src/editor/EmptyContainerAdd.tsx b/craft/src/editor/EmptyContainerAdd.tsx index ca88eb0..188c6ae 100644 --- a/craft/src/editor/EmptyContainerAdd.tsx +++ b/craft/src/editor/EmptyContainerAdd.tsx @@ -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 = { '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(null); const [open, setOpen] = useState(false); const rootRef = useRef(null); + const popRef = useRef(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 => !!b); @@ -108,7 +146,7 @@ export const EmptyContainerAdd: React.FC = () => { {open && ( -
+
{blocks.map((b) => ( + {regenerate.enabled && ( + + )}
{inInstance && } {!inInstance && <> @@ -336,8 +354,11 @@ export const GuidedStyles: React.FC<{ {/* UTILITY (Divider, Spacer) -- use generic but it works well for these */} {isUtility && } + {/* LOCAL BUSINESS (Opening Hours, Call Bar, Reviews, Booking) */} + {isBusiness && } + {/* 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 && ( )} } diff --git a/craft/src/panels/right/styles/ContainerStylePanel.tsx b/craft/src/panels/right/styles/ContainerStylePanel.tsx index a82987f..03fc37d 100644 --- a/craft/src/panels/right/styles/ContainerStylePanel.tsx +++ b/craft/src/panels/right/styles/ContainerStylePanel.tsx @@ -38,7 +38,7 @@ export const ContainerStylePanel: React.FC = ({ selectedId, nod const dot = (key: string | readonly string[]) => ; 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 = ({ selectedId, nod )} {/* Box model + border/effects + animation/visibility rollout */} - - + + ); diff --git a/craft/src/panels/right/styles/ImageStylePanel.tsx b/craft/src/panels/right/styles/ImageStylePanel.tsx index b2bd593..941e9f0 100644 --- a/craft/src/panels/right/styles/ImageStylePanel.tsx +++ b/craft/src/panels/right/styles/ImageStylePanel.tsx @@ -118,8 +118,8 @@ export const ImageStylePanel: React.FC = ({ selectedId, nodePro
{/* Box model + animation/visibility rollout */} - - + + ); diff --git a/craft/src/panels/right/styles/MediaStylePanel.tsx b/craft/src/panels/right/styles/MediaStylePanel.tsx index edc98ff..e470585 100644 --- a/craft/src/panels/right/styles/MediaStylePanel.tsx +++ b/craft/src/panels/right/styles/MediaStylePanel.tsx @@ -279,8 +279,8 @@ export const MediaStylePanel: React.FC = ({ selectedId, nodePro {/* Box model + animation/visibility rollout (Video/Gallery/Map/Slider all carry these props now, so it's safe to render unconditionally). */} - - + + ); diff --git a/craft/src/panels/right/styles/NavStylePanel.responsive.test.tsx b/craft/src/panels/right/styles/NavStylePanel.responsive.test.tsx new file mode 100644 index 0000000..26e9ae6 --- /dev/null +++ b/craft/src/panels/right/styles/NavStylePanel.responsive.test.tsx @@ -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, nodes: string[] = [], extra: Record = {}) => ({ + type: { resolvedName: type }, isCanvas: type === 'Container', props, displayName: type, + custom: {}, hidden: false, nodes, linkedNodes: {}, ...extra, +}); +const state = (extra: Record = {}) => 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 ; +}; +function mount(device: DeviceMode, initialState = state()) { + h = renderEditorHarness({ initialState, wrap: (t) => {t} }); + h.mountChild(); +} +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(); + }); +}); diff --git a/craft/src/panels/right/styles/NavStylePanel.tsx b/craft/src/panels/right/styles/NavStylePanel.tsx index 5209d75..da9fd9b 100644 --- a/craft/src/panels/right/styles/NavStylePanel.tsx +++ b/craft/src/panels/right/styles/NavStylePanel.tsx @@ -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 = ({ 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; + const dot = (key: string | readonly string[]) => ; return ( <> @@ -544,12 +549,12 @@ export const NavStylePanel: React.FC = ({ selectedId, nodeProps previous behavior exactly). */} 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)} /> 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} diff --git a/craft/src/panels/right/styles/boxModel.presets.test.tsx b/craft/src/panels/right/styles/boxModel.presets.test.tsx new file mode 100644 index 0000000..e6f2098 --- /dev/null +++ b/craft/src/panels/right/styles/boxModel.presets.test.tsx @@ -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, nodes: string[] = [], extra: Record = {}) => ({ + 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 }> = ({ id, Panel }) => { + const { nodeProps } = useEditor((s) => ({ nodeProps: s.nodes[id]?.data.props || {} })); + return ; +}; +function mount(id: string, Panel: React.FC) { + h = renderEditorHarness({ initialState: state(), wrap: (t) => {t} }); + h.mountChild(); + 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(); + } + }); +}); diff --git a/craft/src/panels/right/styles/containerBoxModel.tsx b/craft/src/panels/right/styles/containerBoxModel.tsx index 743d6c3..5504795 100644 --- a/craft/src/panels/right/styles/containerBoxModel.tsx +++ b/craft/src/panels/right/styles/containerBoxModel.tsx @@ -81,13 +81,23 @@ export interface BoxModelSectionProps { setPropStyles: (patch: Record, 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) => void; } +const sidesPatch = (kind: 'margin' | 'padding', v: SpacingSideValues): Record => { + const patch: Record = {}; + 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 = ({ style, setPropStyle, setPropStyles, labelAddon }) => { +export const BoxModelSection: React.FC = ({ 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 = ({ 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} /> 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} /> ); @@ -134,10 +146,12 @@ function opacityPercent(v: unknown): number { export interface BorderEffectsSectionProps { style: Record; 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 = ({ style, setPropStyle }) => ( +export const BorderEffectsSection: React.FC = ({ style, setPropStyle, setPropStyleDiscrete }) => ( = ({ styl />
Shadow - setPropStyle('boxShadow', v)} /> + (setPropStyleDiscrete ?? setPropStyle)('boxShadow', v)} />
diff --git a/craft/src/panels/right/styles/mediaBoxModel.tsx b/craft/src/panels/right/styles/mediaBoxModel.tsx index 8c19a6f..b3c6fa5 100644 --- a/craft/src/panels/right/styles/mediaBoxModel.tsx +++ b/craft/src/panels/right/styles/mediaBoxModel.tsx @@ -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) => void; } +const SIDES: SpacingSide[] = ['top', 'right', 'bottom', 'left']; + /** Margin + Padding, per-side, via the shared SpacingControl. */ -export const BoxModelSection: React.FC = ({ style, setPropStyle, labelAddon }) => { +export const BoxModelSection: React.FC = ({ 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) => { + const patch: Record = {}; + SIDES.forEach((s) => { patch[`${kind}${capitalize(s)}`] = all[s]; }); + setPropStylesDiscrete(patch); + } + : undefined); return ( @@ -58,11 +71,13 @@ export const BoxModelSection: React.FC = ({ 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')} /> Padding{labelAddon?.(PADDING_KEYS)}} value={{ top: style.paddingTop, right: style.paddingRight, bottom: style.paddingBottom, left: style.paddingLeft }} onChange={sideSetter('padding')} + onPreset={presetSetter('padding')} /> ); @@ -80,10 +95,12 @@ function opacityPercent(v: unknown): number { export interface BorderEffectsSectionProps { style: Record; 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 = ({ style, setPropStyle }) => ( +export const BorderEffectsSection: React.FC = ({ style, setPropStyle, setPropStyleDiscrete }) => ( = ({ styl />
Shadow - setPropStyle('boxShadow', v)} /> + (setPropStyleDiscrete ?? setPropStyle)('boxShadow', v)} />
diff --git a/craft/src/panels/right/styles/shared.tsx b/craft/src/panels/right/styles/shared.tsx index 620b85c..1e3b8d5 100644 --- a/craft/src/panels/right/styles/shared.tsx +++ b/craft/src/panels/right/styles/shared.tsx @@ -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) => 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) => void; presets?: { label: string; value: string }[]; units?: string[]; } -export const SpacingControl: React.FC = ({ label, value, onChange, onChangeAll, presets = SPACING_PRESETS, units = ['px', '%', 'em', 'rem'] }) => { +export const SpacingControl: React.FC = ({ 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 = ({ label, value, on
- + onPreset({ top: v, right: v, bottom: v, left: v }) : applyToAllSides} + /> {linked ? (
diff --git a/craft/src/panels/topbar/TemplateModal.copy.test.tsx b/craft/src/panels/topbar/TemplateModal.copy.test.tsx new file mode 100644 index 0000000..8d64c74 --- /dev/null +++ b/craft/src/panels/topbar/TemplateModal.copy.test.tsx @@ -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( {}} />); + 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."); + }); +}); diff --git a/craft/src/panels/topbar/TemplateModal.tsx b/craft/src/panels/topbar/TemplateModal.tsx index 49999d0..392fec5 100644 --- a/craft/src/panels/topbar/TemplateModal.tsx +++ b/craft/src/panels/topbar/TemplateModal.tsx @@ -354,9 +354,17 @@ export const TemplateModal: React.FC = ({ 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 + + Use the template's colors and fonts + {/* #44: templates reference site tokens, so unchecked = the site's own palette. */} + + {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."} + +
diff --git a/craft/src/panels/topbar/TopBar.test.tsx b/craft/src/panels/topbar/TopBar.test.tsx index bebd63b..b28cfa4 100644 --- a/craft/src/panels/topbar/TopBar.test.tsx +++ b/craft/src/panels/topbar/TopBar.test.tsx @@ -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('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 = {}) => 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('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('Home'); + } finally { + createUrl.mockRestore(); + openSpy.mockRestore(); + } + }); +}); diff --git a/craft/src/panels/topbar/TopBar.tsx b/craft/src/panels/topbar/TopBar.tsx index 783b55a..4fe93a3 100644 --- a/craft/src/panels/topbar/TopBar.tsx +++ b/craft/src/panels/topbar/TopBar.tsx @@ -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 = ({ 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 = ({ 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 = ({ showGuides, onToggleGuides }) => > + diff --git a/craft/src/styles/editor.css b/craft/src/styles/editor.css index ee91337..4d1996f 100644 --- a/craft/src/styles/editor.css +++ b/craft/src/styles/editor.css @@ -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 */ diff --git a/craft/src/ui/StockPhotoTab.test.tsx b/craft/src/ui/StockPhotoTab.test.tsx index ebec0b5..6e0bbff 100644 --- a/craft/src/ui/StockPhotoTab.test.tsx +++ b/craft/src/ui/StockPhotoTab.test.tsx @@ -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); + }); +}); diff --git a/craft/src/ui/StockPhotoTab.tsx b/craft/src/ui/StockPhotoTab.tsx index 424babd..4487b88 100644 --- a/craft/src/ui/StockPhotoTab.tsx +++ b/craft/src/ui/StockPhotoTab.tsx @@ -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 = ({ onPick }) => { const [selected, setSelected] = useState(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]); diff --git a/craft/src/utils/history-restore.test.ts b/craft/src/utils/history-restore.test.ts index 1b826c3..f6a9d59 100644 --- a/craft/src/utils/history-restore.test.ts +++ b/craft/src/utils/history-restore.test.ts @@ -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('Page A'); + }); }); diff --git a/craft/src/utils/history-restore.ts b/craft/src/utils/history-restore.ts index 64e620c..7ac8509 100644 --- a/craft/src/utils/history-restore.ts +++ b/craft/src/utils/history-restore.ts @@ -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`. diff --git a/craft/src/utils/html-export.head-parity.test.ts b/craft/src/utils/html-export.head-parity.test.ts new file mode 100644 index 0000000..75b0b0d --- /dev/null +++ b/craft/src/utils/html-export.head-parity.test.ts @@ -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 = { + A_defaults: "\n\n\n \n \n Joe's Pizza & Grill\n \n \n \n \n \n \n \n", + B_full: "\n\n\n \n \n \n About "Us"\n \n \n \n \n \n \n \n \n \n \n \n \n \n \n\n", + C_unknown_font: "\n\n\n \n \n Contact\n \n \n \n \n \n \n \n \n \n \n", + D_body_font_and_blank_tokens: "\n\n\n \n \n Services\n \n \n \n \n \n \n \n", +}; + +const PINNED_BODY: Record = { + A_defaults: "
", + B_full: "
", + C_unknown_font: "
", + D_body_font_and_blank_tokens: "
", +}; + + +const root = (props: Record) => 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 => ({ ...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 = { + 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: '\n', + description: "We're 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('') + ''.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, '

x

'); + 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 = '
'; + const bodyOnly = exportToHtml(state, { title: 'T', design: inter }).html; + expect(bodyOnly).not.toContain('family=Merriweather'); + const composed = exportToHtml(state, { title: 'T', design: inter, fontScanHtml: '
' + 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'); + }); +}); diff --git a/craft/src/utils/html-export.test.ts b/craft/src/utils/html-export.test.ts index bb842e2..d61bba0 100644 --- a/craft/src/utils/html-export.test.ts +++ b/craft/src/utils/html-export.test.ts @@ -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}