diff --git a/craft/src/editor/Canvas.tsx b/craft/src/editor/Canvas.tsx index e4ff5b5..1188cfd 100644 --- a/craft/src/editor/Canvas.tsx +++ b/craft/src/editor/Canvas.tsx @@ -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 = ({ device, showGuides }) => { +export const Canvas: React.FC = ({ showGuides }) => { + const { device } = useDevice(); const width = DEVICE_WIDTHS[device]; const { isEditingHeader, isEditingFooter, headerPage, footerPage } = usePages(); diff --git a/craft/src/editor/EditorShell.tsx b/craft/src/editor/EditorShell.tsx index 943ffe9..beac06b 100644 --- a/craft/src/editor/EditorShell.tsx +++ b/craft/src/editor/EditorShell.tsx @@ -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 = () => ( + + + +); + +const EditorShellBody: React.FC = () => { const isMobile = useIsMobile(); - const [device, setDevice] = useState('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(loadShowGuides); const setShowGuides = useCallback((next: boolean) => { setShowGuidesState(next); @@ -91,15 +87,13 @@ export const EditorShell: React.FC = () => { setShowGuides(!showGuides)} />
{!isMobile && }
- +
{!isMobile && }
diff --git a/craft/src/editor/LoadGate.conflict.test.tsx b/craft/src/editor/LoadGate.conflict.test.tsx index 1b0a445..349084f 100644 --- a/craft/src/editor/LoadGate.conflict.test.tsx +++ b/craft/src/editor/LoadGate.conflict.test.tsx @@ -72,7 +72,7 @@ function mount() { - {}} showGuides onToggleGuides={() => {}} /> + {}} /> diff --git a/craft/src/editor/LoadGate.save-guard.test.tsx b/craft/src/editor/LoadGate.save-guard.test.tsx index 576ec6f..5bfdfa8 100644 --- a/craft/src/editor/LoadGate.save-guard.test.tsx +++ b/craft/src/editor/LoadGate.save-guard.test.tsx @@ -71,7 +71,7 @@ function mount() { - {}} showGuides onToggleGuides={() => {}} /> + {}} /> diff --git a/craft/src/editor/RenderNode.responsive.test.tsx b/craft/src/editor/RenderNode.responsive.test.tsx new file mode 100644 index 0000000..e5e39bc --- /dev/null +++ b/craft/src/editor/RenderNode.responsive.test.tsx @@ -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 `` under a real DeviceProvider, so the + * per-device style resolution runs against Craft's real render pipeline. + */ + +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 = (headingProps: Record) => + 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) => ( + + + {tree} + + ), + }); + 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' }); + }); +}); diff --git a/craft/src/editor/RenderNode.tsx b/craft/src/editor/RenderNode.tsx index 8c27340..5c665ac 100644 --- a/craft/src/editor/RenderNode.tsx +++ b/craft/src/editor/RenderNode.tsx @@ -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 + * `` 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, children)`) with the + * resolved `style`. Nodes without overrides, and every node on desktop, + * keep Craft's own `render` untouched. */ export const RenderNode: React.FC = ({ 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(null); const active = isSelected && id !== 'ROOT' && !!dom; @@ -86,11 +119,43 @@ export const RenderNode: React.FC = ({ 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) => ( + + ))} + + ) : ( + 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(
{name} diff --git a/craft/src/editor/SaveSerialise.test.tsx b/craft/src/editor/SaveSerialise.test.tsx index 79ad740..5fe67d6 100644 --- a/craft/src/editor/SaveSerialise.test.tsx +++ b/craft/src/editor/SaveSerialise.test.tsx @@ -112,7 +112,7 @@ async function mount() { - {}} showGuides onToggleGuides={() => {}} /> + {}} /> diff --git a/craft/src/editor/SaveState.test.tsx b/craft/src/editor/SaveState.test.tsx index e8d0eb4..a7e22e8 100644 --- a/craft/src/editor/SaveState.test.tsx +++ b/craft/src/editor/SaveState.test.tsx @@ -96,7 +96,7 @@ async function mount() { - {}} showGuides onToggleGuides={() => {}} /> + {}} /> diff --git a/craft/src/panels/topbar/ReportIssueModal.test.tsx b/craft/src/panels/topbar/ReportIssueModal.test.tsx index a1b8b92..664016d 100644 --- a/craft/src/panels/topbar/ReportIssueModal.test.tsx +++ b/craft/src/panels/topbar/ReportIssueModal.test.tsx @@ -46,7 +46,7 @@ function render(open = true) { document.body.appendChild(container); act(() => { root = createRoot(container); - root.render(); + root.render(); }); } diff --git a/craft/src/panels/topbar/ReportIssueModal.tsx b/craft/src/panels/topbar/ReportIssueModal.tsx index 86cdb95..636ed12 100644 --- a/craft/src/panels/topbar/ReportIssueModal.tsx +++ b/craft/src/panels/topbar/ReportIssueModal.tsx @@ -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 = ({ open, onClose, device }) => { +export const ReportIssueModal: React.FC = ({ open, onClose }) => { + const { device } = useDevice(); const { whpConfig } = useEditorConfig(); const { activePageId, pages } = usePages(); // Guarded with optional chaining: some hosts around this component (e.g. diff --git a/craft/src/panels/topbar/TopBar.autosave-gate.test.tsx b/craft/src/panels/topbar/TopBar.autosave-gate.test.tsx index b2fc167..1f73489 100644 --- a/craft/src/panels/topbar/TopBar.autosave-gate.test.tsx +++ b/craft/src/panels/topbar/TopBar.autosave-gate.test.tsx @@ -69,7 +69,7 @@ function mount() { root = createRoot(container); root.render( - {}} showGuides onToggleGuides={() => {}} /> + {}} /> , ); }); diff --git a/craft/src/panels/topbar/TopBar.test.tsx b/craft/src/panels/topbar/TopBar.test.tsx index 89c57ff..475c4cc 100644 --- a/craft/src/panels/topbar/TopBar.test.tsx +++ b/craft/src/panels/topbar/TopBar.test.tsx @@ -84,7 +84,7 @@ function render() { root = createRoot(container); root.render( - {}} showGuides={false} onToggleGuides={() => {}} /> + {}} /> , ); }); @@ -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 = () => {useDevice().device}; + container = document.createElement('div'); + document.body.appendChild(container); + act(() => { + root = createRoot(container); + root.render( + + + {}} /> + + + , + ); + }); + const tablet = container.querySelector('.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'); + }); +}); diff --git a/craft/src/panels/topbar/TopBar.tsx b/craft/src/panels/topbar/TopBar.tsx index e080cb4..a932050 100644 --- a/craft/src/panels/topbar/TopBar.tsx +++ b/craft/src/panels/topbar/TopBar.tsx @@ -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 = ({ device, onDeviceChange, showGuides, onToggleGuides }) => { +export const TopBar: React.FC = ({ 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 = ({ device, onDeviceChange, showGuid setOverflowOpen(false)} - device={device} - onDeviceChange={onDeviceChange} showGuides={showGuides} onToggleGuides={onToggleGuides} onOpenTemplates={() => setTemplateModalOpen(true)} @@ -391,7 +389,7 @@ export const TopBar: React.FC = ({ device, onDeviceChange, showGuid setTemplateModalOpen(false)} /> setHeadCodeModalOpen(false)} /> - setReportOpen(false)} device={device} /> + setReportOpen(false)} /> ); } @@ -516,7 +514,7 @@ export const TopBar: React.FC = ({ device, onDeviceChange, showGuid
setTemplateModalOpen(false)} /> setHeadCodeModalOpen(false)} /> - setReportOpen(false)} device={device} /> + setReportOpen(false)} /> ); }; diff --git a/craft/src/panels/topbar/TopBarOverflowMenu.tsx b/craft/src/panels/topbar/TopBarOverflowMenu.tsx index 36b60eb..85e60ca 100644 --- a/craft/src/panels/topbar/TopBarOverflowMenu.tsx +++ b/craft/src/panels/topbar/TopBarOverflowMenu.tsx @@ -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 = ({ open, onClose, - device, - onDeviceChange, showGuides, onToggleGuides, onOpenTemplates, @@ -41,6 +38,7 @@ export const TopBarOverflowMenu: React.FC = ({ sitesmithNode, editingDisabled, }) => { + const { device, setDevice: onDeviceChange } = useDevice(); const menuRef = useRef(null); useEffect(() => { diff --git a/craft/src/state/DeviceContext.test.tsx b/craft/src/state/DeviceContext.test.tsx new file mode 100644 index 0000000..afeeceb --- /dev/null +++ b/craft/src/state/DeviceContext.test.tsx @@ -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; + +const Probe: React.FC = () => { + api = useDevice(); + return {api.device}; +}; + +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(); + expect(shown()).toBe('desktop'); + act(() => api.setDevice('mobile')); + expect(shown()).toBe('desktop'); + }); + + test('provider defaults to desktop on a desktop viewport', () => { + stubMatchMedia(false); + mount(); + expect(shown()).toBe('desktop'); + }); + + test('setDevice propagates to every consumer', () => { + stubMatchMedia(false); + const Other: React.FC = () => {useDevice().device}; + mount(); + 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(); + expect(shown()).toBe('mobile'); + act(() => api.setDevice('desktop')); + expect(shown()).toBe('desktop'); + }); + + test('initialDevice seeds the state', () => { + stubMatchMedia(false); + mount(); + expect(shown()).toBe('tablet'); + }); +}); diff --git a/craft/src/state/DeviceContext.tsx b/craft/src/state/DeviceContext.tsx index 9898430..7bcf318 100644 --- a/craft/src/state/DeviceContext.tsx +++ b/craft/src/state/DeviceContext.tsx @@ -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({ device: 'desktop', setDevice: () => {} }); +const DEFAULT_VALUE: DeviceContextValue = { device: 'desktop', setDevice: () => {} }; -export const useDevice = () => useContext(DeviceCtx); +const DeviceCtx = createContext(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 = ({ children, initialDevice = 'desktop' }) => { + const [device, setDevice] = useState(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('desktop'); const value = useMemo(() => ({ device, setDevice }), [device]); return {children}; }; diff --git a/craft/src/test-utils/editorHarness.tsx b/craft/src/test-utils/editorHarness.tsx index 3bb5d8a..68c35d9 100644 --- a/craft/src/test-utils/editorHarness.tsx +++ b/craft/src/test-utils/editorHarness.tsx @@ -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 `` override (e.g. the real `RenderNode`). + * Omitted → Craft's default (no wrapper), as before. */ + onRender?: React.ComponentType<{ render: React.ReactElement }>; + /** Wrap the whole `` tree, e.g. in a context provider + * (`(tree) => {tree}`). */ + 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 = ( + + + + + + {extraChild} + + ); root.render( - - - - - - {extraChild} - + {opts.wrap ? opts.wrap(tree) : tree} , ); } diff --git a/craft/src/test-utils/integration/topbar-history.integration.test.tsx b/craft/src/test-utils/integration/topbar-history.integration.test.tsx index 83f51bd..2c58601 100644 --- a/craft/src/test-utils/integration/topbar-history.integration.test.tsx +++ b/craft/src/test-utils/integration/topbar-history.integration.test.tsx @@ -64,7 +64,7 @@ describe('empty-save banner Undo', () => { - {}} showGuides onToggleGuides={() => {}} /> + {}} /> diff --git a/craft/src/types/index.ts b/craft/src/types/index.ts index c7b23a2..7abcd5d 100644 --- a/craft/src/types/index.ts +++ b/craft/src/types/index.ts @@ -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; + styleMobile?: Partial; +} diff --git a/craft/src/utils/breakpoints.ts b/craft/src/utils/breakpoints.ts new file mode 100644 index 0000000..1b60471 --- /dev/null +++ b/craft/src/utils/breakpoints.ts @@ -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'; +} diff --git a/craft/src/utils/html-export.test.ts b/craft/src/utils/html-export.test.ts index 9753099..9038c60 100644 --- a/craft/src/utils/html-export.test.ts +++ b/craft/src/utils/html-export.test.ts @@ -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}' as any }, + }); + expect(css).not.toContain(''); + expect(css).not.toContain('