From c00985a3a180fa4319573458382dc06ff21fb759 Mon Sep 17 00:00:00 2001 From: Josh Knapp Date: Wed, 7 Oct 2026 13:18:25 -0700 Subject: [PATCH] fix(responsive): derive Container/Section flex layout from the resolved per-device style in export Tablet/mobile Alignment and Vertical-alignment overrides published only the raw text-align/justify-content declarations, while the editor (which renders from the device-resolved style) also applied the derived flex layout (display:flex;flex-direction:column;align-items). Desktop-centred containers stayed centred on mobile, mobile-only centring/vertical-align did nothing, and in boxed mode the flex sits on the inner div which had no scope at all. - Container/Section: single containerLayout()/sectionLayout() helper used by render, toHtml and a new static responsiveLayout hook. - responsiveCssForNode takes an optional DerivedLayoutFn: per device it diffs the layout derived from the resolved style against what already applies (desktop for tablet, tablet for mobile), emits changes (and resets for dropped keys), and scopes boxed-mode inner rules as [data-wsb-r=x]>div. - html-export passes the component's responsiveLayout hook. Co-Authored-By: Claude Opus 5.5 --- craft/src/components/layout/Container.tsx | 50 +++- craft/src/components/layout/Section.tsx | 20 +- .../layout/responsive-flex.test.tsx | 213 ++++++++++++++++++ craft/src/utils/html-export.ts | 12 +- craft/src/utils/responsive-style.ts | 75 +++++- 5 files changed, 348 insertions(+), 22 deletions(-) create mode 100644 craft/src/components/layout/responsive-flex.test.tsx diff --git a/craft/src/components/layout/Container.tsx b/craft/src/components/layout/Container.tsx index a57a98b..44ec28d 100644 --- a/craft/src/components/layout/Container.tsx +++ b/craft/src/components/layout/Container.tsx @@ -57,6 +57,26 @@ const flexAlignFromTextAlign = (textAlign: CSSProperties['textAlign']): CSSPrope // supplies its own conditional flex conversion (cross-axis alignItems from // `textAlign`) independently -- unrelated to this gate. +/** + * The layout declarations Container DERIVES from its style (not the style + * itself): `outer` goes on the root element, `inner` on the boxed wrapper div + * (empty when not boxed -- the alignment flex then sits on the root). One + * source for the editor render, `toHtml` and the per-device `@media` rules + * (`responsiveLayout` below), so a tablet/mobile Alignment or Vertical + * alignment override publishes exactly as the editor shows it (finding 7). + */ +export const containerLayout = ( + style: CSSProperties | undefined, + isBoxed: boolean, +): { outer: CSSProperties; inner: CSSProperties } => { + const flexStyles = flexAlignFromTextAlign(style?.textAlign); + const outer: CSSProperties = { + ...(style?.justifyContent ? { display: 'flex', flexDirection: 'column' } : {}), + ...(isBoxed ? {} : flexStyles), + }; + return { outer, inner: isBoxed ? flexStyles : {} }; +}; + export const Container: UserComponent = ({ style = {}, tag = 'div', @@ -71,15 +91,15 @@ export const Container: UserComponent = ({ const safeTag = sanitizeContainerTag(tag); const needsBoxedWrapper = contentWidth === 'boxed'; - const flexStyles = flexAlignFromTextAlign(style.textAlign); - const hasVerticalAlign = !!style.justifyContent; + // `style` is the device-resolved style (RenderNode), so the derived flex + // follows the current device's Alignment / Vertical alignment. + const layout = containerLayout(style, needsBoxedWrapper); const outerStyle: CSSProperties = { minHeight: '40px', ...style, - ...(hasVerticalAlign ? { display: 'flex', flexDirection: 'column' } : {}), ...(fullWidth ? { width: '100vw', marginLeft: 'calc(-50vw + 50%)' } : {}), - ...(needsBoxedWrapper ? {} : flexStyles), + ...layout.outer, }; // cssId is the user-facing "CSS ID" advanced field; it takes precedence @@ -98,7 +118,7 @@ export const Container: UserComponent = ({ className: cssClass || undefined, }, needsBoxedWrapper - ? React.createElement('div', { style: { maxWidth: '1200px', margin: '0 auto', ...flexStyles } }, children) + ? React.createElement('div', { style: { maxWidth: '1200px', margin: '0 auto', ...layout.inner } }, children) : children, ); @@ -144,13 +164,11 @@ Container.craft = { (Container as any).toHtml = (props: ContainerProps, childrenHtml: string) => { const tag = sanitizeContainerTag(props.tag); const isBoxed = props.contentWidth === 'boxed'; - const flexStyles = flexAlignFromTextAlign(props.style?.textAlign); - const hasVerticalAlign = !!props.style?.justifyContent; + const layout = containerLayout(props.style, isBoxed); const outerCss: CSSProperties = { ...props.style, - ...(hasVerticalAlign ? { display: 'flex', flexDirection: 'column' } : {}), - ...(isBoxed ? {} : flexStyles), + ...layout.outer, }; if (props.fullWidth) { @@ -165,9 +183,21 @@ Container.craft = { const classAttr = props.cssClass ? ` class="${escapeAttr(props.cssClass)}"` : ''; if (isBoxed) { - const innerStyle = cssPropsToString({ maxWidth: '1200px', margin: '0 auto', ...flexStyles }); + const innerStyle = cssPropsToString({ maxWidth: '1200px', margin: '0 auto', ...layout.inner }); return { html: `<${tag}${idAttr}${classAttr}${styleStr ? ` style="${styleStr}"` : ''}>${childrenHtml}` }; } return { html: `<${tag}${idAttr}${classAttr}${styleStr ? ` style="${styleStr}"` : ''}>${childrenHtml}` }; }; + +/** + * Responsive-export hook (see `DerivedLayoutFn` in utils/responsive-style.ts): + * the derived layout for a device-resolved style, keyed by selector suffix + * relative to the node's `[data-wsb-r]` scope. In boxed mode the alignment + * flex lives on the only child, the wrapper div. + */ +(Container as any).responsiveLayout = (style: CSSProperties, props: ContainerProps): Record => { + const isBoxed = props.contentWidth === 'boxed'; + const layout = containerLayout(style, isBoxed); + return isBoxed ? { '': layout.outer, '>div': layout.inner } : { '': layout.outer }; +}; diff --git a/craft/src/components/layout/Section.tsx b/craft/src/components/layout/Section.tsx index d57366e..3f796a2 100644 --- a/craft/src/components/layout/Section.tsx +++ b/craft/src/components/layout/Section.tsx @@ -88,6 +88,15 @@ const ShapeDivider: React.FC<{ /* ---------- Component ---------- */ +/** + * The layout Section DERIVES from its style: a column flex root when + * Vertical alignment (`justifyContent`) is set. Shared by the editor render + * (which receives the device-resolved style), `toHtml`, and the per-device + * `@media` rules (`responsiveLayout` below) -- finding 7. + */ +export const sectionLayout = (style: CSSProperties | undefined): CSSProperties => + style?.justifyContent ? { display: 'flex', flexDirection: 'column' } : {}; + export const Section: UserComponent = ({ style = {}, innerMaxWidth = '1200px', @@ -110,8 +119,6 @@ export const Section: UserComponent = ({ // why an unconditional conversion is a real regression (blockifies // deliberately inline-block children like ButtonLink/Icon) rather than a // no-op, so plain block flow is preserved unless vertical-align is set. - const hasVerticalAlign = !!style.justifyContent; - return (
{ if (ref) connect(drag(ref)); }} @@ -120,7 +127,7 @@ export const Section: UserComponent = ({ width: '100%', position: (hasTopDivider || hasBottomDivider) ? 'relative' : undefined, ...style, - ...(hasVerticalAlign ? { display: 'flex', flexDirection: 'column' } : {}), + ...sectionLayout(style), }} > {hasTopDivider && ( @@ -228,13 +235,11 @@ function buildDividerHtml( (Section as any).toHtml = (props: SectionProps, childrenHtml: string) => { const hasTopDivider = props.topDivider && props.topDivider !== 'none'; const hasBottomDivider = props.bottomDivider && props.bottomDivider !== 'none'; - const hasVerticalAlign = !!props.style?.justifyContent; - const outerStyle = cssPropsToString({ width: '100%', position: (hasTopDivider || hasBottomDivider) ? 'relative' : undefined, ...props.style, - ...(hasVerticalAlign ? { display: 'flex', flexDirection: 'column' } : {}), + ...sectionLayout(props.style), }); const innerStyle = cssPropsToString({ maxWidth: props.innerMaxWidth || '1200px', @@ -251,3 +256,6 @@ function buildDividerHtml( html: `${topHtml}${childrenHtml}${bottomHtml}
`, }; }; + +/** Responsive-export hook (see `DerivedLayoutFn` in utils/responsive-style.ts). */ +(Section as any).responsiveLayout = (style: CSSProperties): Record => ({ '': sectionLayout(style) }); diff --git a/craft/src/components/layout/responsive-flex.test.tsx b/craft/src/components/layout/responsive-flex.test.tsx new file mode 100644 index 0000000..3ea629b --- /dev/null +++ b/craft/src/components/layout/responsive-flex.test.tsx @@ -0,0 +1,213 @@ +import { describe, test, expect, afterEach } from 'vitest'; +import React from 'react'; +import { exportBodyHtml } 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'; + +/** + * Finding 7: Container/Section derive their flex layout (textAlign → + * display:flex;flex-direction:column;align-items, and the vertical-align + * justifyContent → display:flex;flex-direction:column conversion) from the + * style. The editor renders from the device-RESOLVED style, so the published + * @media rules must carry the same derived declarations (computed from the + * resolved per-device style) and target the element that carries the flex + * (the inner div in boxed mode). + */ + +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 containerState = (props: Record) => + JSON.stringify({ + ROOT: node('Container', { tag: 'div', style: {} }, ['c1']), + c1: node('Container', { tag: 'div', cssId: 'c1', ...props }, ['t1'], { parent: 'ROOT' }), + t1: node('Heading', { text: 'Hi', level: 'h2', style: {} }, [], { parent: 'c1' }), + }); + +const sectionState = (props: Record) => + JSON.stringify({ + ROOT: node('Container', { tag: 'div', style: {} }, ['s1']), + s1: node('Section', { anchorId: 's1', innerMaxWidth: '1200px', ...props }, [], { + parent: 'ROOT', + isCanvas: false, + linkedNodes: { 'section-inner': 'sin' }, + }), + sin: node('Container', { tag: 'div', style: { maxWidth: '1200px', margin: '0 auto' } }, ['t1'], { parent: 's1' }), + t1: node('Heading', { text: 'Hi', level: 'h2', style: {} }, [], { parent: 'sin' }), + }); + +/** The body of the `@media { {...} }` rule for `selector`, or null. */ +function ruleFor(html: string, device: 'tablet' | 'mobile', selector: string): string | null { + const m = /