diff --git a/craft/src/components/layout/Container.toHtml.test.ts b/craft/src/components/layout/Container.toHtml.test.ts index 59bee7d..f8370c2 100644 --- a/craft/src/components/layout/Container.toHtml.test.ts +++ b/craft/src/components/layout/Container.toHtml.test.ts @@ -65,14 +65,29 @@ describe('Container.toHtml tag allowlist (adversarial re-review, same class as C }); describe('Container.toHtml vertical alignment (justify-content + min-height)', () => { - test('is always a column flex container (display:flex;flex-direction:column)', () => { + // Regression lock: Container/Section must NOT unconditionally become a + // flex container. Flex-blockifies in-flow children, forcing components + // that deliberately render display:inline-block (ButtonLink, Icon) to + // stack vertically instead of sitting side-by-side -- a real visual + // regression for existing published pages that never touch vertical + // alignment. + test('does NOT become a flex container when no vertical alignment is set (plain block flow preserved)', () => { const { html } = toHtml({}, 'child'); - expect(html).toContain('display:flex'); - expect(html).toContain('flex-direction:column'); + expect(html).not.toContain('display:flex'); + expect(html).not.toContain('flex-direction'); }); - test('style.justifyContent flows into the emitted style attribute', () => { + test('does NOT become a flex container from min-height alone (min-height must not itself trigger flex)', () => { + const { html } = toHtml({ style: { minHeight: '400px' } }, 'child'); + expect(html).not.toContain('display:flex'); + expect(html).not.toContain('flex-direction'); + expect(html).toContain('min-height:400px'); + }); + + test('becomes a column flex container when style.justifyContent is set (feature still works)', () => { const { html } = toHtml({ style: { justifyContent: 'center' } }, 'child'); + expect(html).toContain('display:flex'); + expect(html).toContain('flex-direction:column'); expect(html).toContain('justify-content:center'); }); @@ -83,6 +98,8 @@ describe('Container.toHtml vertical alignment (justify-content + min-height)', ( test('justify-content and min-height still flow through in boxed (contentWidth) mode', () => { const { html } = toHtml({ contentWidth: 'boxed', style: { justifyContent: 'flex-end', minHeight: '500px' } }, 'child'); + expect(html).toContain('display:flex'); + expect(html).toContain('flex-direction:column'); expect(html).toContain('justify-content:flex-end'); expect(html).toContain('min-height:500px'); }); diff --git a/craft/src/components/layout/Container.tsx b/craft/src/components/layout/Container.tsx index 75ab9a9..0f6cedc 100644 --- a/craft/src/components/layout/Container.tsx +++ b/craft/src/components/layout/Container.tsx @@ -43,17 +43,19 @@ const flexAlignFromTextAlign = (textAlign: CSSProperties['textAlign']): CSSPrope return {}; }; -// Container is ALWAYS display:flex/flex-direction:column at its root (both -// in the editor render below and in toHtml) so that `style.justifyContent` -// (the new Vertical Alignment control, paired with `style.minHeight`) has an -// axis to act on. This is a no-op visually for existing content: a column -// flex container with default `justify-content:flex-start` and -// `align-items:stretch` reproduces ordinary block-flow stacking (children -// stack top-to-bottom, full width) byte-for-byte, so nothing published -// before this feature shipped changes appearance. `flexAlignFromTextAlign` -// above still supplies `alignItems` (cross-axis, i.e. horizontal position) -// from `textAlign` independently -- the two controls act on orthogonal axes -// and don't conflict. +// Container only becomes display:flex/flex-direction:column at its root +// (both in the editor render below and in toHtml) when the user has +// actually set `style.justifyContent` (the Vertical Alignment control, +// paired with `style.minHeight`) -- i.e. the flex conversion is gated on +// vertical-align actually being in use, not unconditional. In-flow children +// of a flex container get CSS-blockified, which would force components that +// deliberately render `display:inline-block` (ButtonLink, Icon) to stack +// vertically instead of sitting side-by-side -- a real visual regression for +// any container/section that never touches vertical alignment, not a no-op. +// So plain block flow (no `display`/`flex-direction` at all) is preserved +// unless vertical-align is set. `flexAlignFromTextAlign` above still +// supplies its own conditional flex conversion (cross-axis alignItems from +// `textAlign`) independently -- unrelated to this gate. export const Container: UserComponent = ({ style = {}, @@ -70,12 +72,12 @@ export const Container: UserComponent = ({ const safeTag = sanitizeContainerTag(tag); const needsBoxedWrapper = contentWidth === 'boxed'; const flexStyles = flexAlignFromTextAlign(style.textAlign); + const hasVerticalAlign = !!style.justifyContent; const outerStyle: CSSProperties = { - display: 'flex', - flexDirection: 'column', minHeight: '40px', ...style, + ...(hasVerticalAlign ? { display: 'flex', flexDirection: 'column' } : {}), ...(fullWidth ? { width: '100vw', marginLeft: 'calc(-50vw + 50%)' } : {}), ...(needsBoxedWrapper ? {} : flexStyles), }; @@ -142,11 +144,11 @@ Container.craft = { const tag = sanitizeContainerTag(props.tag); const isBoxed = props.contentWidth === 'boxed'; const flexStyles = flexAlignFromTextAlign(props.style?.textAlign); + const hasVerticalAlign = !!props.style?.justifyContent; const outerCss: CSSProperties = { - display: 'flex', - flexDirection: 'column', ...props.style, + ...(hasVerticalAlign ? { display: 'flex', flexDirection: 'column' } : {}), ...(isBoxed ? {} : flexStyles), }; diff --git a/craft/src/components/layout/Section.toHtml.test.ts b/craft/src/components/layout/Section.toHtml.test.ts index c0db786..0d9afa8 100644 --- a/craft/src/components/layout/Section.toHtml.test.ts +++ b/craft/src/components/layout/Section.toHtml.test.ts @@ -75,14 +75,27 @@ describe('Section.toHtml shape divider color/height XSS hardening', () => { }); describe('Section.toHtml vertical alignment (justify-content + min-height)', () => { - test('is always a column flex container (display:flex;flex-direction:column)', () => { + // Regression lock: same rationale as Container -- see Container.toHtml.test.ts. + // Section must not unconditionally become a flex container, or it + // blockifies inline-block children (ButtonLink, Icon) that are meant to + // sit side-by-side in existing published sections. + test('does NOT become a flex container when no vertical alignment is set (plain block flow preserved)', () => { const { html } = toHtml({}, 'child'); - expect(html).toContain('display:flex'); - expect(html).toContain('flex-direction:column'); + expect(html).not.toContain('display:flex'); + expect(html).not.toContain('flex-direction'); }); - test('style.justifyContent flows into the emitted style attribute', () => { + test('does NOT become a flex container from min-height alone (min-height must not itself trigger flex)', () => { + const { html } = toHtml({ style: { minHeight: '600px' } }, 'child'); + expect(html).not.toContain('display:flex'); + expect(html).not.toContain('flex-direction'); + expect(html).toContain('min-height:600px'); + }); + + test('becomes a column flex container when style.justifyContent is set (feature still works)', () => { const { html } = toHtml({ style: { justifyContent: 'center' } }, 'child'); + expect(html).toContain('display:flex'); + expect(html).toContain('flex-direction:column'); expect(html).toContain('justify-content:center'); }); diff --git a/craft/src/components/layout/Section.tsx b/craft/src/components/layout/Section.tsx index 9287ad8..a425e8f 100644 --- a/craft/src/components/layout/Section.tsx +++ b/craft/src/components/layout/Section.tsx @@ -103,6 +103,13 @@ export const Section: UserComponent = ({ const hasTopDivider = topDivider && topDivider !== 'none'; const hasBottomDivider = bottomDivider && bottomDivider !== 'none'; + // Section's root only becomes a column flex container when the user has + // actually set `style.justifyContent` (Vertical Alignment control, paired + // with `style.minHeight`) -- see the matching note in Container.tsx for + // 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 (
= ({ id={anchorId || undefined} style={{ width: '100%', - // Section's root is always a column flex container so - // `style.justifyContent` (Vertical Alignment control, paired with - // `style.minHeight`) has an axis to act on -- see the matching note - // in Container.tsx for why this is a no-op for existing content - // (default justify-content/align-items reproduce ordinary block - // stacking). - display: 'flex', - flexDirection: 'column', position: (hasTopDivider || hasBottomDivider) ? 'relative' : undefined, ...style, + ...(hasVerticalAlign ? { display: 'flex', flexDirection: 'column' } : {}), }} > {hasTopDivider && ( @@ -227,13 +227,13 @@ 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%', - display: 'flex', - flexDirection: 'column', position: (hasTopDivider || hasBottomDivider) ? 'relative' : undefined, ...props.style, + ...(hasVerticalAlign ? { display: 'flex', flexDirection: 'column' } : {}), }); const innerStyle = cssPropsToString({ maxWidth: props.innerMaxWidth || '1200px',