feat(site-builder): per-breakpoint style model, device context and responsive export

- utils/breakpoints.ts: single source for 767/768/991/992; head
  VISIBILITY/RESPONSIVE CSS built from it (flex-stacking rule moved from
  768 to the 767 mobile boundary).
- utils/responsive-style.ts: styleTablet/styleMobile partial overrides,
  cascade resolution, pure setters, scoped @media CSS per node.
- state/DeviceContext.tsx: real DeviceProvider (owns the one-time mobile
  forcing from EditorShell); TopBar, overflow menu, ReportIssueModal and
  Canvas read useDevice() instead of props.
- editor/RenderNode.tsx: resolves the effective style for the current
  device in one place and marks overridden nodes with data-wsb-overridden.
  Craft passes an opaque <DefaultRender/> to onRender, so the component
  element is rebuilt with the resolved style (cloneElement cannot work).
- utils/html-export.ts: hidden nodes skipped; overrides emitted as one
  body-level <style data-wsb-responsive> block + data-wsb-r scope attr;
  injectAttrs skips leading <style> elements.
- test-utils/editorHarness.tsx: optional onRender/wrap options.

Refs #45
Refs #37

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
shadowdaoandClaude Opus 5.5 committed 2026-10-07 11:28:44 -07:00
1 parent 2180f9a776
commit 5f99df23a7
24 files changed
+1006 -92

No files matched your search

+3 -3
View File
@@ -2,14 +2,13 @@ import React, { useMemo, useRef, useEffect } from 'react';
import { Frame, Element, useEditor } from '@craftjs/core';
import { Container } from '../components/layout/Container';
import { usePages } from '../state/PageContext';
import { DeviceMode } from '../types';
import { useDevice } from '../state/DeviceContext';
import { DEVICE_WIDTHS } from '../constants/presets';
import { exportBodyHtml } from '../utils/html-export';
import { useIsMobile } from '../hooks/useIsMobile';
import { useMobileChrome } from '../state/MobileChromeContext';
interface CanvasProps {
device: DeviceMode;
/** Item 10: when false, applies `.guides-off` to hide the dashed drop-target guides. */
showGuides: boolean;
}
@@ -130,7 +129,8 @@ export const EmptyCanvasHint: React.FC = () => {
);
};
export const Canvas: React.FC<CanvasProps> = ({ device, showGuides }) => {
export const Canvas: React.FC<CanvasProps> = ({ showGuides }) => {
const { device } = useDevice();
const width = DEVICE_WIDTHS[device];
const { isEditingHeader, isEditingFooter, headerPage, footerPage } = usePages();
+16 -22
View File
@@ -1,4 +1,4 @@
import React, { useState, useCallback, useEffect, useRef } from 'react';
import React, { useState, useCallback } from 'react';
import { useEditor } from '@craftjs/core';
import { TopBar } from '../panels/topbar/TopBar';
import { LeftPanel } from '../panels/left/LeftPanel';
@@ -14,7 +14,7 @@ import { MobileChromeProvider } from '../state/MobileChromeContext';
import { LayerFocusProvider } from '../panels/left/LayerFocusContext';
import { LoadGate } from './LoadGate';
import { DirtyTracker } from './DirtyTracker';
import { DeviceMode } from '../types';
import { DeviceProvider } from '../state/DeviceContext';
const SHOW_GUIDES_STORAGE_KEY = 'craft-show-guides';
@@ -27,28 +27,24 @@ function loadShowGuides(): boolean {
}
}
export const EditorShell: React.FC = () => {
/**
* DeviceProvider sits OUTSIDE the shell body so every hook the body calls
* (keyboard shortcuts, commands) sees the shared device, not the default.
*/
export const EditorShell: React.FC = () => (
<DeviceProvider>
<EditorShellBody />
</DeviceProvider>
);
const EditorShellBody: React.FC = () => {
const isMobile = useIsMobile();
const [device, setDevice] = useState<DeviceMode>('desktop');
// Phase A: default the canvas to the "mobile" preview width the first
// time we detect a mobile viewport, so the frame fits without the user
// having to reach for the device switcher (now tucked in TopBar's
// overflow menu on mobile). Only fires once, and only if the device is
// still at its initial default -- it must not fight a device the user
// has already picked (e.g. from the overflow menu) on a later re-render.
const mobileDeviceAppliedRef = useRef(false);
useEffect(() => {
if (isMobile && !mobileDeviceAppliedRef.current) {
mobileDeviceAppliedRef.current = true;
setDevice((current) => (current === 'desktop' ? 'mobile' : current));
}
}, [isMobile]);
// Item 10: canvas dashed "guide" outlines toggle -- default ON, persisted
// so the choice survives a reload. Lifted here (rather than owned by
// TopBar or Canvas alone) because the toggle button lives in TopBar but
// the `.guides-off` class it drives is applied to Canvas's
// `.canvas-device-frame`, mirroring how `device` is already lifted for
// the same reason.
// `.canvas-device-frame`. (The device mode itself is shared through
// DeviceContext -- see the EditorShell wrapper above.)
const [showGuides, setShowGuidesState] = useState<boolean>(loadShowGuides);
const setShowGuides = useCallback((next: boolean) => {
setShowGuidesState(next);
@@ -91,15 +87,13 @@ export const EditorShell: React.FC = () => {
<LoadGate />
<DirtyTracker />
<TopBar
device={device}
onDeviceChange={setDevice}
showGuides={showGuides}
onToggleGuides={() => setShowGuides(!showGuides)}
/>
<div className="editor-container">
{!isMobile && <LeftPanel />}
<div onContextMenu={handleContextMenu} style={{ flex: 1, display: 'flex', minWidth: 0 }}>
<Canvas device={device} showGuides={showGuides} />
<Canvas showGuides={showGuides} />
</div>
{!isMobile && <RightPanel />}
</div>
+1 -1
View File
@@ -72,7 +72,7 @@ function mount() {
<SiteDesignProvider>
<PageProvider>
<LoadGate />
<TopBar device="desktop" onDeviceChange={() => {}} showGuides onToggleGuides={() => {}} />
<TopBar showGuides onToggleGuides={() => {}} />
<SiteDesignPanel />
<GuardProbe />
</PageProvider>
@@ -71,7 +71,7 @@ function mount() {
<SiteDesignProvider>
<PageProvider>
<LoadGate />
<TopBar device="desktop" onDeviceChange={() => {}} showGuides onToggleGuides={() => {}} />
<TopBar showGuides onToggleGuides={() => {}} />
<SiteDesignPanel />
<GuardProbe />
</PageProvider>
@@ -0,0 +1,118 @@
import { describe, test, expect, afterEach } from 'vitest';
import React from 'react';
import { renderEditorHarness, EditorHarness } from '../test-utils/editorHarness';
import { DeviceProvider, useDevice } from '../state/DeviceContext';
import { RenderNode } from './RenderNode';
import type { DeviceMode } from '../types';
/**
* Real-editor tests (no @craftjs/core mock -- RenderNode.test.tsx mocks it
* for the badge tests, and vi.mock is file-wide): the actual RenderNode is
* installed as `<Editor onRender>` under a real DeviceProvider, so the
* per-device style resolution runs against Craft's real render pipeline.
*/
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 = (headingProps: Record<string, unknown>) =>
JSON.stringify({
ROOT: node('Container', { tag: 'div', style: {} }, ['sec1']),
sec1: node('Container', { tag: 'section', style: { padding: '20px' } }, ['head1'], { parent: 'ROOT' }),
head1: node('Heading', { text: 'Hello', level: 'h2', style: { fontSize: '40px' }, ...headingProps }, [], {
parent: 'sec1',
}),
});
let h: EditorHarness | null = null;
let setDevice: (d: DeviceMode) => void = () => {};
const DeviceGrab: React.FC = () => {
setDevice = useDevice().setDevice;
return null;
};
function mount(initialState: string, device: DeviceMode) {
h = renderEditorHarness({
initialState,
onRender: RenderNode,
wrap: (tree) => (
<DeviceProvider initialDevice={device}>
<DeviceGrab />
{tree}
</DeviceProvider>
),
});
return h;
}
const h2 = () => h!.container.querySelector('h2') as HTMLElement;
afterEach(() => {
h?.unmount();
h = null;
});
describe('RenderNode per-device style resolution (real editor)', () => {
test('mobile: a styleMobile override is applied to the rendered element', () => {
mount(state({ styleMobile: { textAlign: 'center' } }), 'mobile');
expect(h2().getAttribute('style')).toContain('text-align: center');
expect(h2().style.fontSize).toBe('40px');
});
test('desktop: the same node renders its desktop style only', () => {
mount(state({ styleMobile: { textAlign: 'center' } }), 'desktop');
expect(h2().getAttribute('style') || '').not.toContain('text-align');
expect(h2().hasAttribute('data-wsb-overridden')).toBe(false);
});
test('tablet: tablet override applies; mobile inherits it and layers its own', () => {
mount(state({ styleTablet: { fontSize: '32px' }, styleMobile: { textAlign: 'center' } }), 'tablet');
expect(h2().style.fontSize).toBe('32px');
expect(h2().style.textAlign).toBe('');
h!.act(() => setDevice('mobile'));
expect(h2().style.fontSize).toBe('32px');
expect(h2().style.textAlign).toBe('center');
h!.act(() => setDevice('desktop'));
expect(h2().style.fontSize).toBe('40px');
expect(h2().style.textAlign).toBe('');
});
test('data-wsb-overridden marks nodes overridden on the current device only', () => {
mount(state({ styleMobile: { textAlign: 'center' } }), 'mobile');
expect(h2().hasAttribute('data-wsb-overridden')).toBe(true);
h!.act(() => setDevice('tablet'));
expect(h2().hasAttribute('data-wsb-overridden')).toBe(false);
});
test('canvas children of an overridden container still render', () => {
const s = JSON.parse(state({}));
s.sec1.props.styleMobile = { padding: '4px' };
mount(JSON.stringify(s), 'mobile');
const section = h!.container.querySelector('section') as HTMLElement;
expect(section.style.padding).toBe('4px');
expect(section.querySelector('h2')).not.toBeNull();
expect(h2().textContent).toBe('Hello');
});
test('a prop edit through real actions.setProp is reflected on the mobile canvas', () => {
mount(state({}), 'mobile');
expect(h2().style.textAlign).toBe('');
h!.act(() => {
h!.actions.setProp('head1', (p: any) => {
p.styleMobile = { textAlign: 'right' };
});
});
expect(h2().style.textAlign).toBe('right');
// desktop style untouched in the serialized state
expect(JSON.parse(h!.getSerialized()).head1.props.style).toEqual({ fontSize: '40px' });
});
});
+76 -11
View File
@@ -1,6 +1,8 @@
import React, { useCallback, useEffect, useRef } from 'react';
import { createPortal } from 'react-dom';
import { useEditor, useNode } from '@craftjs/core';
import { NodeElement, useEditor, useNode } from '@craftjs/core';
import { useDevice } from '../state/DeviceContext';
import { hasAnyOverride, resolveStyleForDevice, overrideFor, type ResponsiveProps } from '../utils/responsive-style';
interface RenderNodeProps {
render: React.ReactElement;
@@ -34,17 +36,48 @@ interface RenderNodeProps {
* -- `actions.setNodeEvent` -- is stripped from the public `useEditor()`
* API at runtime.) editor.css matches both attributes for the same
* outline and suppresses both under `.guides-off`.
* - `data-wsb-overridden`: set while the current device mode (tablet/
* mobile, from DeviceContext) has per-breakpoint overrides for this node
* -- i.e. the canvas is showing something other than the desktop style.
*
* Per-breakpoint styles (#45/#37): when the device is not desktop and the
* node carries `styleTablet`/`styleMobile`, this is the ONE place the
* effective style is resolved (`resolveStyleForDevice`), so no component
* needs to know about devices. Craft hands `onRender` an opaque
* `<DefaultRender/>` element that reads the node's props itself, so the
* override cannot be applied with `cloneElement(render, {style})` -- instead
* the component element is rebuilt here exactly as Craft's DefaultRender
* does (`createElement(type, props, <NodeElement/> children)`) with the
* resolved `style`. Nodes without overrides, and every node on desktop,
* keep Craft's own `render` untouched.
*/
export const RenderNode: React.FC<RenderNodeProps> = ({ render }) => {
const { actions } = useEditor();
const { id, isSelected, dom, name, parent, isCanvas, isHovered } = useNode((node) => ({
isSelected: node.events.selected,
isHovered: node.events.hovered,
dom: node.dom,
name: (node.data.props?.aiName as string) || node.data.displayName,
parent: node.data.parent,
isCanvas: node.data.isCanvas,
}));
const { device } = useDevice();
const { id, isSelected, dom, name, parent, isCanvas, isHovered, overridden } = useNode((node) => {
// Keep the collector cheap: the node's type/props/children are only
// collected for the (rare) nodes that actually carry overrides.
const props = node.data.props as ResponsiveProps | undefined;
const overridden = hasAnyOverride(props)
? { type: node.data.type, props: node.data.props, childIds: node.data.nodes }
: null;
return {
isSelected: node.events.selected,
isHovered: node.events.hovered,
dom: node.dom,
name: (node.data.props?.aiName as string) || node.data.displayName,
parent: node.data.parent,
isCanvas: node.data.isCanvas,
overridden,
};
});
// Overrides that apply on the current device (mobile inherits tablet's).
const activeOverride =
device !== 'desktop' &&
!!overridden &&
(Object.keys(overrideFor(overridden.props, device)).length > 0 ||
(device === 'mobile' && Object.keys(overrideFor(overridden.props, 'tablet')).length > 0));
const badgeRef = useRef<HTMLDivElement>(null);
const active = isSelected && id !== 'ROOT' && !!dom;
@@ -86,11 +119,43 @@ export const RenderNode: React.FC<RenderNodeProps> = ({ render }) => {
}
}, [dom, isHovered]);
if (!active) return <>{render}</>;
useEffect(() => {
if (!dom) return;
if (activeOverride) {
dom.setAttribute('data-wsb-overridden', '');
} else {
dom.removeAttribute('data-wsb-overridden');
}
}, [dom, activeOverride]);
let output: React.ReactElement = render;
// String-typed (plain DOM) nodes go through Craft's own SimpleElement
// connector wrapper, which is not exported -- leave those untouched. Every
// resolver component is a React component, so this only skips raw tags.
if (device !== 'desktop' && overridden && typeof overridden.type !== 'string') {
const { type, props, childIds } = overridden;
const children =
childIds && childIds.length > 0 ? (
<>
{childIds.map((childId: string) => (
<NodeElement id={childId} key={childId} />
))}
</>
) : (
props.children
);
output = React.createElement(
type as React.ElementType,
{ ...props, style: resolveStyleForDevice(props as ResponsiveProps, device) },
children,
);
}
if (!active) return <>{output}</>;
return (
<>
{render}
{output}
{createPortal(
<div ref={badgeRef} className="component-indicator" style={{ position: 'fixed' }}>
<span>{name}</span>
+1 -1
View File
@@ -112,7 +112,7 @@ async function mount() {
<LoadGate />
<DirtyTracker />
<Probe />
<TopBar device="desktop" onDeviceChange={() => {}} showGuides onToggleGuides={() => {}} />
<TopBar showGuides onToggleGuides={() => {}} />
</PageProvider>
</SiteDesignProvider>
</SaveGuardProvider>
+1 -1
View File
@@ -96,7 +96,7 @@ async function mount() {
<LoadGate />
<DirtyTracker />
<Probe />
<TopBar device="desktop" onDeviceChange={() => {}} showGuides onToggleGuides={() => {}} />
<TopBar showGuides onToggleGuides={() => {}} />
</PageProvider>
</SiteDesignProvider>
</SaveGuardProvider>
@@ -46,7 +46,7 @@ function render(open = true) {
document.body.appendChild(container);
act(() => {
root = createRoot(container);
root.render(<ReportIssueModal open={open} onClose={vi.fn()} device="desktop" />);
root.render(<ReportIssueModal open={open} onClose={vi.fn()} />);
});
}
+3 -2
View File
@@ -7,11 +7,11 @@ import { usePages } from '../../state/PageContext';
import { buildReportPayload, MAX_DESCRIPTION_CHARS, type ReportCategory } from '../../utils/report-payload';
import { getRecentConsoleErrors } from '../../utils/console-buffer';
import { editorBuild } from '../../utils/build-stamp';
import { useDevice } from '../../state/DeviceContext';
export interface ReportIssueModalProps {
open: boolean;
onClose: () => void;
device: string;
}
const CATEGORIES: { value: ReportCategory; label: string }[] = [
@@ -25,7 +25,8 @@ const CATEGORIES: { value: ReportCategory; label: string }[] = [
* losing text is a real possibility, not on every keystroke from zero. */
const COUNTER_THRESHOLD = MAX_DESCRIPTION_CHARS - 500;
export const ReportIssueModal: React.FC<ReportIssueModalProps> = ({ open, onClose, device }) => {
export const ReportIssueModal: React.FC<ReportIssueModalProps> = ({ open, onClose }) => {
const { device } = useDevice();
const { whpConfig } = useEditorConfig();
const { activePageId, pages } = usePages();
// Guarded with optional chaining: some hosts around this component (e.g.
@@ -69,7 +69,7 @@ function mount() {
root = createRoot(container);
root.render(
<EditorConfigProvider config={whpConfig}>
<TopBar device="desktop" onDeviceChange={() => {}} showGuides onToggleGuides={() => {}} />
<TopBar showGuides onToggleGuides={() => {}} />
</EditorConfigProvider>,
);
});
+28 -1
View File
@@ -84,7 +84,7 @@ function render() {
root = createRoot(container);
root.render(
<EditorConfigProvider config={whpConfig}>
<TopBar device="desktop" onDeviceChange={() => {}} showGuides={false} onToggleGuides={() => {}} />
<TopBar showGuides={false} onToggleGuides={() => {}} />
</EditorConfigProvider>,
);
});
@@ -187,3 +187,30 @@ describe('TopBar publish-warnings wiring', () => {
expect(container.querySelector('[data-testid="publish-warnings"]')).toBeNull();
});
});
describe('TopBar device switcher reads/writes DeviceContext (W0-breakpoints)', () => {
test('clicking Tablet updates the shared device for every consumer', async () => {
const { DeviceProvider, useDevice } = await import('../../state/DeviceContext');
const Probe: React.FC = () => <span data-testid="probe">{useDevice().device}</span>;
container = document.createElement('div');
document.body.appendChild(container);
act(() => {
root = createRoot(container);
root.render(
<EditorConfigProvider config={whpConfig}>
<DeviceProvider>
<TopBar showGuides={false} onToggleGuides={() => {}} />
<Probe />
</DeviceProvider>
</EditorConfigProvider>,
);
});
const tablet = container.querySelector<HTMLButtonElement>('.device-btn[aria-label="Tablet"]')!;
expect(tablet.getAttribute('aria-pressed')).toBe('false');
act(() => {
tablet.dispatchEvent(new MouseEvent('click', { bubbles: true }));
});
expect(container.querySelector('[data-testid="probe"]')!.textContent).toBe('tablet');
expect(tablet.getAttribute('aria-pressed')).toBe('true');
});
});
+5 -7
View File
@@ -7,6 +7,7 @@ import { useSiteDesign } from '../../state/SiteDesignContext';
import { useIsMobile } from '../../hooks/useIsMobile';
import { useMobileChrome } from '../../state/MobileChromeContext';
import { DeviceMode } from '../../types';
import { useDevice } from '../../state/DeviceContext';
import { TemplateModal } from './TemplateModal';
import { HeadCodeModal } from './HeadCodeModal';
import { ReportIssueModal } from './ReportIssueModal';
@@ -21,14 +22,13 @@ import { SitesmithButton } from '../sitesmith/SitesmithButton';
import { useSitesmithModal } from '../../state/SitesmithContext';
interface TopBarProps {
device: DeviceMode;
onDeviceChange: (device: DeviceMode) => void;
/** Item 10: canvas dashed guide outlines, default ON. */
showGuides: boolean;
onToggleGuides: () => void;
}
export const TopBar: React.FC<TopBarProps> = ({ device, onDeviceChange, showGuides, onToggleGuides }) => {
export const TopBar: React.FC<TopBarProps> = ({ showGuides, onToggleGuides }) => {
const { device, setDevice: onDeviceChange } = useDevice();
const { whpConfig, isWHP } = useEditorConfig();
const { actions, query, canUndo, canRedo } = useEditor((_state, query) => ({
canUndo: query.history.canUndo(),
@@ -377,8 +377,6 @@ export const TopBar: React.FC<TopBarProps> = ({ device, onDeviceChange, showGuid
<TopBarOverflowMenu
open={overflowOpen}
onClose={() => setOverflowOpen(false)}
device={device}
onDeviceChange={onDeviceChange}
showGuides={showGuides}
onToggleGuides={onToggleGuides}
onOpenTemplates={() => setTemplateModalOpen(true)}
@@ -391,7 +389,7 @@ export const TopBar: React.FC<TopBarProps> = ({ device, onDeviceChange, showGuid
<TemplateModal open={templateModalOpen} onClose={() => setTemplateModalOpen(false)} />
<HeadCodeModal open={headCodeModalOpen} onClose={() => setHeadCodeModalOpen(false)} />
<ReportIssueModal open={reportOpen} onClose={() => setReportOpen(false)} device={device} />
<ReportIssueModal open={reportOpen} onClose={() => setReportOpen(false)} />
</nav>
);
}
@@ -516,7 +514,7 @@ export const TopBar: React.FC<TopBarProps> = ({ device, onDeviceChange, showGuid
</div>
<TemplateModal open={templateModalOpen} onClose={() => setTemplateModalOpen(false)} />
<HeadCodeModal open={headCodeModalOpen} onClose={() => setHeadCodeModalOpen(false)} />
<ReportIssueModal open={reportOpen} onClose={() => setReportOpen(false)} device={device} />
<ReportIssueModal open={reportOpen} onClose={() => setReportOpen(false)} />
</nav>
);
};
@@ -1,11 +1,10 @@
import React, { useEffect, useRef } from 'react';
import { DeviceMode } from '../../types';
import { useDevice } from '../../state/DeviceContext';
export interface TopBarOverflowMenuProps {
open: boolean;
onClose: () => void;
device: DeviceMode;
onDeviceChange: (device: DeviceMode) => void;
showGuides: boolean;
onToggleGuides: () => void;
onOpenTemplates: () => void;
@@ -30,8 +29,6 @@ export interface TopBarOverflowMenuProps {
export const TopBarOverflowMenu: React.FC<TopBarOverflowMenuProps> = ({
open,
onClose,
device,
onDeviceChange,
showGuides,
onToggleGuides,
onOpenTemplates,
@@ -41,6 +38,7 @@ export const TopBarOverflowMenu: React.FC<TopBarOverflowMenuProps> = ({
sitesmithNode,
editingDisabled,
}) => {
const { device, setDevice: onDeviceChange } = useDevice();
const menuRef = useRef<HTMLDivElement>(null);
useEffect(() => {
+77
View File
@@ -0,0 +1,77 @@
import { describe, test, expect, afterEach, vi } from 'vitest';
import React from 'react';
import { createRoot, Root } from 'react-dom/client';
import { act } from 'react-dom/test-utils';
import { DeviceProvider, useDevice } from './DeviceContext';
import type { DeviceMode } from '../types';
let container: HTMLDivElement;
let root: Root;
let api: ReturnType<typeof useDevice>;
const Probe: React.FC = () => {
api = useDevice();
return <span data-testid="device">{api.device}</span>;
};
function mount(ui: React.ReactElement) {
container = document.createElement('div');
document.body.appendChild(container);
act(() => {
root = createRoot(container);
root.render(ui);
});
}
const shown = () => container.querySelector('[data-testid="device"]')!.textContent;
function stubMatchMedia(matches: boolean) {
vi.stubGlobal('matchMedia', (q: string) => ({
matches, media: q, onchange: null,
addListener() {}, removeListener() {}, addEventListener() {}, removeEventListener() {}, dispatchEvent: () => false,
}));
}
afterEach(() => {
act(() => root.unmount());
container.remove();
vi.unstubAllGlobals();
});
describe('DeviceContext', () => {
test('without a provider: desktop + a no-op setter', () => {
mount(<Probe />);
expect(shown()).toBe('desktop');
act(() => api.setDevice('mobile'));
expect(shown()).toBe('desktop');
});
test('provider defaults to desktop on a desktop viewport', () => {
stubMatchMedia(false);
mount(<DeviceProvider><Probe /></DeviceProvider>);
expect(shown()).toBe('desktop');
});
test('setDevice propagates to every consumer', () => {
stubMatchMedia(false);
const Other: React.FC = () => <i data-testid="other">{useDevice().device}</i>;
mount(<DeviceProvider><Probe /><Other /></DeviceProvider>);
act(() => api.setDevice('tablet'));
expect(shown()).toBe('tablet');
expect(container.querySelector('[data-testid="other"]')!.textContent).toBe('tablet');
});
test('a mobile viewport forces the mobile canvas once, then respects the user choice', () => {
stubMatchMedia(true);
mount(<DeviceProvider><Probe /></DeviceProvider>);
expect(shown()).toBe('mobile');
act(() => api.setDevice('desktop'));
expect(shown()).toBe('desktop');
});
test('initialDevice seeds the state', () => {
stubMatchMedia(false);
mount(<DeviceProvider initialDevice={'tablet' as DeviceMode}><Probe /></DeviceProvider>);
expect(shown()).toBe('tablet');
});
});
+38 -9
View File
@@ -1,22 +1,51 @@
import React, { createContext, useContext, useMemo, useState } from 'react';
import React, { createContext, useContext, useEffect, useMemo, useRef, useState } from 'react';
import type { DeviceMode } from '../types';
import { useIsMobile } from '../hooks/useIsMobile';
/**
* MINIMAL STUB added by W0-commands so the command registry can code against
* the `{device, setDevice}` interface. W0-breakpoints owns the real
* implementation of this file; on merge, take theirs (the API is the same).
* The editor's current device mode (desktop / tablet / mobile canvas).
*
* Shared state rather than props threaded from EditorShell: the TopBar
* switcher, the Canvas frame width, RenderNode's per-device style resolution
* (#45) and the command registry all read the same value.
*
* Without a provider (unit tests, standalone component mounts) the default
* is `desktop` with a no-op setter, so consumers never need a guard.
*/
interface DeviceContextValue {
export interface DeviceContextValue {
device: DeviceMode;
setDevice: (d: DeviceMode) => void;
}
const DeviceCtx = createContext<DeviceContextValue>({ device: 'desktop', setDevice: () => {} });
const DEFAULT_VALUE: DeviceContextValue = { device: 'desktop', setDevice: () => {} };
export const useDevice = () => useContext(DeviceCtx);
const DeviceCtx = createContext<DeviceContextValue>(DEFAULT_VALUE);
export const useDevice = (): DeviceContextValue => useContext(DeviceCtx);
interface DeviceProviderProps {
children: React.ReactNode;
/** Starting device (tests). Defaults to `desktop`. */
initialDevice?: DeviceMode;
}
export const DeviceProvider: React.FC<DeviceProviderProps> = ({ children, initialDevice = 'desktop' }) => {
const [device, setDevice] = useState<DeviceMode>(initialDevice);
const isMobile = useIsMobile();
// Default the canvas to the "mobile" preview width the first time a mobile
// viewport is detected, so the frame fits without the user reaching for the
// device switcher (tucked in TopBar's overflow menu on mobile). Fires once,
// and only while the device is still `desktop` -- it must not fight a device
// the user has already picked on a later re-render.
const mobileDeviceAppliedRef = useRef(false);
useEffect(() => {
if (isMobile && !mobileDeviceAppliedRef.current) {
mobileDeviceAppliedRef.current = true;
setDevice((current) => (current === 'desktop' ? 'mobile' : current));
}
}, [isMobile]);
export const DeviceProvider: React.FC<{ children: React.ReactNode }> = ({ children }) => {
const [device, setDevice] = useState<DeviceMode>('desktop');
const value = useMemo(() => ({ device, setDevice }), [device]);
return <DeviceCtx.Provider value={value}>{children}</DeviceCtx.Provider>;
};
+20 -7
View File
@@ -123,6 +123,12 @@ export interface EditorHarnessOptions {
* once `initialState` is given (deserialize replaces ROOT anyway); only
* affects the very first (pre-deserialize) paint. */
frameTag?: 'div' | 'header' | 'footer';
/** Craft.js `<Editor onRender>` override (e.g. the real `RenderNode`).
* Omitted → Craft's default (no wrapper), as before. */
onRender?: React.ComponentType<{ render: React.ReactElement }>;
/** Wrap the whole `<Editor>` tree, e.g. in a context provider
* (`(tree) => <DeviceProvider>{tree}</DeviceProvider>`). */
wrap?: (tree: React.ReactNode) => React.ReactNode;
}
export interface EditorHarness {
@@ -168,15 +174,22 @@ export function renderEditorHarness(opts: EditorHarnessOptions = {}): EditorHarn
const frameTag = opts.frameTag ?? 'div';
function paint() {
const tree = (
<Editor
resolver={componentResolver}
enabled={true}
{...(opts.onRender ? { onRender: opts.onRender } : {})}
>
<Frame>
<Element is={Container} canvas tag={frameTag} style={{}} />
</Frame>
<Capture apiRef={apiRef} />
{extraChild}
</Editor>
);
root.render(
<EditorConfigProvider config={null}>
<Editor resolver={componentResolver} enabled={true}>
<Frame>
<Element is={Container} canvas tag={frameTag} style={{}} />
</Frame>
<Capture apiRef={apiRef} />
{extraChild}
</Editor>
{opts.wrap ? opts.wrap(tree) : tree}
</EditorConfigProvider>,
);
}
@@ -64,7 +64,7 @@ describe('empty-save banner Undo', () => {
<SiteDesignProvider>
<PageProvider>
<LoadGate />
<TopBar device="desktop" onDeviceChange={() => {}} showGuides onToggleGuides={() => {}} />
<TopBar showGuides onToggleGuides={() => {}} />
</PageProvider>
</SiteDesignProvider>
</SaveGuardProvider>
+8
View File
@@ -54,3 +54,11 @@ export interface StyleProps {
}
export type DeviceMode = 'desktop' | 'tablet' | 'mobile';
/** Per-breakpoint PARTIAL style overrides layered over `style` (desktop):
* tablet = style + styleTablet; mobile = style + styleTablet + styleMobile.
* See utils/responsive-style.ts. */
export interface ResponsiveStyleProps {
styleTablet?: Partial<CSSProperties>;
styleMobile?: Partial<CSSProperties>;
}
+41
View File
@@ -0,0 +1,41 @@
import type { DeviceMode } from '../types';
/**
* Single source of truth for the builder's responsive breakpoints.
*
* mobile ≤ 767px
* tablet 768px – 991px
* desktop ≥ 992px
*
* Used by the exporter (visibility/flex-stacking CSS, per-node responsive
* override blocks) and the editor (device-mode style resolution). The
* backend `generateCompiledHTML` emits the same numbers in its head CSS --
* keep the two in step (head parity).
*/
export const BREAKPOINTS = {
mobileMax: 767,
tabletMin: 768,
tabletMax: 991,
desktopMin: 992,
} as const;
/** Cascade order: desktop is the base, tablet overrides it, mobile overrides tablet. */
export const DEVICE_ORDER: readonly DeviceMode[] = ['desktop', 'tablet', 'mobile'];
/**
* The media query an override for `device` is emitted under. Tablet uses
* `max-width:991px` (not a closed 768–991 range) on purpose: mobile inherits
* tablet overrides, mirroring `resolveStyleForDevice`'s cascade.
*/
export function mediaQueryFor(device: 'tablet' | 'mobile'): string {
return device === 'tablet'
? `(max-width:${BREAKPOINTS.tabletMax}px)`
: `(max-width:${BREAKPOINTS.mobileMax}px)`;
}
/** Which device bucket a viewport width falls in. */
export function deviceForWidth(px: number): DeviceMode {
if (px <= BREAKPOINTS.mobileMax) return 'mobile';
if (px <= BREAKPOINTS.tabletMax) return 'tablet';
return 'desktop';
}
+136 -3
View File
@@ -1,5 +1,13 @@
import { describe, test, expect } from 'vitest';
import { exportBodyHtml, exportToHtml, buildAnimationScript, ExportOptions } from './html-export';
import {
exportBodyHtml,
exportToHtml,
buildAnimationScript,
ExportOptions,
injectAttrs,
RESPONSIVE_CSS_MINIFIED,
VISIBILITY_CSS_MINIFIED,
} from './html-export';
import { DEFAULT_SITE_DESIGN, SiteDesign } from '../state/SiteDesignContext';
/**
@@ -562,9 +570,17 @@ describe('export head unchanged by the TOKEN_VARS move (W0-tokens-core)', () =>
displayName: 'Container', custom: {}, hidden: false, nodes: [], linkedNodes: {},
},
});
// W0-breakpoints deliberately moved the flex-stacking rule from 768 to the
// 767 mobile boundary. Undo exactly that one change before hashing so these
// baselines still prove nothing ELSE in the head moved.
const undo767 = (html: string) => {
const from = '@media(max-width:767px){[style*=';
expect(html.split(from).length).toBe(2);
return html.replace(from, '@media(max-width:768px){[style*=');
};
test('default design document hash matches the pre-move baseline', async () => {
const html = exportToHtml(emptyRoot, { title: 'T' }).html;
const html = undo767(exportToHtml(emptyRoot, { title: 'T' }).html);
expect(html.length).toBe(2164);
expect(await sha(html)).toBe('f50f2940798bda064c11e65d111004355034acd240e03cfd7e6fd5cfe98bf3b6');
});
@@ -576,7 +592,7 @@ describe('export head unchanged by the TOKEN_VARS move (W0-tokens-core)', () =>
headingFont: '"Playfair Display", serif',
accentColor: 'red}</style><script>',
};
const html = exportToHtml(emptyRoot, { title: 'T', design }).html;
const html = undo767(exportToHtml(emptyRoot, { title: 'T', design }).html);
expect(html.length).toBe(2452);
expect(await sha(html)).toBe('c561538b7ca64c8bac8b2cd02ff65053a866f7891ceeed97c956ea79153d1338');
});
@@ -588,3 +604,120 @@ describe('export head unchanged by the TOKEN_VARS move (W0-tokens-core)', () =>
);
});
});
describe('per-breakpoint overrides + hidden nodes (W0-breakpoints)', () => {
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 makeState = (headingProps: Record<string, unknown>, headingExtra: Record<string, unknown> = {}) =>
JSON.stringify({
ROOT: node('Container', { tag: 'div', style: {} }, ['sec1']),
sec1: node('Container', { tag: 'section', style: { padding: '20px' } }, ['head1'], { parent: 'ROOT' }),
head1: node('Heading', { text: 'Hello', level: 'h2', style: { fontSize: '40px' }, ...headingProps }, [], {
parent: 'sec1',
...headingExtra,
}),
});
test('a mobile override exports one scoped body <style> block + data-wsb-r on the <h2>', () => {
const { html, css } = exportBodyHtml(makeState({ styleMobile: { fontSize: '24px' } }));
expect(html.startsWith(
'<style data-wsb-responsive>@media (max-width:767px){[data-wsb-r="head1"]{font-size:24px!important}}</style>',
)).toBe(true);
expect(html).toContain('<h2 style="font-size:40px" data-wsb-r="head1">Hello</h2>');
expect(css).toBe('');
});
test('the full document carries the block in the BODY, not the head', () => {
const { html } = exportToHtml(makeState({ styleTablet: { textAlign: 'center' } }));
const head = html.slice(0, html.indexOf('</head>'));
const body = html.slice(html.indexOf('<body>'));
expect(head).not.toContain('data-wsb');
expect(body).toContain(
'<style data-wsb-responsive>@media (max-width:991px){[data-wsb-r="head1"]{text-align:center!important}}</style>',
);
});
test('a state without overrides exports byte-identically to the pre-change exporter', () => {
// Captured from the exporter BEFORE this task.
const { html } = exportBodyHtml(makeState({}));
expect(html).toBe('<div><section style="padding:20px"><h2 style="font-size:40px">Hello</h2></section></div>');
expect(exportBodyHtml(makeState({ styleTablet: {}, styleMobile: { fontSize: '' } })).html).toBe(html);
});
test('a node with hidden:true is omitted together with its subtree', () => {
const state = JSON.parse(makeState({ styleMobile: { fontSize: '24px' } }));
state.sec1.hidden = true;
const { html } = exportBodyHtml(JSON.stringify(state));
expect(html).toBe('<div></div>');
// ...and its overrides do not leak a style block either.
expect(html).not.toContain('data-wsb');
});
test('a hidden leaf is omitted, siblings survive', () => {
const { html } = exportBodyHtml(makeState({}, { hidden: true }));
expect(html).toBe('<div><section style="padding:20px"></section></div>');
});
test('RESPONSIVE_CSS_MINIFIED stacks flex rows at the 767 mobile boundary', () => {
expect(RESPONSIVE_CSS_MINIFIED).toContain('@media(max-width:767px){');
expect(RESPONSIVE_CSS_MINIFIED).not.toContain('768');
expect(VISIBILITY_CSS_MINIFIED).toBe(
'@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}}',
);
const pretty = exportToHtml(makeState({}), { minifyCss: false }).html;
expect(pretty).toContain('@media (max-width: 767px) {\n [style*="display: flex"]');
expect(pretty).not.toContain('max-width: 768px');
});
});
describe('injectAttrs skips leading <style> blocks (W0-breakpoints)', () => {
test('attrs land on the element after a leading <style>', () => {
expect(injectAttrs('<style>.b:hover{color:red}</style><a href="#">x</a>', ' data-x')).toBe(
'<style>.b:hover{color:red}</style><a href="#" data-x>x</a>',
);
});
test('multiple leading style blocks are skipped', () => {
expect(injectAttrs('<style>a{}</style><style media="x">b{}</style><div>y</div>', ' data-x')).toBe(
'<style>a{}</style><style media="x">b{}</style><div data-x>y</div>',
);
});
test('void tag after a style block stays well-formed', () => {
expect(injectAttrs('<style>i{}</style><img src="a" />', ' data-x')).toBe('<style>i{}</style><img src="a" data-x/>');
});
test('plain html unchanged in behaviour', () => {
expect(injectAttrs('<div>x</div>', ' data-x')).toBe('<div data-x>x</div>');
expect(injectAttrs('<img src="a" />', ' data-x')).toBe('<img src="a" data-x/>');
expect(injectAttrs('<div>x</div>', '')).toBe('<div>x</div>');
});
test('ButtonLink with hover + override: data-wsb-r lands on the <a>, not the <style>', () => {
const state = JSON.stringify({
ROOT: {
type: { resolvedName: 'Container' }, isCanvas: true, props: { tag: 'div', style: {} },
displayName: 'Container', custom: {}, hidden: false, nodes: ['btn1'], linkedNodes: {},
},
btn1: {
type: { resolvedName: 'ButtonLink' }, isCanvas: false, parent: 'ROOT',
props: { text: 'Go', href: '#', hoverBg: '#000', style: {}, styleMobile: { width: '100%' } },
displayName: 'ButtonLink', custom: {}, hidden: false, nodes: [], linkedNodes: {},
},
});
const { html } = exportBodyHtml(state);
expect(html).toMatch(/<a href="#"[^>]* data-wsb-r="btn1">Go<\/a>/);
expect(html).not.toContain('<style data-wsb-r=');
expect(html).toContain('</style><a href="#"');
});
});
+67 -16
View File
@@ -4,6 +4,8 @@ import { escapeHtml, escapeAttr, cssValue, safeImageUrl } from './escape';
import { sanitizeContainerTag } from '../components/layout/Container';
import { SiteDesign, DEFAULT_SITE_DESIGN } from '../state/SiteDesignContext';
import { TOKEN_VARS } from './tokens';
import { BREAKPOINTS } from './breakpoints';
import { responsiveCssForNode, responsiveScopeId } from './responsive-style';
export interface ExportOptions {
title?: string;
@@ -60,9 +62,15 @@ function buildDataAttrs(props: Record<string, any>): string {
* self-close slash, outside the tag). Detect that trailing `/` and insert
* the attrs before it instead, yielding well-formed `<img ... data-animation="..."/>`.
* Non-void tags (no trailing `/`) are unaffected.
*
* Leading `<style>…</style>` elements (components such as ButtonLink and
* ColumnLayout emit a scoped style block before their element) are skipped,
* so the attrs land on the component's real element, not on the `<style>`.
*/
function injectAttrs(html: string, attrs: string): string {
export function injectAttrs(html: string, attrs: string): string {
if (!attrs) return html;
const prefixLen = leadingStyleLength(html);
if (prefixLen > 0) return html.slice(0, prefixLen) + injectAttrs(html.slice(prefixLen), attrs);
// Find the first > of the opening tag and inject before it
const idx = html.indexOf('>');
if (idx === -1) return html;
@@ -79,12 +87,33 @@ function injectAttrs(html: string, attrs: string): string {
return html.slice(0, idx) + attrs + html.slice(idx);
}
/** Length of any run of `<style …>…</style>` elements (plus whitespace) at the
* very start of `html`; 0 when it does not start with one. Only skips when
* something follows the style block(s) -- a bare style block is left alone. */
function leadingStyleLength(html: string): number {
const re = /^\s*<style\b[^>]*>[\s\S]*?<\/style>\s*/i;
let len = 0;
for (;;) {
const m = re.exec(html.slice(len));
if (!m) break;
len += m[0].length;
}
return len < html.length ? len : 0;
}
/**
* Recursively render a Craft.js node tree to HTML.
*
* `cssAcc` collects each node's scoped per-breakpoint override CSS
* (`responsiveCssForNode`; children before parents -- each rule targets its own
* element, so order is irrelevant); the exporters emit it as one
* body-level `<style data-wsb-responsive>` block.
*/
function renderNode(nodes: Record<string, any>, nodeId: string): { html: string } {
function renderNode(nodes: Record<string, any>, nodeId: string, cssAcc: string[] = []): { html: string } {
const node = nodes[nodeId];
if (!node) return { html: '' };
// Hidden nodes (and their whole subtree) are left out of the export.
if (node.hidden === true) return { html: '' };
const typeName = node.type?.resolvedName || node.type;
const props = node.props || {};
@@ -95,20 +124,26 @@ function renderNode(nodes: Record<string, any>, nodeId: string): { html: string
// Render direct child nodes
let childrenHtml = childNodeIds
.map((childId: string) => renderNode(nodes, childId).html)
.map((childId: string) => renderNode(nodes, childId, cssAcc).html)
.join('');
// Render linked nodes (e.g., Section's inner container)
const linkedHtml = Object.values(linkedNodes)
.map((linkedId: string) => renderNode(nodes, linkedId).html)
.map((linkedId: string) => renderNode(nodes, linkedId, cssAcc).html)
.join('');
// For linked nodes, the component's toHtml should handle them via childrenHtml
// We prioritize linked nodes if direct children are empty
const allChildrenHtml = childrenHtml + linkedHtml;
// Build data attributes for responsive visibility and animations
const dataAttrs = buildDataAttrs(props);
// Build data attributes for responsive visibility and animations, plus the
// scope attribute that this node's per-breakpoint override CSS targets.
let dataAttrs = buildDataAttrs(props);
const responsiveCss = responsiveCssForNode(nodeId, props);
if (responsiveCss) {
cssAcc.push(responsiveCss);
dataAttrs += ` data-wsb-r="${responsiveScopeId(nodeId)}"`;
}
// Look up component in resolver and call toHtml. `nodeId` is the Craft.js
// node id for this node -- unique per node in the tree and stable across
@@ -298,22 +333,26 @@ export function buildSeoMeta(options: ExportOptions, title: string): string {
return lines.join('\n ');
}
// Built from `BREAKPOINTS` (utils/breakpoints.ts). Head CSS -- the backend
// `generateCompiledHTML` must emit the minified forms byte-for-byte.
const { mobileMax, tabletMin, tabletMax, desktopMin } = BREAKPOINTS;
const RESPONSIVE_CSS = `
@media (max-width: 768px) {
@media (max-width: ${mobileMax}px) {
[style*="display: flex"][style*="flex-direction: row"],
[style*="display:flex"][style*="flex-direction:row"] {
flex-direction: column !important;
}
}`;
const RESPONSIVE_CSS_MINIFIED = `@media(max-width:768px){[style*="display: flex"][style*="flex-direction: row"],[style*="display:flex"][style*="flex-direction:row"]{flex-direction:column!important}}`;
export const RESPONSIVE_CSS_MINIFIED = `@media(max-width:${mobileMax}px){[style*="display: flex"][style*="flex-direction: row"],[style*="display:flex"][style*="flex-direction:row"]{flex-direction:column!important}}`;
const VISIBILITY_CSS = `
@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; } }`;
@media (min-width: ${desktopMin}px) { [data-hide-desktop] { display: none !important; } }
@media (min-width: ${tabletMin}px) and (max-width: ${tabletMax}px) { [data-hide-tablet] { display: none !important; } }
@media (max-width: ${mobileMax}px) { [data-hide-mobile] { display: none !important; } }`;
const VISIBILITY_CSS_MINIFIED = `@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}}`;
export const VISIBILITY_CSS_MINIFIED = `@media(min-width:${desktopMin}px){[data-hide-desktop]{display:none!important}}@media(min-width:${tabletMin}px) and (max-width:${tabletMax}px){[data-hide-tablet]{display:none!important}}@media(max-width:${mobileMax}px){[data-hide-mobile]{display:none!important}}`;
const ANIMATION_CSS = `
@keyframes fadeIn { from { opacity: 0; } to { opacity: 1; } }
@@ -401,6 +440,20 @@ ${bodyHtml}${animationScript}
</html>`;
}
/**
* Render the page body: the node tree, preceded by one
* `<style data-wsb-responsive>` block when any node carries tablet/mobile
* overrides. It lives in the BODY (like ButtonLink/ColumnLayout's scoped
* styles), so the stored page HTML carries it and the head stays identical
* to the backend's; `css` in the save payload stays `''`.
*/
function renderBody(nodes: Record<string, any>): string {
const cssAcc: string[] = [];
const { html } = renderNode(nodes, 'ROOT', cssAcc);
const css = cssAcc.join('');
return css ? `<style data-wsb-responsive>${css}</style>${html}` : html;
}
/**
* Export serialized Craft.js state to standalone HTML.
*/
@@ -413,8 +466,7 @@ export function exportToHtml(
): { html: string; css: string } {
try {
const nodes = JSON.parse(serializedState);
const { html: bodyHtml } = renderNode(nodes, 'ROOT');
const fullHtml = wrapInDocument(bodyHtml, options);
const fullHtml = wrapInDocument(renderBody(nodes), options);
return { html: fullHtml, css: '' };
} catch (e) {
console.error('Export to HTML failed:', e);
@@ -433,8 +485,7 @@ export function exportBodyHtml(
): { html: string; css: string } {
try {
const nodes = JSON.parse(serializedState);
const { html } = renderNode(nodes, 'ROOT');
return { html, css: '' };
return { html: renderBody(nodes), css: '' };
} catch (e) {
console.error('Body export failed:', e);
return { html: '', css: '' };
+195
View File
@@ -0,0 +1,195 @@
import { describe, test, expect } from 'vitest';
import {
RESPONSIVE_STYLE_KEYS,
resolveStyleForDevice,
overrideFor,
hasOverride,
withResponsiveValue,
withoutOverride,
hasAnyOverride,
responsiveCssForNode,
responsiveScopeId,
} from './responsive-style';
import { BREAKPOINTS, DEVICE_ORDER, mediaQueryFor, deviceForWidth } from './breakpoints';
describe('breakpoints', () => {
test('values: ≤767 mobile / 768-991 tablet / ≥992 desktop', () => {
expect(BREAKPOINTS).toEqual({ mobileMax: 767, tabletMin: 768, tabletMax: 991, desktopMin: 992 });
expect(DEVICE_ORDER).toEqual(['desktop', 'tablet', 'mobile']);
});
test('mediaQueryFor', () => {
expect(mediaQueryFor('tablet')).toBe('(max-width:991px)');
expect(mediaQueryFor('mobile')).toBe('(max-width:767px)');
});
test('deviceForWidth boundaries', () => {
expect(deviceForWidth(375)).toBe('mobile');
expect(deviceForWidth(767)).toBe('mobile');
expect(deviceForWidth(768)).toBe('tablet');
expect(deviceForWidth(991)).toBe('tablet');
expect(deviceForWidth(992)).toBe('desktop');
expect(deviceForWidth(1440)).toBe('desktop');
});
});
describe('resolveStyleForDevice cascade', () => {
const props = {
style: { fontSize: '40px', color: 'red', textAlign: 'left' as const },
styleTablet: { fontSize: '32px' },
styleMobile: { fontSize: '24px', textAlign: 'center' as const },
};
test('desktop → style only', () => {
expect(resolveStyleForDevice(props, 'desktop')).toEqual(props.style);
});
test('tablet → style + styleTablet', () => {
expect(resolveStyleForDevice(props, 'tablet')).toEqual({ fontSize: '32px', color: 'red', textAlign: 'left' });
});
test('mobile → style + styleTablet + styleMobile', () => {
expect(resolveStyleForDevice(props, 'mobile')).toEqual({ fontSize: '24px', color: 'red', textAlign: 'center' });
});
test('mobile inherits tablet when it has no own value', () => {
expect(
resolveStyleForDevice({ style: { padding: '40px' }, styleTablet: { padding: '20px' } }, 'mobile'),
).toEqual({ padding: '20px' });
});
test("'' (and null/undefined) in an override does NOT override", () => {
const p = {
style: { fontSize: '40px', padding: '10px' },
styleTablet: { fontSize: '' },
styleMobile: { padding: '' as const, margin: undefined },
};
expect(resolveStyleForDevice(p, 'tablet')).toEqual({ fontSize: '40px', padding: '10px' });
expect(resolveStyleForDevice(p, 'mobile')).toEqual({ fontSize: '40px', padding: '10px' });
});
test('missing style objects resolve to {}', () => {
expect(resolveStyleForDevice({}, 'mobile')).toEqual({});
});
});
describe('overrideFor / hasOverride / hasAnyOverride', () => {
const props = { style: { fontSize: '40px' }, styleTablet: { fontSize: '32px', padding: '' } };
test('overrideFor returns the raw partial; {} for desktop / absent', () => {
expect(overrideFor(props, 'tablet')).toEqual({ fontSize: '32px', padding: '' });
expect(overrideFor(props, 'desktop')).toEqual({});
expect(overrideFor(props, 'mobile')).toEqual({});
});
test('hasOverride requires a non-empty value in that device', () => {
expect(hasOverride(props, 'tablet', 'fontSize')).toBe(true);
expect(hasOverride(props, 'tablet', 'padding')).toBe(false);
expect(hasOverride(props, 'mobile', 'fontSize')).toBe(false);
expect(hasOverride(props, 'desktop', 'fontSize')).toBe(false);
});
test('hasAnyOverride', () => {
expect(hasAnyOverride(props)).toBe(true);
expect(hasAnyOverride({ style: { fontSize: '1px' } })).toBe(false);
expect(hasAnyOverride({ styleTablet: {}, styleMobile: { padding: '' } })).toBe(false);
expect(hasAnyOverride(undefined)).toBe(false);
});
});
describe('withResponsiveValue / withoutOverride', () => {
const base = { text: 'Hi', style: { fontSize: '40px' } };
test('desktop writes style, leaves overrides alone', () => {
const next = withResponsiveValue({ ...base, styleTablet: { fontSize: '30px' } }, 'desktop', 'fontSize', '48px');
expect(next).toEqual({ text: 'Hi', style: { fontSize: '48px' }, styleTablet: { fontSize: '30px' } });
});
test('tablet writes styleTablet, mobile writes styleMobile, desktop style untouched', () => {
const t = withResponsiveValue(base, 'tablet', 'fontSize', '32px');
expect(t).toEqual({ text: 'Hi', style: { fontSize: '40px' }, styleTablet: { fontSize: '32px' } });
const m = withResponsiveValue(t, 'mobile', 'textAlign', 'center');
expect(m.styleMobile).toEqual({ textAlign: 'center' });
expect(m.styleTablet).toEqual({ fontSize: '32px' });
expect(m.style).toEqual({ fontSize: '40px' });
});
test('is pure (input not mutated)', () => {
const input = { style: { fontSize: '40px' }, styleTablet: { padding: '1px' } };
const snapshot = JSON.stringify(input);
withResponsiveValue(input, 'tablet', 'fontSize', '2px');
withoutOverride(input, 'tablet', 'padding');
expect(JSON.stringify(input)).toBe(snapshot);
});
test('throws for a non-responsive key on tablet/mobile, allows it on desktop', () => {
expect(() => withResponsiveValue(base, 'tablet', 'color', 'red')).toThrow();
expect(() => withResponsiveValue(base, 'mobile', 'backgroundColor', 'red')).toThrow();
expect(withResponsiveValue(base, 'desktop', 'color', 'red').style).toEqual({ fontSize: '40px', color: 'red' });
});
test("'' on tablet/mobile clears the override", () => {
const p = { style: {}, styleMobile: { fontSize: '20px', padding: '4px' } };
expect(withResponsiveValue(p, 'mobile', 'fontSize', '').styleMobile).toEqual({ padding: '4px' });
});
test('withoutOverride removes only that key, keeps an (empty) object', () => {
const p = { styleTablet: { fontSize: '20px' } };
expect(withoutOverride(p, 'tablet', 'fontSize')).toEqual({ styleTablet: {} });
expect(withoutOverride(p, 'desktop', 'fontSize')).toBe(p);
});
test('RESPONSIVE_STYLE_KEYS covers the layout + type-scale set', () => {
for (const k of ['fontSize', 'textAlign', 'padding', 'marginTop', 'gap', 'order', 'flexDirection', 'maxWidth']) {
expect(RESPONSIVE_STYLE_KEYS.has(k)).toBe(true);
}
expect(RESPONSIVE_STYLE_KEYS.has('color')).toBe(false);
expect(RESPONSIVE_STYLE_KEYS.size).toBe(27);
});
});
describe('responsiveCssForNode', () => {
test("'' when no overrides", () => {
expect(responsiveCssForNode('abc', { style: { fontSize: '40px' } })).toBe('');
expect(responsiveCssForNode('abc', { styleTablet: {}, styleMobile: { fontSize: '' } })).toBe('');
});
test('exact string for a two-key example (kebab-case, !important on every declaration)', () => {
expect(
responsiveCssForNode('node_1', {
style: { fontSize: '40px' },
styleMobile: { fontSize: '24px', textAlign: 'center' },
}),
).toBe('@media (max-width:767px){[data-wsb-r="node_1"]{font-size:24px!important;text-align:center!important}}');
});
test('tablet block precedes mobile block', () => {
expect(
responsiveCssForNode('n', { styleTablet: { paddingTop: '10px' }, styleMobile: { lineHeight: '1.2' } }),
).toBe(
'@media (max-width:991px){[data-wsb-r="n"]{padding-top:10px!important}}' +
'@media (max-width:767px){[data-wsb-r="n"]{line-height:1.2!important}}',
);
});
test('breakout characters in values are sanitised', () => {
const css = responsiveCssForNode('n', {
styleMobile: { fontSize: '1px}body{display:none;color:red</style><script>x()</script>' as any },
});
expect(css).not.toContain('</style>');
expect(css).not.toContain('<script>');
expect(css).not.toContain(';color');
// Only the scoped rule's own braces survive.
expect(css.match(/[{}]/g)!.join('')).toBe('{{}}');
});
test('a malicious KEY is dropped', () => {
const css = responsiveCssForNode('n', { styleMobile: { '"]{x:y}': '1px', fontSize: '2px' } as any });
expect(css).toBe('@media (max-width:767px){[data-wsb-r="n"]{font-size:2px!important}}');
});
test('scope id is stripped to [A-Za-z0-9_-]', () => {
expect(responsiveScopeId('a"b]c d_-9')).toBe('abcd_-9');
expect(responsiveCssForNode('"]', { styleMobile: { fontSize: '2px' } })).toBe('');
});
});
+166
View File
@@ -0,0 +1,166 @@
import type { CSSProperties } from 'react';
import type { DeviceMode } from '../types';
import { cssPropsToString } from './style-helpers';
import { mediaQueryFor } from './breakpoints';
/**
* Per-breakpoint style overrides (site-builder #45 / #37).
*
* A node's `style` is the desktop (base) style. `styleTablet` / `styleMobile`
* are optional PARTIAL objects layered on top of it -- they never touch
* `style`, so editing tablet/mobile can never change desktop. Cascade:
* desktop → style
* tablet → style + styleTablet
* mobile → style + styleTablet + styleMobile
* The editor applies the cascade in ONE place (`editor/RenderNode.tsx`) and
* the exporter emits the same cascade as scoped `@media` rules
* (`responsiveCssForNode`), so Preview/Published match the canvas.
*
* v1 scope: only values under `props.style` participate. Device-relevant
* props that live outside `style` (e.g. HeroSimple `minHeight`/`textAlign`,
* ColumnLayout `gap`) are not overridable yet.
*
* Pure module -- no React runtime import.
*/
/** Style keys that may carry a tablet/mobile override (layout + type scale). */
export const RESPONSIVE_STYLE_KEYS: ReadonlySet<string> = new Set([
'marginTop', 'marginRight', 'marginBottom', 'marginLeft', 'margin',
'paddingTop', 'paddingRight', 'paddingBottom', 'paddingLeft', 'padding',
'fontSize', 'lineHeight', 'letterSpacing', 'textAlign',
'width', 'maxWidth', 'minWidth', 'height', 'minHeight', 'maxHeight',
'display', 'flexDirection', 'flexWrap', 'alignItems', 'justifyContent', 'gap', 'order',
]);
export type ResponsiveProps = {
style?: CSSProperties;
styleTablet?: Partial<CSSProperties>;
styleMobile?: Partial<CSSProperties>;
};
type OverrideDevice = 'tablet' | 'mobile';
type StyleRecord = Record<string, unknown>;
const OVERRIDE_KEY: Record<OverrideDevice, 'styleTablet' | 'styleMobile'> = {
tablet: 'styleTablet',
mobile: 'styleMobile',
};
/** `''`, `null` and `undefined` mean "not set" -- they never override. */
const isSet = (v: unknown): boolean => v !== undefined && v !== null && v !== '';
const asRecord = (o: unknown): StyleRecord =>
o && typeof o === 'object' && !Array.isArray(o) ? (o as StyleRecord) : {};
/** Only the entries of a partial that actually carry a value. */
function setEntries(partial: unknown): StyleRecord {
const out: StyleRecord = {};
for (const [k, v] of Object.entries(asRecord(partial))) {
if (isSet(v)) out[k] = v;
}
return out;
}
/** The effective style for `device` (see cascade above). */
export function resolveStyleForDevice(props: ResponsiveProps, device: DeviceMode): CSSProperties {
const base = asRecord(props.style);
if (device === 'desktop') return base as CSSProperties;
const tablet = setEntries(props.styleTablet);
if (device === 'tablet') return { ...base, ...tablet } as CSSProperties;
return { ...base, ...tablet, ...setEntries(props.styleMobile) } as CSSProperties;
}
/** The raw partial stored for `device` (`{}` for desktop or when absent). */
export function overrideFor(props: ResponsiveProps, device: DeviceMode): Partial<CSSProperties> {
if (device === 'desktop') return {};
return asRecord(props[OVERRIDE_KEY[device]]) as Partial<CSSProperties>;
}
/** True when `device`'s own partial carries a non-empty value for `key`. */
export function hasOverride(props: ResponsiveProps, device: DeviceMode, key: string): boolean {
if (device === 'desktop') return false;
return isSet(asRecord(props[OVERRIDE_KEY[device]])[key]);
}
/**
* Pure setter: desktop writes `style[key]`; tablet/mobile write the key into
* that device's partial. An empty value on tablet/mobile clears the override
* (falls back to the inherited value). Throws for a non-responsive key on
* tablet/mobile -- callers gate their controls on `RESPONSIVE_STYLE_KEYS`.
*/
export function withResponsiveValue<T extends ResponsiveProps>(
props: T,
device: DeviceMode,
key: string,
value: unknown,
): T & ResponsiveProps {
if (device === 'desktop') {
return { ...props, style: { ...asRecord(props.style), [key]: value } as CSSProperties };
}
if (!RESPONSIVE_STYLE_KEYS.has(key)) {
throw new Error(`"${key}" cannot be overridden per device`);
}
if (!isSet(value)) return withoutOverride(props, device, key);
const field = OVERRIDE_KEY[device];
return { ...props, [field]: { ...asRecord(props[field]), [key]: value } };
}
/**
* Pure: removes `key` from `device`'s partial. The partial is kept as an
* (possibly empty) object rather than deleted so a caller that merges the
* result into Craft props via `Object.assign` really does clear it.
*/
export function withoutOverride<T extends ResponsiveProps>(
props: T,
device: DeviceMode,
key: string,
): T & ResponsiveProps {
if (device === 'desktop') return props;
const field = OVERRIDE_KEY[device];
const next = { ...asRecord(props[field]) };
delete next[key];
return { ...props, [field]: next };
}
/** True when either override partial carries at least one value. */
export function hasAnyOverride(props: ResponsiveProps | undefined | null): boolean {
if (!props) return false;
return (
Object.keys(setEntries(props.styleTablet)).length > 0 ||
Object.keys(setEntries(props.styleMobile)).length > 0
);
}
/** Craft ids are already `[A-Za-z0-9_-]`; strip anything else defensively. */
export function responsiveScopeId(nodeId: string): string {
return String(nodeId).replace(/[^A-Za-z0-9_-]/g, '');
}
/**
* One declaration through the same sanitiser as inline styles, plus the
* extra characters that only matter inside a `<style>` block: `{`/`}` would
* otherwise end the rule and open a new, unscoped one.
*/
function declaration(key: string, value: unknown): string {
const decl = cssPropsToString({ [key]: value } as CSSProperties).replace(/[{}]/g, '');
return decl ? `${decl}!important` : '';
}
/**
* Scoped CSS for a node's overrides, or `''` when it has none:
* `@media (max-width:991px){[data-wsb-r="<id>"]{k:v!important;…}}@media (max-width:767px){…}`.
* `!important` is required because the base value is an inline `style=""`.
*/
export function responsiveCssForNode(scopeAttr: string, props: ResponsiveProps): string {
const scope = responsiveScopeId(scopeAttr);
if (!scope) return '';
let css = '';
for (const device of ['tablet', 'mobile'] as const) {
const decls = Object.entries(setEntries(props[OVERRIDE_KEY[device]]))
.map(([k, v]) => declaration(k, v))
.filter(Boolean);
if (decls.length === 0) continue;
css += `@media ${mediaQueryFor(device)}{[data-wsb-r="${scope}"]{${decls.join(';')}}}`;
}
return css;
}