From e20c6044136ad4b55b6354f5f19ee09b62e07c08 Mon Sep 17 00:00:00 2001 From: Josh Knapp Date: Wed, 7 Oct 2026 13:39:08 -0700 Subject: [PATCH] fix(editor): mobile column stacking follows the desktop style, as published Published mobile stacking is the global RESPONSIVE_CSS rule matching the DESKTOP inline style, so un-stacking Columns on mobile under a stacked tablet (styleMobile.flexDirection 'row') publishes un-stacked. The editor's device-emulation copy matched the resolved inline style and showed it stacked. RenderNode now marks nodes with a flexDirection/display override with data-wsb-base-stack (baseStacksOnMobile) and editor.css keys off it. Co-Authored-By: Claude Opus 5.5 --- .../layout/ColumnLayout.stack-parity.test.tsx | 88 +++++++++++++++++++ craft/src/editor/RenderNode.tsx | 13 ++- craft/src/styles/editor.css | 7 +- craft/src/utils/responsive-style.ts | 30 +++++++ 4 files changed, 134 insertions(+), 4 deletions(-) create mode 100644 craft/src/components/layout/ColumnLayout.stack-parity.test.tsx diff --git a/craft/src/components/layout/ColumnLayout.stack-parity.test.tsx b/craft/src/components/layout/ColumnLayout.stack-parity.test.tsx new file mode 100644 index 0000000..4eccbc9 --- /dev/null +++ b/craft/src/components/layout/ColumnLayout.stack-parity.test.tsx @@ -0,0 +1,88 @@ +import { describe, test, expect, afterEach } from 'vitest'; +import React from 'react'; +import { exportBodyHtml, RESPONSIVE_CSS_MINIFIED } from '../../utils/html-export'; +import { renderEditorHarness, EditorHarness } from '../../test-utils/editorHarness'; +import { DeviceProvider } from '../../state/DeviceContext'; +import { RenderNode } from '../../editor/RenderNode'; +import type { DeviceMode } from '../../types'; +// Vitest stubs CSS imports (`?raw` reads back empty); read the real file (same as DropIndicator.test). +const nodeFs = (globalThis as any).process.getBuiltinModule('node:fs'); +const EDITOR_CSS: string = nodeFs.readFileSync(`${(globalThis as any).process.cwd()}/src/styles/editor.css`, 'utf8'); + +/** + * Minor finding: un-stacking Columns on mobile (styleMobile.flexDirection 'row' + * under a stacked tablet) rendered STACKED in the editor but UN-stacked when + * published. Published mobile stacking is the global RESPONSIVE_CSS rule, which + * matches the DESKTOP inline style; the editor's device-emulation copy of that + * rule matched the device-RESOLVED inline style instead. + */ + + +/** Selector list of every editor.css rule that stacks (flex-direction:column) on the mobile frame. */ +function editorMobileStackSelectors(): string { + const out: string[] = []; + const re = /([^{}]+)\{([^{}]*)\}/g; + let m: RegExpExecArray | null; + while ((m = re.exec(EDITOR_CSS))) { + const sel = m[1].replace(/\/\*[\s\S]*?\*\//g, '').trim(); + if (sel.includes('[data-device="mobile"]') && /flex-direction:\s*column\s*!important/.test(m[2])) out.push(sel); + } + return out.join(','); +} + +/** The published global mobile stacking selector (from RESPONSIVE_CSS). */ +const PUBLISHED_STACK_SELECTOR = /\{([^{}]+)\{flex-direction:column!important\}/.exec(RESPONSIVE_CSS_MINIFIED)![1]; + +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 colsState = (props: Record) => + JSON.stringify({ + ROOT: node('Container', { tag: 'div', style: {} }, ['cl']), + cl: node('ColumnLayout', { columns: 2, split: '50-50', gap: '16px', anchorId: 'cl', ...props }, [], { + parent: 'ROOT', isCanvas: false, linkedNodes: { 'col-0': 'c0', 'col-1': 'c1' }, + }), + c0: node('Container', { tag: 'div', style: {} }, [], { parent: 'cl' }), + c1: node('Container', { tag: 'div', style: {} }, [], { parent: 'cl' }), + }); + +let h: EditorHarness | null = null; +afterEach(() => { h?.unmount(); h = null; }); + +function editorStacksOnMobile(state: string, device: DeviceMode = 'mobile'): boolean { + h = renderEditorHarness({ + initialState: state, + onRender: RenderNode, + wrap: (tree) =>
{tree}
, + }); + const el = h.container.querySelector('#cl') as HTMLElement; + return el.matches(editorMobileStackSelectors()); +} + +function publishedBaseStacks(state: string): boolean { + const doc = document.createElement('div'); + doc.innerHTML = exportBodyHtml(state).html; + return (doc.querySelector('#cl') as HTMLElement).matches(PUBLISHED_STACK_SELECTOR); +} + +describe('Columns mobile stacking: editor matches published', () => { + test('un-stack on mobile under a stacked tablet -> NOT stacked in the editor (published is not)', () => { + const state = colsState({ style: {}, styleTablet: { flexDirection: 'column' }, styleMobile: { flexDirection: 'row' } }); + expect(publishedBaseStacks(state)).toBe(false); + expect(exportBodyHtml(state).html).toMatch(/@media \(max-width:767px\)\{\[data-wsb-r="b-cl"\]\{flex-direction:row!important/); + expect(editorStacksOnMobile(state)).toBe(false); + }); + + test('desktop row, no override -> stacked in both', () => { + const state = colsState({ style: { flexDirection: 'row' } }); + expect(publishedBaseStacks(state)).toBe(true); + expect(editorStacksOnMobile(state)).toBe(true); + }); + + test('desktop row + mobile row override -> stacked in both (global rule wins published)', () => { + const state = colsState({ style: { flexDirection: 'row' }, styleMobile: { flexDirection: 'row' } }); + expect(publishedBaseStacks(state)).toBe(true); + expect(editorStacksOnMobile(state)).toBe(true); + }); +}); diff --git a/craft/src/editor/RenderNode.tsx b/craft/src/editor/RenderNode.tsx index b5b318f..bdfd6a5 100644 --- a/craft/src/editor/RenderNode.tsx +++ b/craft/src/editor/RenderNode.tsx @@ -2,7 +2,7 @@ import React, { useCallback, useContext, useEffect, useMemo, useRef } from 'reac import { createPortal } from 'react-dom'; import { NodeElement, useEditor, useNode } from '@craftjs/core'; import { useDevice } from '../state/DeviceContext'; -import { hasAnyOverride, hasActiveOverride, resolveStyleForDevice, type ResponsiveProps } from '../utils/responsive-style'; +import { baseStacksOnMobile, hasAnyOverride, hasActiveOverride, resolveStyleForDevice, type ResponsiveProps } from '../utils/responsive-style'; import type { DeviceMode } from '../types'; import { GlobalInstanceContext } from '../state/GlobalInstanceContext'; @@ -97,7 +97,7 @@ export const RenderNode: React.FC = ({ render }) => { const { actions } = useEditor(); const { device } = useDevice(); const parentInstance = useContext(GlobalInstanceContext); - const { id, isSelected, dom, name, parent, isCanvas, isHovered, isComponent, tabletOverride, mobileOverride, hideDesktop, hideTablet, hideMobile, isInstanceRoot } = useNode((node) => { + const { id, isSelected, dom, name, parent, isCanvas, isHovered, isComponent, tabletOverride, mobileOverride, hideDesktop, hideTablet, hideMobile, isInstanceRoot, baseStack } = useNode((node) => { // Keep the collector cheap (booleans only); ResolvedRender collects the // node's type/props/children itself, as Craft's DefaultRender does. const props = node.data.props as ResponsiveProps | undefined; @@ -116,6 +116,7 @@ export const RenderNode: React.FC = ({ render }) => { hideTablet: !!node.data.props?.hideOnTablet, hideMobile: !!node.data.props?.hideOnMobile, isInstanceRoot: !!(node.data.custom?.global as { libraryId?: string } | undefined)?.libraryId, + baseStack: baseStacksOnMobile(props, node.dom?.style), }; }); const instanceRoot = isInstanceRoot ? id : parentInstance; @@ -182,6 +183,14 @@ export const RenderNode: React.FC = ({ render }) => { } }, [dom, hideDesktop, hideTablet, hideMobile, isSelected]); + // Mobile stacking follows the DESKTOP style when published (see + // baseStacksOnMobile); editor.css keys off this instead of the resolved inline. + useEffect(() => { + if (!dom) return; + if (baseStack === null) dom.removeAttribute('data-wsb-base-stack'); + else dom.setAttribute('data-wsb-base-stack', baseStack ? '1' : '0'); + }, [dom, baseStack]); + useEffect(() => { if (!dom) return; if (activeOverride) { diff --git a/craft/src/styles/editor.css b/craft/src/styles/editor.css index 4d1996f..d89b108 100644 --- a/craft/src/styles/editor.css +++ b/craft/src/styles/editor.css @@ -2314,8 +2314,11 @@ body { outline: 2px dashed var(--color-warning, #f59e0b); } -.canvas-device-frame[data-device="mobile"] [style*="display: flex"][style*="flex-direction: row"], -.canvas-device-frame[data-device="mobile"] [style*="display:flex"][style*="flex-direction:row"] { +/* Published stacking matches the DESKTOP inline style; a node with a + flexDirection/display override carries data-wsb-base-stack (RenderNode). */ +.canvas-device-frame[data-device="mobile"] [style*="display: flex"][style*="flex-direction: row"]:not([data-wsb-base-stack]), +.canvas-device-frame[data-device="mobile"] [style*="display:flex"][style*="flex-direction:row"]:not([data-wsb-base-stack]), +.canvas-device-frame[data-device="mobile"] [data-wsb-base-stack="1"] { flex-direction: column !important; } diff --git a/craft/src/utils/responsive-style.ts b/craft/src/utils/responsive-style.ts index e37e2de..6d8ff48 100644 --- a/craft/src/utils/responsive-style.ts +++ b/craft/src/utils/responsive-style.ts @@ -133,6 +133,36 @@ export function hasActiveOverride(props: ResponsiveProps | undefined | null, dev return device === 'mobile' && Object.keys(setEntries(props.styleMobile)).length > 0; } +/** + * Published mobile stacking is the global RESPONSIVE_CSS rule, which matches the + * DESKTOP inline style (`display: flex` + `flex-direction: row*`); a per-device + * flexDirection/display override cannot change whether it fires. For a node whose + * overrides touch those keys, returns whether that rule fires on the published + * element (so the editor can mirror it instead of matching the resolved inline + * style); `null` when no such override exists or the base is unknowable. + * `renderedStyle` is the element's current inline style (its un-overridden keys + * equal the desktop values, including component defaults like Columns' flex). + */ +export function baseStacksOnMobile( + props: ResponsiveProps | undefined | null, + renderedStyle: { display?: string; flexDirection?: string } | undefined, +): boolean | null { + if (!props) return null; + const t = asRecord(props.styleTablet); + const m = asRecord(props.styleMobile); + const overridden = (k: string) => isSet(t[k]) || isSet(m[k]); + if (!overridden('flexDirection') && !overridden('display')) return null; + const base = asRecord(props.style); + const baseValue = (k: 'display' | 'flexDirection'): string | null => { + if (isSet(base[k])) return String(base[k]); + if (!overridden(k)) return renderedStyle?.[k] ?? ''; + return k === 'flexDirection' ? '' : null; // no component default sets a row direction + }; + const display = baseValue('display'); + if (display === null) return null; + return display === 'flex' && /^row/.test(baseValue('flexDirection') ?? ''); +} + /** True when either override partial carries at least one value. */ export function hasAnyOverride(props: ResponsiveProps | undefined | null): boolean { if (!props) return false;