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 <noreply@anthropic.com>
This commit is contained in:
shadowdaoandClaude Opus 5.5 committed 2026-10-07 13:40:54 -07:00
1 parent 579ea5370c
commit e20c604413
4 files changed
+134 -4

No files matched your search

@@ -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<string, unknown>, nodes: string[] = [], extra: Record<string, unknown> = {}) => ({
type: { resolvedName: type }, isCanvas: type === 'Container', props, displayName: type,
custom: {}, hidden: false, nodes, linkedNodes: {}, ...extra,
});
const colsState = (props: Record<string, unknown>) =>
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) => <DeviceProvider initialDevice={device}><div className="canvas-device-frame" data-device={device}>{tree}</div></DeviceProvider>,
});
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);
});
});
+11 -2
View File
@@ -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<RenderNodeProps> = ({ 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<RenderNodeProps> = ({ 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<RenderNodeProps> = ({ 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) {
+5 -2
View File
@@ -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;
}
+30
View File
@@ -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;