From 518458a8ddb8eeada8ed53d6e2f656f10e27670b Mon Sep 17 00:00:00 2001 From: Frederick O'Brien Date: Fri, 27 Feb 2026 16:59:21 +0000 Subject: [PATCH 01/23] Add vertical rules to grid module --- dotcom-rendering/src/grid.ts | 80 +++++++++++++++++++++++++++++------- 1 file changed, 66 insertions(+), 14 deletions(-) diff --git a/dotcom-rendering/src/grid.ts b/dotcom-rendering/src/grid.ts index a02e5c3f861..793d038f4f2 100644 --- a/dotcom-rendering/src/grid.ts +++ b/dotcom-rendering/src/grid.ts @@ -179,22 +179,74 @@ const outerRules = (color?: string): string => ` // ----- API ----- // /** - * Ask the element to span all grid columns between two grid lines. The lines - * can be specified either by `Line` name or by number. - * @param from The grid line to start from, either a `Line` name or a number. - * @param to The grid line to end at, either a `Line` name or a number. - * @returns {string} CSS to place the element on the grid. - * - * @example Will place the element in the centre column. - * const styles = css` - * ${grid.between('centre-column-start', 'centre-column-end')} - * `; + * Optional vertical grid rules. * - * @example Will place the element between lines 3 and 5. - * const styles = css` - * ${grid.between(3, 5)} - * `; + * Usage: + *
+ *
+ *
+ *
+ * ... + *
*/ +const verticalRules = ` + ${fromBreakpoint.tablet} { + position: relative; + --centre-transform: translateX(-${columnGap}); + + ${fromBreakpoint.leftCol} { + --centre-transform: translateX(calc(${columnGap} / -2)); + } + + .grid-rule { + position: absolute; + top: 0; + bottom: 0; + width: 1px; + background-color: ${themePalette('--article-border')}; + pointer-events: none; + } + + /* LEFT OUTER RULE — where the left column starts */ + .rule-left { + grid-column: left-column-start; + justify-self: start; + transform: translateX(-${columnGap}); + } + + /* CENTRE RULE — start of centre column */ + .rule-centre { + grid-column: centre-column-start; + justify-self: start; + transform: var(--centre-transform); /* centre it in gap */ + } + + /* RIGHT OUTER RULE — end of right column */ + .rule-right { + grid-column: right-column-end; + justify-self: start; + transform: translateX(-1px); + ${betweenBreakpoint.tablet.and.desktop} { + grid-column: centre-column-end; + } + } + } + + ${fromBreakpoint.leftCol} { + .rule-left { + grid-column: left-column-start; + } + } + + ${fromBreakpoint.wide} { + .rule-right { + grid-column: right-column-end; + } + } +`; + +// ----- API ----- // + const between = (from: Line | number, to: Line | number): string => ` grid-column: ${from} / ${to}; `; From d5bfedfdc537e7c636ba1f3a00fa4e12341be2d9 Mon Sep 17 00:00:00 2001 From: Frederick O'Brien Date: Tue, 3 Mar 2026 14:31:18 +0000 Subject: [PATCH 02/23] Fix not appearing --- dotcom-rendering/src/components/RightColumn.tsx | 4 ---- 1 file changed, 4 deletions(-) diff --git a/dotcom-rendering/src/components/RightColumn.tsx b/dotcom-rendering/src/components/RightColumn.tsx index 764a38d82ff..47bde5b8cdc 100644 --- a/dotcom-rendering/src/components/RightColumn.tsx +++ b/dotcom-rendering/src/components/RightColumn.tsx @@ -8,11 +8,7 @@ const hideBelow = (showFrom: Breakpoint) => css` } ${from[showFrom]} { - height: 100%; display: block; - flex-basis: 300px; - flex-grow: 0; - flex-shrink: 0; } `; From 279b7004d7abc9a9b8b10118add968e38fe44644 Mon Sep 17 00:00:00 2001 From: Frederick O'Brien Date: Wed, 4 Mar 2026 14:41:11 +0000 Subject: [PATCH 03/23] Roll back RightColumn changes --- dotcom-rendering/src/components/RightColumn.tsx | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/dotcom-rendering/src/components/RightColumn.tsx b/dotcom-rendering/src/components/RightColumn.tsx index 47bde5b8cdc..764a38d82ff 100644 --- a/dotcom-rendering/src/components/RightColumn.tsx +++ b/dotcom-rendering/src/components/RightColumn.tsx @@ -8,7 +8,11 @@ const hideBelow = (showFrom: Breakpoint) => css` } ${from[showFrom]} { + height: 100%; display: block; + flex-basis: 300px; + flex-grow: 0; + flex-shrink: 0; } `; From 3b59d807327292a19980c03456519ed20e43f652 Mon Sep 17 00:00:00 2001 From: Frederick O'Brien Date: Thu, 5 Mar 2026 14:54:12 +0000 Subject: [PATCH 04/23] Human readable furniture row configuration --- .../src/layouts/lib/furnitureLayouts.ts | 159 ++++++++++++++++++ 1 file changed, 159 insertions(+) create mode 100644 dotcom-rendering/src/layouts/lib/furnitureLayouts.ts diff --git a/dotcom-rendering/src/layouts/lib/furnitureLayouts.ts b/dotcom-rendering/src/layouts/lib/furnitureLayouts.ts new file mode 100644 index 00000000000..c13ba4f337d --- /dev/null +++ b/dotcom-rendering/src/layouts/lib/furnitureLayouts.ts @@ -0,0 +1,159 @@ +import { css, type SerializedStyles } from '@emotion/react'; +import { from, until } from '@guardian/source/foundations'; + +export type LayoutType = 'standard' | 'matchReport' | 'media' | 'labs'; + +export type Area = + // Common areas + | 'title' + | 'headline' + | 'standfirst' + | 'main-media' + | 'meta' + | 'body' + | 'right-column' + // Match report specific areas + | 'match-nav' + | 'match-tabs'; + +type LayoutRows = Partial< + Record< + Area, + { mobile?: number; tablet?: number; leftCol?: number; desktop?: number } + > +>; + +type BreakpointRows = Area[][]; + +type LayoutDefinition = { + mobile?: BreakpointRows; + tablet?: BreakpointRows; + leftCol?: BreakpointRows; +}; + +const furnitureRowLayouts: Record = { + standard: { + tablet: [ + ['title'], + ['headline'], + ['standfirst'], + ['main-media'], + ['meta'], + ], + + leftCol: [ + ['title', 'headline'], + ['standfirst'], + ['meta', 'main-media'], + ], + }, + matchReport: { + tablet: [ + ['match-nav'], + ['match-tabs'], + ['title'], + ['headline'], + ['standfirst'], + ['main-media'], + ['meta'], + ], + leftCol: [ + ['title', 'match-nav'], + ['match-tabs'], + ['headline'], + ['meta', 'main-media'], + ], + }, + media: { + mobile: [ + ['title'], + ['headline'], + ['main-media'], + ['standfirst'], + ['meta'], + ], + tablet: [ + ['title'], + ['headline'], + ['main-media'], + ['standfirst'], + ['meta'], + ], + leftCol: [ + ['title', 'headline'], + ['meta', 'main-media'], + ['standfirst'], + ], + }, + labs: { + tablet: [ + ['title'], + ['headline'], + ['standfirst'], + ['main-media'], + ['meta'], + ], + leftCol: [ + ['title', 'headline'], + ['standfirst'], + ['meta', 'main-media'], + ], + }, +}; + +const buildRowMap = (layout: LayoutDefinition): LayoutRows => { + const map: LayoutRows = {} as LayoutRows; + + const apply = ( + rows: Area[][] | undefined, + breakpoint: 'mobile' | 'tablet' | 'leftCol', + ) => { + if (!rows) return; + + for (const [index, areas] of rows.entries()) { + const row = index + 1; + + for (const area of areas) { + map[area] ??= {}; + map[area][breakpoint] = row; + } + } + }; + + apply(layout.mobile, 'mobile'); + apply(layout.tablet, 'tablet'); + apply(layout.leftCol, 'leftCol'); + + return map; +}; + +const rowMaps = Object.fromEntries( + Object.entries(furnitureRowLayouts).map(([name, layout]) => [ + name, + buildRowMap(layout), + ]), +) as Record; + +const breakpointQueries = { + mobile: until.tablet, + tablet: from.tablet, + leftCol: from.leftCol, + desktop: from.desktop, +} as const; + +export const rowCss = ( + area: Area, + layoutType: LayoutType, +): SerializedStyles => { + const rows = rowMaps[layoutType][area] ?? {}; + + return css( + Object.entries(rows).map( + ([bp, row]) => css` + ${breakpointQueries[bp as keyof typeof breakpointQueries]} { + grid-row: ${row}; + } + `, + ), + ); +}; From b7b41a2b15b2efc24231f187e026da76d1c554f3 Mon Sep 17 00:00:00 2001 From: Frederick O'Brien Date: Fri, 6 Mar 2026 12:52:55 +0000 Subject: [PATCH 05/23] Review polishes and refactors --- dotcom-rendering/src/grid.ts | 89 ++++++++++--------- .../src/layouts/lib/furnitureLayouts.ts | 25 +----- 2 files changed, 49 insertions(+), 65 deletions(-) diff --git a/dotcom-rendering/src/grid.ts b/dotcom-rendering/src/grid.ts index 793d038f4f2..005a5373ba5 100644 --- a/dotcom-rendering/src/grid.ts +++ b/dotcom-rendering/src/grid.ts @@ -179,69 +179,70 @@ const outerRules = (color?: string): string => ` // ----- API ----- // /** - * Optional vertical grid rules. + * Render Guardian grid vertical rules. + * + * Left and right rules are always present. + * A centre rule can optionally be enabled. * * Usage: - *
- *
- *
- *
- * ... - *
+ * css([grid.container, grid.verticalRules()]) + * css([grid.container, grid.verticalRules({ centre: true })]) */ -const verticalRules = ` +const verticalRules = (options: VerticalRuleOptions = {}): string => ` ${fromBreakpoint.tablet} { position: relative; - --centre-transform: translateX(-${columnGap}); - ${fromBreakpoint.leftCol} { - --centre-transform: translateX(calc(${columnGap} / -2)); - } + --centre-transform: translateX(-${columnGap}); - .grid-rule { - position: absolute; - top: 0; - bottom: 0; - width: 1px; - background-color: ${themePalette('--article-border')}; - pointer-events: none; - } + ${fromBreakpoint.leftCol} { + --centre-transform: translateX(calc(${columnGap} / -2)); + } - /* LEFT OUTER RULE — where the left column starts */ - .rule-left { - grid-column: left-column-start; - justify-self: start; - transform: translateX(-${columnGap}); + &::before, + &::after + ${options.centre ? ', & > *:first-child::before' : ''} { + position: absolute; + top: 0; + bottom: 0; + width: 1px; + background-color: ${themePalette('--article-border')}; + pointer-events: none; + content: ''; } - /* CENTRE RULE — start of centre column */ - .rule-centre { + /* LEFT OUTER RULE */ + &::before { grid-column: centre-column-start; justify-self: start; - transform: var(--centre-transform); /* centre it in gap */ + transform: translateX(-${columnGap}); + + ${fromBreakpoint.leftCol} { + grid-column: left-column-start; + } } - /* RIGHT OUTER RULE — end of right column */ - .rule-right { + /* RIGHT OUTER RULE */ + &::after { grid-column: right-column-end; justify-self: start; - transform: translateX(-1px); - ${betweenBreakpoint.tablet.and.desktop} { - grid-column: centre-column-end; - } - } - } + transform: translateX(-1px); - ${fromBreakpoint.leftCol} { - .rule-left { - grid-column: left-column-start; + ${betweenBreakpoint.tablet.and.desktop} { + grid-column: centre-column-end; + } } - } - ${fromBreakpoint.wide} { - .rule-right { - grid-column: right-column-end; - } + ${ + options.centre + ? ` + /* CENTRE RULE */ + & > *:first-child::before { + grid-column: centre-column-start; + justify-self: start; + transform: var(--centre-transform); + }` + : '' + } } `; diff --git a/dotcom-rendering/src/layouts/lib/furnitureLayouts.ts b/dotcom-rendering/src/layouts/lib/furnitureLayouts.ts index c13ba4f337d..2bab7eecc63 100644 --- a/dotcom-rendering/src/layouts/lib/furnitureLayouts.ts +++ b/dotcom-rendering/src/layouts/lib/furnitureLayouts.ts @@ -1,7 +1,7 @@ import { css, type SerializedStyles } from '@emotion/react'; import { from, until } from '@guardian/source/foundations'; -export type LayoutType = 'standard' | 'matchReport' | 'media' | 'labs'; +export type LayoutType = 'standard' | 'matchReport' | 'media'; export type Area = // Common areas @@ -13,8 +13,7 @@ export type Area = | 'body' | 'right-column' // Match report specific areas - | 'match-nav' - | 'match-tabs'; + | 'match-summary'; type LayoutRows = Partial< Record< @@ -49,8 +48,7 @@ const furnitureRowLayouts: Record = { }, matchReport: { tablet: [ - ['match-nav'], - ['match-tabs'], + ['match-summary'], ['title'], ['headline'], ['standfirst'], @@ -58,8 +56,7 @@ const furnitureRowLayouts: Record = { ['meta'], ], leftCol: [ - ['title', 'match-nav'], - ['match-tabs'], + ['title', 'match-summary'], ['headline'], ['meta', 'main-media'], ], @@ -85,20 +82,6 @@ const furnitureRowLayouts: Record = { ['standfirst'], ], }, - labs: { - tablet: [ - ['title'], - ['headline'], - ['standfirst'], - ['main-media'], - ['meta'], - ], - leftCol: [ - ['title', 'headline'], - ['standfirst'], - ['meta', 'main-media'], - ], - }, }; const buildRowMap = (layout: LayoutDefinition): LayoutRows => { From b34ab09c49de2b099387d75e4e5ae16c703a5ee0 Mon Sep 17 00:00:00 2001 From: Frederick O'Brien Date: Tue, 10 Mar 2026 10:13:16 +0000 Subject: [PATCH 06/23] Adjust media furniture layout --- dotcom-rendering/src/layouts/lib/furnitureLayouts.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/dotcom-rendering/src/layouts/lib/furnitureLayouts.ts b/dotcom-rendering/src/layouts/lib/furnitureLayouts.ts index 2bab7eecc63..45d7781a2a3 100644 --- a/dotcom-rendering/src/layouts/lib/furnitureLayouts.ts +++ b/dotcom-rendering/src/layouts/lib/furnitureLayouts.ts @@ -12,7 +12,7 @@ export type Area = | 'meta' | 'body' | 'right-column' - // Match report specific areas + // Match report specific area | 'match-summary'; type LayoutRows = Partial< From 1c6fc698477207bd923c702d6752c28484bc5337 Mon Sep 17 00:00:00 2001 From: Frederick O'Brien Date: Tue, 10 Mar 2026 17:00:44 +0000 Subject: [PATCH 07/23] More consistent row and column grid styling abstraction --- dotcom-rendering/src/grid.ts | 2 +- .../src/layouts/lib/furnitureLayouts.ts | 64 +++++++++++++++++-- 2 files changed, 60 insertions(+), 6 deletions(-) diff --git a/dotcom-rendering/src/grid.ts b/dotcom-rendering/src/grid.ts index 005a5373ba5..fdac13c8710 100644 --- a/dotcom-rendering/src/grid.ts +++ b/dotcom-rendering/src/grid.ts @@ -13,7 +13,7 @@ import { palette } from './palette'; * Named CSS grid lines, based on the three columns commonly used for Guardian * layouts. */ -type Line = +export type Line = | 'grid-start' | 'left-column-start' | 'left-column-end' diff --git a/dotcom-rendering/src/layouts/lib/furnitureLayouts.ts b/dotcom-rendering/src/layouts/lib/furnitureLayouts.ts index 45d7781a2a3..119afea1876 100644 --- a/dotcom-rendering/src/layouts/lib/furnitureLayouts.ts +++ b/dotcom-rendering/src/layouts/lib/furnitureLayouts.ts @@ -1,5 +1,6 @@ import { css, type SerializedStyles } from '@emotion/react'; import { from, until } from '@guardian/source/foundations'; +import { grid, type Line } from '../../grid'; export type LayoutType = 'standard' | 'matchReport' | 'media'; @@ -18,7 +19,7 @@ export type Area = type LayoutRows = Partial< Record< Area, - { mobile?: number; tablet?: number; leftCol?: number; desktop?: number } + { mobile?: number; tablet?: number; desktop?: number; leftCol?: number } > >; @@ -27,6 +28,7 @@ type BreakpointRows = Area[][]; type LayoutDefinition = { mobile?: BreakpointRows; tablet?: BreakpointRows; + desktop?: BreakpointRows; leftCol?: BreakpointRows; }; @@ -84,12 +86,37 @@ const furnitureRowLayouts: Record = { }, }; +type BreakpointColumns = Partial< + Record< + 'mobile' | 'tablet' | 'desktop' | 'leftCol', + Column | [Line | number, Line | number] + > +>; + +type ColumnLayoutMap = Partial>; + +const furnitureColumnLayouts: Record = { + standard: {}, + media: { + 'main-media': { + desktop: ['centre-column-start', 'right-column-start'], + }, + standfirst: { + desktop: ['centre-column-start', 'right-column-start'], + }, + body: { + desktop: ['centre-column-start', 'right-column-start'], + }, + }, + matchReport: {}, +}; + const buildRowMap = (layout: LayoutDefinition): LayoutRows => { const map: LayoutRows = {} as LayoutRows; const apply = ( rows: Area[][] | undefined, - breakpoint: 'mobile' | 'tablet' | 'leftCol', + breakpoint: 'mobile' | 'tablet' | 'desktop' | 'leftCol', ) => { if (!rows) return; @@ -105,6 +132,7 @@ const buildRowMap = (layout: LayoutDefinition): LayoutRows => { apply(layout.mobile, 'mobile'); apply(layout.tablet, 'tablet'); + apply(layout.desktop, 'desktop'); apply(layout.leftCol, 'leftCol'); return map; @@ -124,13 +152,20 @@ const breakpointQueries = { desktop: from.desktop, } as const; -export const rowCss = ( +type Column = 'left' | 'centre' | 'right'; + +type ColumnConfig = Partial>; + +export const gridCss = ( area: Area, layoutType: LayoutType, + columnsOverride?: ColumnConfig, ): SerializedStyles => { const rows = rowMaps[layoutType][area] ?? {}; + const columns = furnitureColumnLayouts[layoutType][area] ?? {}; - return css( + return css([ + grid.column.centre, // default Object.entries(rows).map( ([bp, row]) => css` ${breakpointQueries[bp as keyof typeof breakpointQueries]} { @@ -138,5 +173,24 @@ export const rowCss = ( } `, ), - ); + Object.entries(columns).map(([bp, colOrSpan]) => { + const colStyle = Array.isArray(colOrSpan) + ? grid.between(colOrSpan[0], colOrSpan[1]) + : grid.column[colOrSpan as keyof typeof grid.column]; + + return css` + ${from[bp as keyof typeof from]} { + ${colStyle}; + } + `; + }), + columnsOverride && + Object.entries(columnsOverride).map( + ([bp, col]) => css` + ${from[bp as keyof typeof from]} { + ${grid.column[col as keyof typeof grid.column]}; + } + `, + ), + ]); }; From 01cdd838527f67fae543e5f2878b65198c855f50 Mon Sep 17 00:00:00 2001 From: Frederick O'Brien Date: Wed, 11 Mar 2026 10:02:20 +0000 Subject: [PATCH 08/23] Dom review polishes --- dotcom-rendering/src/grid.ts | 19 ++++--------------- 1 file changed, 4 insertions(+), 15 deletions(-) diff --git a/dotcom-rendering/src/grid.ts b/dotcom-rendering/src/grid.ts index fdac13c8710..a1012e8c07b 100644 --- a/dotcom-rendering/src/grid.ts +++ b/dotcom-rendering/src/grid.ts @@ -13,7 +13,7 @@ import { palette } from './palette'; * Named CSS grid lines, based on the three columns commonly used for Guardian * layouts. */ -export type Line = +type Line = | 'grid-start' | 'left-column-start' | 'left-column-end' @@ -195,7 +195,7 @@ const verticalRules = (options: VerticalRuleOptions = {}): string => ` --centre-transform: translateX(-${columnGap}); ${fromBreakpoint.leftCol} { - --centre-transform: translateX(calc(${columnGap} / -2)); + --centre-transform: translateX(calc(-${columnGap} / 2)); } &::before, @@ -205,7 +205,7 @@ const verticalRules = (options: VerticalRuleOptions = {}): string => ` top: 0; bottom: 0; width: 1px; - background-color: ${themePalette('--article-border')}; + background-color: ${palette('--article-border')}; pointer-events: none; content: ''; } @@ -232,18 +232,7 @@ const verticalRules = (options: VerticalRuleOptions = {}): string => ` } } - ${ - options.centre - ? ` - /* CENTRE RULE */ - & > *:first-child::before { - grid-column: centre-column-start; - justify-self: start; - transform: var(--centre-transform); - }` - : '' - } - } + ${options.centre ? optionalCentreRule : ''} `; // ----- API ----- // From 3f00bf4854edc86795e54f5e6c94cbeb884fbdd6 Mon Sep 17 00:00:00 2001 From: Frederick O'Brien Date: Wed, 11 Mar 2026 12:25:39 +0000 Subject: [PATCH 09/23] Incorporate grid rows into furniture layout config --- .../src/layouts/lib/furnitureLayouts.ts | 121 ++++++++++++------ 1 file changed, 80 insertions(+), 41 deletions(-) diff --git a/dotcom-rendering/src/layouts/lib/furnitureLayouts.ts b/dotcom-rendering/src/layouts/lib/furnitureLayouts.ts index 119afea1876..35c30d531d8 100644 --- a/dotcom-rendering/src/layouts/lib/furnitureLayouts.ts +++ b/dotcom-rendering/src/layouts/lib/furnitureLayouts.ts @@ -16,11 +16,15 @@ export type Area = // Match report specific area | 'match-summary'; +type Breakpoint = 'mobile' | 'tablet' | 'desktop' | 'leftCol'; + +type RowPlacement = { + start: number; + span?: number; +}; + type LayoutRows = Partial< - Record< - Area, - { mobile?: number; tablet?: number; desktop?: number; leftCol?: number } - > + Record>> >; type BreakpointRows = Area[][]; @@ -32,37 +36,44 @@ type LayoutDefinition = { leftCol?: BreakpointRows; }; +const tabletVanillaRows: BreakpointRows = [ + ['title'], + ['headline'], + ['standfirst'], + ['main-media'], + ['meta'], + ['body'], +]; + const furnitureRowLayouts: Record = { standard: { - tablet: [ - ['title'], - ['headline'], - ['standfirst'], - ['main-media'], - ['meta'], + tablet: tabletVanillaRows, + desktop: [ + ['title', 'right-column'], + ['headline', 'right-column'], + ['standfirst', 'right-column'], + ['main-media', 'right-column'], + ['meta', 'right-column'], + ['body', 'right-column'], ], - leftCol: [ - ['title', 'headline'], - ['standfirst'], - ['meta', 'main-media'], + ['title', 'headline', 'right-column'], + ['standfirst', 'right-column'], + ['meta', 'main-media', 'right-column'], + ['body', 'right-column'], ], }, + matchReport: { - tablet: [ - ['match-summary'], - ['title'], - ['headline'], - ['standfirst'], - ['main-media'], - ['meta'], - ], + tablet: [['match-summary'], ...tabletVanillaRows], leftCol: [ ['title', 'match-summary'], ['headline'], ['meta', 'main-media'], + ['body', 'right-column'], ], }, + media: { mobile: [ ['title'], @@ -70,6 +81,7 @@ const furnitureRowLayouts: Record = { ['main-media'], ['standfirst'], ['meta'], + ['body'], ], tablet: [ ['title'], @@ -77,20 +89,29 @@ const furnitureRowLayouts: Record = { ['main-media'], ['standfirst'], ['meta'], + ['body'], + ], + desktop: [ + ['title'], + ['headline'], + ['main-media', 'right-column'], + ['standfirst', 'right-column'], + ['meta', 'right-column'], + ['body', 'right-column'], ], leftCol: [ ['title', 'headline'], - ['meta', 'main-media'], - ['standfirst'], + ['meta', 'main-media', 'right-column'], + ['meta', 'standfirst', 'right-column'], + ['body', 'right-column'], ], }, }; +type Column = 'left' | 'centre' | 'right'; + type BreakpointColumns = Partial< - Record< - 'mobile' | 'tablet' | 'desktop' | 'leftCol', - Column | [Line | number, Line | number] - > + Record >; type ColumnLayoutMap = Partial>; @@ -112,22 +133,37 @@ const furnitureColumnLayouts: Record = { }; const buildRowMap = (layout: LayoutDefinition): LayoutRows => { - const map: LayoutRows = {} as LayoutRows; + const map: LayoutRows = {}; const apply = ( - rows: Area[][] | undefined, - breakpoint: 'mobile' | 'tablet' | 'desktop' | 'leftCol', + rows: BreakpointRows | undefined, + breakpoint: Breakpoint, ) => { if (!rows) return; + const areaRows: Record = {}; + for (const [index, areas] of rows.entries()) { const row = index + 1; for (const area of areas) { - map[area] ??= {}; - map[area][breakpoint] = row; + areaRows[area] ??= []; + areaRows[area].push(row); } } + + for (const [area, rowList] of Object.entries(areaRows) as [ + Area, + number[], + ][]) { + const start = rowList[0]; + const span = rowList.length > 1 ? rowList.length : undefined; + + if (start == null) continue; + + map[area] ??= {}; + map[area][breakpoint] = { start, span }; + } }; apply(layout.mobile, 'mobile'); @@ -152,8 +188,6 @@ const breakpointQueries = { desktop: from.desktop, } as const; -type Column = 'left' | 'centre' | 'right'; - type ColumnConfig = Partial>; export const gridCss = ( @@ -166,13 +200,18 @@ export const gridCss = ( return css([ grid.column.centre, // default - Object.entries(rows).map( - ([bp, row]) => css` - ${breakpointQueries[bp as keyof typeof breakpointQueries]} { - grid-row: ${row}; + Object.entries(rows).map(([bp, placement]) => { + const rowValue = + placement.span != null + ? `${placement.start} / span ${placement.span}` + : placement.start; + + return css` + ${breakpointQueries[bp as Breakpoint]} { + grid-row: ${rowValue}; } - `, - ), + `; + }), Object.entries(columns).map(([bp, colOrSpan]) => { const colStyle = Array.isArray(colOrSpan) ? grid.between(colOrSpan[0], colOrSpan[1]) From c05dd6a3dde15150b4a0b2a2c966bea1ee806fad Mon Sep 17 00:00:00 2001 From: Frederick O'Brien Date: Wed, 11 Mar 2026 12:44:55 +0000 Subject: [PATCH 10/23] Type tidying --- dotcom-rendering/src/layouts/lib/furnitureLayouts.ts | 12 +++++------- 1 file changed, 5 insertions(+), 7 deletions(-) diff --git a/dotcom-rendering/src/layouts/lib/furnitureLayouts.ts b/dotcom-rendering/src/layouts/lib/furnitureLayouts.ts index 35c30d531d8..584592cf8df 100644 --- a/dotcom-rendering/src/layouts/lib/furnitureLayouts.ts +++ b/dotcom-rendering/src/layouts/lib/furnitureLayouts.ts @@ -1,6 +1,6 @@ import { css, type SerializedStyles } from '@emotion/react'; import { from, until } from '@guardian/source/foundations'; -import { grid, type Line } from '../../grid'; +import { type ColumnPreset, grid, type Line } from '../../grid'; export type LayoutType = 'standard' | 'matchReport' | 'media'; @@ -108,10 +108,8 @@ const furnitureRowLayouts: Record = { }, }; -type Column = 'left' | 'centre' | 'right'; - type BreakpointColumns = Partial< - Record + Record >; type ColumnLayoutMap = Partial>; @@ -188,7 +186,7 @@ const breakpointQueries = { desktop: from.desktop, } as const; -type ColumnConfig = Partial>; +type ColumnConfig = Partial>; export const gridCss = ( area: Area, @@ -215,7 +213,7 @@ export const gridCss = ( Object.entries(columns).map(([bp, colOrSpan]) => { const colStyle = Array.isArray(colOrSpan) ? grid.between(colOrSpan[0], colOrSpan[1]) - : grid.column[colOrSpan as keyof typeof grid.column]; + : grid.column[colOrSpan]; return css` ${from[bp as keyof typeof from]} { @@ -227,7 +225,7 @@ export const gridCss = ( Object.entries(columnsOverride).map( ([bp, col]) => css` ${from[bp as keyof typeof from]} { - ${grid.column[col as keyof typeof grid.column]}; + ${grid.column[col]}; } `, ), From 20963b2a21ed78f705a3015b9512239b9c1edc07 Mon Sep 17 00:00:00 2001 From: Frederick O'Brien Date: Wed, 11 Mar 2026 13:00:05 +0000 Subject: [PATCH 11/23] Move all column settings into layout config --- .../src/layouts/lib/furnitureLayouts.ts | 26 +++++++++++++++---- 1 file changed, 21 insertions(+), 5 deletions(-) diff --git a/dotcom-rendering/src/layouts/lib/furnitureLayouts.ts b/dotcom-rendering/src/layouts/lib/furnitureLayouts.ts index 584592cf8df..65d1f049443 100644 --- a/dotcom-rendering/src/layouts/lib/furnitureLayouts.ts +++ b/dotcom-rendering/src/layouts/lib/furnitureLayouts.ts @@ -66,10 +66,19 @@ const furnitureRowLayouts: Record = { matchReport: { tablet: [['match-summary'], ...tabletVanillaRows], + desktop: [ + ['match-summary', 'right-column'], + ['title', 'right-column'], + ['headline', 'right-column'], + ['standfirst', 'right-column'], + ['main-media', 'right-column'], + ['meta', 'right-column'], + ['body', 'right-column'], + ], leftCol: [ - ['title', 'match-summary'], - ['headline'], - ['meta', 'main-media'], + ['title', 'match-summary', 'right-column'], + ['headline', 'right-column'], + ['meta', 'main-media', 'right-column'], ['body', 'right-column'], ], }, @@ -114,9 +123,16 @@ type BreakpointColumns = Partial< type ColumnLayoutMap = Partial>; +const furnitureColumnDefaults: ColumnLayoutMap = { + title: { leftCol: 'left' }, + meta: { leftCol: 'left' }, + ['right-column']: { desktop: 'right' }, +}; + const furnitureColumnLayouts: Record = { - standard: {}, + standard: furnitureColumnDefaults, media: { + ...furnitureColumnDefaults, 'main-media': { desktop: ['centre-column-start', 'right-column-start'], }, @@ -127,7 +143,7 @@ const furnitureColumnLayouts: Record = { desktop: ['centre-column-start', 'right-column-start'], }, }, - matchReport: {}, + matchReport: furnitureColumnDefaults, }; const buildRowMap = (layout: LayoutDefinition): LayoutRows => { From 7cf930866eb047be353a08588d4c376b94f6ad68 Mon Sep 17 00:00:00 2001 From: Frederick O'Brien Date: Thu, 12 Mar 2026 10:39:02 +0000 Subject: [PATCH 12/23] Deduplicate config --- .../src/layouts/lib/furnitureLayouts.ts | 28 ++++++++----------- 1 file changed, 11 insertions(+), 17 deletions(-) diff --git a/dotcom-rendering/src/layouts/lib/furnitureLayouts.ts b/dotcom-rendering/src/layouts/lib/furnitureLayouts.ts index 65d1f049443..27e3d575a37 100644 --- a/dotcom-rendering/src/layouts/lib/furnitureLayouts.ts +++ b/dotcom-rendering/src/layouts/lib/furnitureLayouts.ts @@ -45,17 +45,19 @@ const tabletVanillaRows: BreakpointRows = [ ['body'], ]; +const desktopVanillaRows: BreakpointRows = [ + ['title', 'right-column'], + ['headline', 'right-column'], + ['standfirst', 'right-column'], + ['main-media', 'right-column'], + ['meta', 'right-column'], + ['body', 'right-column'], +]; + const furnitureRowLayouts: Record = { standard: { tablet: tabletVanillaRows, - desktop: [ - ['title', 'right-column'], - ['headline', 'right-column'], - ['standfirst', 'right-column'], - ['main-media', 'right-column'], - ['meta', 'right-column'], - ['body', 'right-column'], - ], + desktop: desktopVanillaRows, leftCol: [ ['title', 'headline', 'right-column'], ['standfirst', 'right-column'], @@ -66,15 +68,7 @@ const furnitureRowLayouts: Record = { matchReport: { tablet: [['match-summary'], ...tabletVanillaRows], - desktop: [ - ['match-summary', 'right-column'], - ['title', 'right-column'], - ['headline', 'right-column'], - ['standfirst', 'right-column'], - ['main-media', 'right-column'], - ['meta', 'right-column'], - ['body', 'right-column'], - ], + desktop: [['match-summary', 'right-column'], ...desktopVanillaRows], leftCol: [ ['title', 'match-summary', 'right-column'], ['headline', 'right-column'], From d8e10b1adbfd56166556c503a2038da473487c6a Mon Sep 17 00:00:00 2001 From: Frederick O'Brien Date: Thu, 12 Mar 2026 17:08:23 +0000 Subject: [PATCH 13/23] Adjust standard layout to sit properly when there's no featured image --- dotcom-rendering/src/layouts/lib/furnitureLayouts.ts | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/dotcom-rendering/src/layouts/lib/furnitureLayouts.ts b/dotcom-rendering/src/layouts/lib/furnitureLayouts.ts index 27e3d575a37..0bd27a45516 100644 --- a/dotcom-rendering/src/layouts/lib/furnitureLayouts.ts +++ b/dotcom-rendering/src/layouts/lib/furnitureLayouts.ts @@ -62,7 +62,7 @@ const furnitureRowLayouts: Record = { ['title', 'headline', 'right-column'], ['standfirst', 'right-column'], ['meta', 'main-media', 'right-column'], - ['body', 'right-column'], + ['meta', 'body', 'right-column'], ], }, @@ -73,7 +73,7 @@ const furnitureRowLayouts: Record = { ['title', 'match-summary', 'right-column'], ['headline', 'right-column'], ['meta', 'main-media', 'right-column'], - ['body', 'right-column'], + ['meta', 'body', 'right-column'], ], }, @@ -106,7 +106,7 @@ const furnitureRowLayouts: Record = { ['title', 'headline'], ['meta', 'main-media', 'right-column'], ['meta', 'standfirst', 'right-column'], - ['body', 'right-column'], + ['meta', 'body', 'right-column'], ], }, }; From 8cf863c4977fb0c79b848a51dd5411f136b3f18b Mon Sep 17 00:00:00 2001 From: Frederick O'Brien Date: Tue, 17 Mar 2026 17:04:09 +0000 Subject: [PATCH 14/23] Tidying --- .../src/layouts/lib/furnitureLayouts.ts | 98 +++++++++---------- 1 file changed, 48 insertions(+), 50 deletions(-) diff --git a/dotcom-rendering/src/layouts/lib/furnitureLayouts.ts b/dotcom-rendering/src/layouts/lib/furnitureLayouts.ts index 0bd27a45516..9d949881595 100644 --- a/dotcom-rendering/src/layouts/lib/furnitureLayouts.ts +++ b/dotcom-rendering/src/layouts/lib/furnitureLayouts.ts @@ -18,25 +18,8 @@ export type Area = type Breakpoint = 'mobile' | 'tablet' | 'desktop' | 'leftCol'; -type RowPlacement = { - start: number; - span?: number; -}; - -type LayoutRows = Partial< - Record>> ->; - -type BreakpointRows = Area[][]; - -type LayoutDefinition = { - mobile?: BreakpointRows; - tablet?: BreakpointRows; - desktop?: BreakpointRows; - leftCol?: BreakpointRows; -}; - -const tabletVanillaRows: BreakpointRows = [ +// Rows config +const tabletVanillaRows: Rows = [ ['title'], ['headline'], ['standfirst'], @@ -45,7 +28,7 @@ const tabletVanillaRows: BreakpointRows = [ ['body'], ]; -const desktopVanillaRows: BreakpointRows = [ +const desktopVanillaRows: Rows = [ ['title', 'right-column'], ['headline', 'right-column'], ['standfirst', 'right-column'], @@ -54,6 +37,15 @@ const desktopVanillaRows: BreakpointRows = [ ['body', 'right-column'], ]; +const mediaRowsUntilDesktop: Rows = [ + ['title'], + ['headline'], + ['main-media'], + ['standfirst'], + ['meta'], + ['body'], +]; + const furnitureRowLayouts: Record = { standard: { tablet: tabletVanillaRows, @@ -78,22 +70,8 @@ const furnitureRowLayouts: Record = { }, media: { - mobile: [ - ['title'], - ['headline'], - ['main-media'], - ['standfirst'], - ['meta'], - ['body'], - ], - tablet: [ - ['title'], - ['headline'], - ['main-media'], - ['standfirst'], - ['meta'], - ['body'], - ], + mobile: mediaRowsUntilDesktop, + tablet: mediaRowsUntilDesktop, desktop: [ ['title'], ['headline'], @@ -111,12 +89,7 @@ const furnitureRowLayouts: Record = { }, }; -type BreakpointColumns = Partial< - Record ->; - -type ColumnLayoutMap = Partial>; - +// Columns config const furnitureColumnDefaults: ColumnLayoutMap = { title: { leftCol: 'left' }, meta: { leftCol: 'left' }, @@ -140,13 +113,36 @@ const furnitureColumnLayouts: Record = { matchReport: furnitureColumnDefaults, }; -const buildRowMap = (layout: LayoutDefinition): LayoutRows => { - const map: LayoutRows = {}; +// Types +type RowPlacement = { + start: number; + span?: number; +}; + +type CompiledLayout = Partial< + Record>> +>; - const apply = ( - rows: BreakpointRows | undefined, - breakpoint: Breakpoint, - ) => { +type Rows = Area[][]; + +type LayoutDefinition = { + mobile?: Rows; + tablet?: Rows; + desktop?: Rows; + leftCol?: Rows; +}; + +type BreakpointColumns = Partial< + Record +>; + +type ColumnLayoutMap = Partial>; + +// Guardian Grid CSS generation +const buildRowMap = (layout: LayoutDefinition): CompiledLayout => { + const map: CompiledLayout = {}; + + const apply = (rows: Rows | undefined, breakpoint: Breakpoint) => { if (!rows) return; const areaRows: Record = {}; @@ -187,7 +183,7 @@ const rowMaps = Object.fromEntries( name, buildRowMap(layout), ]), -) as Record; +) as Record; const breakpointQueries = { mobile: until.tablet, @@ -203,11 +199,13 @@ export const gridCss = ( layoutType: LayoutType, columnsOverride?: ColumnConfig, ): SerializedStyles => { + const defaultColumn = grid.column.centre; + const rows = rowMaps[layoutType][area] ?? {}; const columns = furnitureColumnLayouts[layoutType][area] ?? {}; return css([ - grid.column.centre, // default + defaultColumn, Object.entries(rows).map(([bp, placement]) => { const rowValue = placement.span != null From 6c08bac3f09d301544e1490dddee143b01601481 Mon Sep 17 00:00:00 2001 From: Frederick O'Brien Date: Wed, 18 Mar 2026 16:49:17 +0000 Subject: [PATCH 15/23] Rebase tidy --- .../src/layouts/lib/furnitureLayouts.ts | 20 ++----------------- 1 file changed, 2 insertions(+), 18 deletions(-) diff --git a/dotcom-rendering/src/layouts/lib/furnitureLayouts.ts b/dotcom-rendering/src/layouts/lib/furnitureLayouts.ts index 9d949881595..49ce382b395 100644 --- a/dotcom-rendering/src/layouts/lib/furnitureLayouts.ts +++ b/dotcom-rendering/src/layouts/lib/furnitureLayouts.ts @@ -2,19 +2,16 @@ import { css, type SerializedStyles } from '@emotion/react'; import { from, until } from '@guardian/source/foundations'; import { type ColumnPreset, grid, type Line } from '../../grid'; -export type LayoutType = 'standard' | 'matchReport' | 'media'; +export type LayoutType = 'standard' | 'media'; export type Area = - // Common areas | 'title' | 'headline' | 'standfirst' | 'main-media' | 'meta' | 'body' - | 'right-column' - // Match report specific area - | 'match-summary'; + | 'right-column'; type Breakpoint = 'mobile' | 'tablet' | 'desktop' | 'leftCol'; @@ -57,18 +54,6 @@ const furnitureRowLayouts: Record = { ['meta', 'body', 'right-column'], ], }, - - matchReport: { - tablet: [['match-summary'], ...tabletVanillaRows], - desktop: [['match-summary', 'right-column'], ...desktopVanillaRows], - leftCol: [ - ['title', 'match-summary', 'right-column'], - ['headline', 'right-column'], - ['meta', 'main-media', 'right-column'], - ['meta', 'body', 'right-column'], - ], - }, - media: { mobile: mediaRowsUntilDesktop, tablet: mediaRowsUntilDesktop, @@ -110,7 +95,6 @@ const furnitureColumnLayouts: Record = { desktop: ['centre-column-start', 'right-column-start'], }, }, - matchReport: furnitureColumnDefaults, }; // Types From 29caa455c1c7e9fc07c7897096953c753cc426ed Mon Sep 17 00:00:00 2001 From: Frederick O'Brien Date: Wed, 15 Apr 2026 17:14:36 +0100 Subject: [PATCH 16/23] Rename furnitureLayouts to furnitureArrangements --- .../layouts/lib/{furnitureLayouts.ts => furnitureArrangements.ts} | 0 1 file changed, 0 insertions(+), 0 deletions(-) rename dotcom-rendering/src/layouts/lib/{furnitureLayouts.ts => furnitureArrangements.ts} (100%) diff --git a/dotcom-rendering/src/layouts/lib/furnitureLayouts.ts b/dotcom-rendering/src/layouts/lib/furnitureArrangements.ts similarity index 100% rename from dotcom-rendering/src/layouts/lib/furnitureLayouts.ts rename to dotcom-rendering/src/layouts/lib/furnitureArrangements.ts From 73ceca5a2c62643ab44c21dc0eed60888883aa3d Mon Sep 17 00:00:00 2001 From: Frederick O'Brien Date: Tue, 21 Apr 2026 12:23:14 +0100 Subject: [PATCH 17/23] Review suggestions Co-Authored-By: Jamie B <53781962+JamieB-gu@users.noreply.github.com> --- dotcom-rendering/src/grid.ts | 8 -------- 1 file changed, 8 deletions(-) diff --git a/dotcom-rendering/src/grid.ts b/dotcom-rendering/src/grid.ts index a1012e8c07b..9d813201ee6 100644 --- a/dotcom-rendering/src/grid.ts +++ b/dotcom-rendering/src/grid.ts @@ -192,12 +192,6 @@ const verticalRules = (options: VerticalRuleOptions = {}): string => ` ${fromBreakpoint.tablet} { position: relative; - --centre-transform: translateX(-${columnGap}); - - ${fromBreakpoint.leftCol} { - --centre-transform: translateX(calc(-${columnGap} / 2)); - } - &::before, &::after ${options.centre ? ', & > *:first-child::before' : ''} { @@ -213,7 +207,6 @@ const verticalRules = (options: VerticalRuleOptions = {}): string => ` /* LEFT OUTER RULE */ &::before { grid-column: centre-column-start; - justify-self: start; transform: translateX(-${columnGap}); ${fromBreakpoint.leftCol} { @@ -224,7 +217,6 @@ const verticalRules = (options: VerticalRuleOptions = {}): string => ` /* RIGHT OUTER RULE */ &::after { grid-column: right-column-end; - justify-self: start; transform: translateX(-1px); ${betweenBreakpoint.tablet.and.desktop} { From e52a33f1a78d8cf2ecb96eec86f6129660a2c82d Mon Sep 17 00:00:00 2001 From: Frederick O'Brien Date: Wed, 22 Apr 2026 13:35:29 +0100 Subject: [PATCH 18/23] Readability improvements --- .../src/layouts/lib/furnitureArrangements.ts | 105 +++++++++--------- 1 file changed, 50 insertions(+), 55 deletions(-) diff --git a/dotcom-rendering/src/layouts/lib/furnitureArrangements.ts b/dotcom-rendering/src/layouts/lib/furnitureArrangements.ts index 49ce382b395..6ab827df7ab 100644 --- a/dotcom-rendering/src/layouts/lib/furnitureArrangements.ts +++ b/dotcom-rendering/src/layouts/lib/furnitureArrangements.ts @@ -16,7 +16,7 @@ export type Area = type Breakpoint = 'mobile' | 'tablet' | 'desktop' | 'leftCol'; // Rows config -const tabletVanillaRows: Rows = [ +const tabletStandardRows: Rows = [ ['title'], ['headline'], ['standfirst'], @@ -25,7 +25,7 @@ const tabletVanillaRows: Rows = [ ['body'], ]; -const desktopVanillaRows: Rows = [ +const desktopStandardRows: Rows = [ ['title', 'right-column'], ['headline', 'right-column'], ['standfirst', 'right-column'], @@ -43,10 +43,10 @@ const mediaRowsUntilDesktop: Rows = [ ['body'], ]; -const furnitureRowLayouts: Record = { +const furnitureRowArrangements: Record = { standard: { - tablet: tabletVanillaRows, - desktop: desktopVanillaRows, + tablet: tabletStandardRows, + desktop: desktopStandardRows, leftCol: [ ['title', 'headline', 'right-column'], ['standfirst', 'right-column'], @@ -75,13 +75,13 @@ const furnitureRowLayouts: Record = { }; // Columns config -const furnitureColumnDefaults: ColumnLayoutMap = { +const furnitureColumnDefaults: ColumnArrangementMap = { title: { leftCol: 'left' }, meta: { leftCol: 'left' }, ['right-column']: { desktop: 'right' }, }; -const furnitureColumnLayouts: Record = { +const furnitureColumnArrangements: Record = { standard: furnitureColumnDefaults, media: { ...furnitureColumnDefaults, @@ -103,13 +103,13 @@ type RowPlacement = { span?: number; }; -type CompiledLayout = Partial< +type CompiledArrangement = Partial< Record>> >; type Rows = Area[][]; -type LayoutDefinition = { +type ArrangementDefinition = { mobile?: Rows; tablet?: Rows; desktop?: Rows; @@ -120,19 +120,25 @@ type BreakpointColumns = Partial< Record >; -type ColumnLayoutMap = Partial>; +type ColumnArrangementMap = Partial>; // Guardian Grid CSS generation -const buildRowMap = (layout: LayoutDefinition): CompiledLayout => { - const map: CompiledLayout = {}; - - const apply = (rows: Rows | undefined, breakpoint: Breakpoint) => { +const buildRowMap = ( + arrangement: ArrangementDefinition, +): CompiledArrangement => { + const map: CompiledArrangement = {}; + + const collectRowsForBreakpoint = ( + rows: Rows | undefined, + breakpoint: Breakpoint, + ) => { if (!rows) return; const areaRows: Record = {}; for (const [index, areas] of rows.entries()) { - const row = index + 1; + const cssGridOffset = 1; // CSS grid is 1-indexed + const row = index + cssGridOffset; for (const area of areas) { areaRows[area] ??= []; @@ -154,20 +160,20 @@ const buildRowMap = (layout: LayoutDefinition): CompiledLayout => { } }; - apply(layout.mobile, 'mobile'); - apply(layout.tablet, 'tablet'); - apply(layout.desktop, 'desktop'); - apply(layout.leftCol, 'leftCol'); + collectRowsForBreakpoint(arrangement.mobile, 'mobile'); + collectRowsForBreakpoint(arrangement.tablet, 'tablet'); + collectRowsForBreakpoint(arrangement.desktop, 'desktop'); + collectRowsForBreakpoint(arrangement.leftCol, 'leftCol'); return map; }; const rowMaps = Object.fromEntries( - Object.entries(furnitureRowLayouts).map(([name, layout]) => [ + Object.entries(furnitureRowArrangements).map(([name, arrangement]) => [ name, - buildRowMap(layout), + buildRowMap(arrangement), ]), -) as Record; +) as Record; const breakpointQueries = { mobile: until.tablet, @@ -176,33 +182,28 @@ const breakpointQueries = { desktop: from.desktop, } as const; -type ColumnConfig = Partial>; - -export const gridCss = ( +export const gridItemCss = ( area: Area, layoutType: LayoutType, - columnsOverride?: ColumnConfig, ): SerializedStyles => { - const defaultColumn = grid.column.centre; - const rows = rowMaps[layoutType][area] ?? {}; - const columns = furnitureColumnLayouts[layoutType][area] ?? {}; - - return css([ - defaultColumn, - Object.entries(rows).map(([bp, placement]) => { - const rowValue = - placement.span != null - ? `${placement.start} / span ${placement.span}` - : placement.start; - - return css` - ${breakpointQueries[bp as Breakpoint]} { - grid-row: ${rowValue}; - } - `; - }), - Object.entries(columns).map(([bp, colOrSpan]) => { + const columns = furnitureColumnArrangements[layoutType][area] ?? {}; + + const defaultColumnCss = grid.column.centre; + const rowPlacementCss = Object.entries(rows).map(([bp, placement]) => { + const rowValue = + placement.span != null + ? `${placement.start} / span ${placement.span}` + : placement.start; + + return css` + ${breakpointQueries[bp as Breakpoint]} { + grid-row: ${rowValue}; + } + `; + }); + const columnPlacementCss = Object.entries(columns).map( + ([bp, colOrSpan]) => { const colStyle = Array.isArray(colOrSpan) ? grid.between(colOrSpan[0], colOrSpan[1]) : grid.column[colOrSpan]; @@ -212,14 +213,8 @@ export const gridCss = ( ${colStyle}; } `; - }), - columnsOverride && - Object.entries(columnsOverride).map( - ([bp, col]) => css` - ${from[bp as keyof typeof from]} { - ${grid.column[col]}; - } - `, - ), - ]); + }, + ); + + return css([defaultColumnCss, rowPlacementCss, columnPlacementCss]); }; From 24f5558a90de95de1bedf614a2ed0d8903dec6b8 Mon Sep 17 00:00:00 2001 From: Frederick O'Brien Date: Wed, 22 Apr 2026 15:20:50 +0100 Subject: [PATCH 19/23] Documentation --- .../src/layouts/lib/furnitureArrangements.ts | 41 ++++++++++++++++++- 1 file changed, 40 insertions(+), 1 deletion(-) diff --git a/dotcom-rendering/src/layouts/lib/furnitureArrangements.ts b/dotcom-rendering/src/layouts/lib/furnitureArrangements.ts index 6ab827df7ab..1c1ad4741aa 100644 --- a/dotcom-rendering/src/layouts/lib/furnitureArrangements.ts +++ b/dotcom-rendering/src/layouts/lib/furnitureArrangements.ts @@ -122,7 +122,27 @@ type BreakpointColumns = Partial< type ColumnArrangementMap = Partial>; -// Guardian Grid CSS generation +/** + * Converts the human-readable {@link furnitureRowArrangements} into a lookup table + * of CSS grid row positions ready for use in {@link gridItemCss}. + * + * Each area in the arrangement is assigned a `start` row number and, if it + * appears in multiple consecutive rows (e.g. a sidebar), a `span` count. + * + * @example + * // Given this arrangement for 'desktop': + * [ + * ['title', 'right-column'], // row 1 + * ['body', 'right-column'], // row 2 + * ] + * + * // Produces: + * { + * title: { desktop: { start: 1 } }, + * body: { desktop: { start: 2 } }, + * 'right-column': { desktop: { start: 1, span: 2 } }, + * } + */ const buildRowMap = ( arrangement: ArrangementDefinition, ): CompiledArrangement => { @@ -182,6 +202,25 @@ const breakpointQueries = { desktop: from.desktop, } as const; +/** + * Returns the Emotion CSS needed to position a single grid item — its + * default column, its row at each breakpoint, and any column overrides. + * The grid item _must_ be inside a {@link grid} module container. + * + * The output is built from three layers, applied in order (later wins): + * 1. **Default column** — all items start in the centre column. + * 2. **Row placement** — `grid-row` values per breakpoint, derived from + * {@link furnitureRowArrangements} via the pre-computed {@link rowMaps}. + * 3. **Column placement** — column overrides per breakpoint from + * {@link furnitureColumnArrangements} (e.g. `meta` shifts left on wide screens). + * + * @param area - The named piece of article furniture to position (e.g. `'headline'`, `'body'`). + * @param layoutType - Either `'standard'` or `'media'`. + * + * @example + * // In a React component: + *
+ */ export const gridItemCss = ( area: Area, layoutType: LayoutType, From 31232d45f2bd6251ddd9aec6974447e39f20b32e Mon Sep 17 00:00:00 2001 From: Frederick O'Brien Date: Tue, 28 Apr 2026 12:58:30 +0100 Subject: [PATCH 20/23] Replace abstraction with vanilla CSS approach --- .../src/layouts/StandardLayout.tsx | 1 - .../src/layouts/lib/furnitureArrangements.ts | 309 ++++++------------ 2 files changed, 100 insertions(+), 210 deletions(-) diff --git a/dotcom-rendering/src/layouts/StandardLayout.tsx b/dotcom-rendering/src/layouts/StandardLayout.tsx index 4942ac6c129..8c304056f3f 100644 --- a/dotcom-rendering/src/layouts/StandardLayout.tsx +++ b/dotcom-rendering/src/layouts/StandardLayout.tsx @@ -259,7 +259,6 @@ export const StandardLayout = (props: WebProps | AppProps) => { )} - {/* GridItem order matters — mobile layout relies on DOM order for grid placement. See furnitureArrangements.ts if reordering. */}
= { + mobile: until.tablet, + tablet: from.tablet, + desktop: from.desktop, + leftCol: from.leftCol, +}; -const desktopStandardRows: Rows = [ - ['title', 'right-column'], - ['headline', 'right-column'], - ['standfirst', 'right-column'], - ['main-media', 'right-column'], - ['meta', 'right-column'], - ['body', 'right-column'], -]; +// Raw CSS overrides per area per breakpoint. Entries are only needed when an area +// deviates from the default: centre column, single-column mobile layout with areas +// in DOM order (main-media → title → headline → standfirst → meta → body → right-column). -const mediaRowsUntilDesktop: Rows = [ - ['title'], - ['headline'], - ['main-media'], - ['standfirst'], - ['meta'], - ['body'], -]; +type AreaCss = Partial>; +type LayoutCssMap = Partial>; -const furnitureRowArrangements: Record = { - standard: { - tablet: tabletStandardRows, - desktop: desktopStandardRows, - leftCol: [ - ['title', 'headline', 'right-column'], - ['standfirst', 'right-column'], - ['meta', 'main-media', 'right-column'], - ['meta', 'body', 'right-column'], - ], +const standardCss: LayoutCssMap = { + title: { + tablet: 'grid-row: 1;', + leftCol: + 'grid-row: 1; grid-column: left-column-start / left-column-end;', }, - media: { - mobile: mediaRowsUntilDesktop, - tablet: mediaRowsUntilDesktop, - desktop: [ - ['title'], - ['headline'], - ['main-media', 'right-column'], - ['standfirst', 'right-column'], - ['meta', 'right-column'], - ['body', 'right-column'], - ], - leftCol: [ - ['title', 'headline'], - ['meta', 'main-media', 'right-column'], - ['meta', 'standfirst', 'right-column'], - ['meta', 'body', 'right-column'], - ], + headline: { + tablet: 'grid-row: 2;', + leftCol: 'grid-row: 1;', }, -}; - -// Columns config -const furnitureColumnDefaults: ColumnArrangementMap = { - title: { leftCol: 'left' }, - meta: { leftCol: 'left' }, - ['right-column']: { desktop: 'right' }, -}; - -const furnitureColumnArrangements: Record = { - standard: furnitureColumnDefaults, - media: { - ...furnitureColumnDefaults, - 'main-media': { - desktop: ['centre-column-start', 'right-column-start'], - }, - standfirst: { - desktop: ['centre-column-start', 'right-column-start'], - }, - body: { - desktop: ['centre-column-start', 'right-column-start'], - }, + standfirst: { + tablet: 'grid-row: 3;', + leftCol: 'grid-row: 2;', + }, + 'main-media': { + tablet: 'grid-row: 4;', + leftCol: 'grid-row: 3;', + }, + meta: { + tablet: 'grid-row: 5;', + leftCol: + 'grid-row: 3 / span 2; grid-column: left-column-start / left-column-end;', + }, + body: { + tablet: 'grid-row: 6;', + leftCol: 'grid-row: 4;', + }, + 'right-column': { + desktop: + 'grid-row: 1 / span 6; grid-column: right-column-start / right-column-end;', + leftCol: + 'grid-row: 1 / span 4; grid-column: right-column-start / right-column-end;', }, }; -// Types -type RowPlacement = { - start: number; - span?: number; -}; - -type CompiledArrangement = Partial< - Record>> ->; - -type Rows = Area[][]; - -type ArrangementDefinition = { - mobile?: Rows; - tablet?: Rows; - desktop?: Rows; - leftCol?: Rows; +const mediaCss: LayoutCssMap = { + title: { + mobile: 'grid-row: 1;', + leftCol: + 'grid-row: 1; grid-column: left-column-start / left-column-end;', + }, + headline: { + mobile: 'grid-row: 2;', + leftCol: 'grid-row: 1;', + }, + 'main-media': { + mobile: 'grid-row: 3;', + desktop: + 'grid-row: 3; grid-column: centre-column-start / right-column-start;', + leftCol: + 'grid-row: 2; grid-column: centre-column-start / right-column-start;', + }, + standfirst: { + mobile: 'grid-row: 4;', + desktop: + 'grid-row: 4; grid-column: centre-column-start / right-column-start;', + leftCol: + 'grid-row: 3; grid-column: centre-column-start / right-column-start;', + }, + meta: { + mobile: 'grid-row: 5;', + leftCol: + 'grid-row: 2 / span 3; grid-column: left-column-start / left-column-end;', + }, + body: { + desktop: + 'grid-row: 6; grid-column: centre-column-start / right-column-start;', + leftCol: + 'grid-row: 4; grid-column: centre-column-start / right-column-start;', + }, + 'right-column': { + desktop: + 'grid-row: 3 / span 4; grid-column: right-column-start / right-column-end;', + leftCol: + 'grid-row: 2 / span 3; grid-column: right-column-start / right-column-end;', + }, }; -type BreakpointColumns = Partial< - Record ->; - -type ColumnArrangementMap = Partial>; - -/** - * Converts the human-readable {@link furnitureRowArrangements} into a lookup table - * of CSS grid row positions ready for use in {@link gridItemCss}. - * - * Each area in the arrangement is assigned a `start` row number and, if it - * appears in multiple consecutive rows (e.g. a sidebar), a `span` count. - * - * @example - * // Given this arrangement for 'desktop': - * [ - * ['title', 'right-column'], // row 1 - * ['body', 'right-column'], // row 2 - * ] - * - * // Produces: - * { - * title: { desktop: { start: 1 } }, - * body: { desktop: { start: 2 } }, - * 'right-column': { desktop: { start: 1, span: 2 } }, - * } - */ -const buildRowMap = ( - arrangement: ArrangementDefinition, -): CompiledArrangement => { - const map: CompiledArrangement = {}; - - const collectRowsForBreakpoint = ( - rows: Rows | undefined, - breakpoint: Breakpoint, - ) => { - if (!rows) return; - - const areaRows: Record = {}; - - for (const [index, areas] of rows.entries()) { - const cssGridOffset = 1; // CSS grid is 1-indexed - const row = index + cssGridOffset; - - for (const area of areas) { - areaRows[area] ??= []; - areaRows[area].push(row); - } - } - - for (const [area, rowList] of Object.entries(areaRows) as [ - Area, - number[], - ][]) { - const start = rowList[0]; - const span = rowList.length > 1 ? rowList.length : undefined; - - if (start == null) continue; - - map[area] ??= {}; - map[area][breakpoint] = { start, span }; - } - }; - - collectRowsForBreakpoint(arrangement.mobile, 'mobile'); - collectRowsForBreakpoint(arrangement.tablet, 'tablet'); - collectRowsForBreakpoint(arrangement.desktop, 'desktop'); - collectRowsForBreakpoint(arrangement.leftCol, 'leftCol'); - - return map; +const layoutCssMaps: Record = { + standard: standardCss, + media: mediaCss, }; -const rowMaps = Object.fromEntries( - Object.entries(furnitureRowArrangements).map(([name, arrangement]) => [ - name, - buildRowMap(arrangement), - ]), -) as Record; - -const breakpointQueries = { - mobile: until.tablet, - tablet: from.tablet, - leftCol: from.leftCol, - desktop: from.desktop, -} as const; - /** * Returns the Emotion CSS needed to position a single grid item — its * default column, its row at each breakpoint, and any column overrides. * The grid item _must_ be inside a {@link grid} module container. * - * The output is built from three layers, applied in order (later wins): - * 1. **Default column** — all items start in the centre column. - * 2. **Row placement** — `grid-row` values per breakpoint, derived from - * {@link furnitureRowArrangements} via the pre-computed {@link rowMaps}. - * 3. **Column placement** — column overrides per breakpoint from - * {@link furnitureColumnArrangements} (e.g. `meta` shifts left on wide screens). + * All items default to the centre column. Per-breakpoint overrides for + * `grid-row` and `grid-column` are applied on top via media queries, + * looked up from the plain CSS maps defined in this file. * * @param area - The named piece of article furniture to position (e.g. `'headline'`, `'body'`). - * @param layoutType - Either `'standard'` or `'media'`. + * @param layoutType - See {@link LayoutType}. Determines which CSS map to use for lookups. * * @example * // In a React component: @@ -225,35 +131,20 @@ export const gridItemCss = ( area: Area, layoutType: LayoutType, ): SerializedStyles => { - const rows = rowMaps[layoutType][area] ?? {}; - const columns = furnitureColumnArrangements[layoutType][area] ?? {}; - - const defaultColumnCss = grid.column.centre; - const rowPlacementCss = Object.entries(rows).map(([bp, placement]) => { - const rowValue = - placement.span != null - ? `${placement.start} / span ${placement.span}` - : placement.start; + const areaOverrides = layoutCssMaps[layoutType][area] ?? {}; - return css` + const breakpointCss = Object.entries(areaOverrides).map( + ([bp, styles]) => css` ${breakpointQueries[bp as Breakpoint]} { - grid-row: ${rowValue}; + ${styles} } - `; - }); - const columnPlacementCss = Object.entries(columns).map( - ([bp, colOrSpan]) => { - const colStyle = Array.isArray(colOrSpan) - ? grid.between(colOrSpan[0], colOrSpan[1]) - : grid.column[colOrSpan]; - - return css` - ${from[bp as keyof typeof from]} { - ${colStyle}; - } - `; - }, + `, ); - return css([defaultColumnCss, rowPlacementCss, columnPlacementCss]); + // All items default to the centre column; breakpoint entries above + // override grid-row and grid-column as needed. + return css` + grid-column: centre-column-start / centre-column-end; + ${breakpointCss} + `; }; From b26decf7e60381f0dfa8c77cea64df6b73222548 Mon Sep 17 00:00:00 2001 From: Frederick O'Brien Date: Wed, 29 Apr 2026 11:06:37 +0100 Subject: [PATCH 21/23] Furniture arrangement abstractrion --- .../src/layouts/lib/furnitureArrangements.ts | 240 ++++++++++-------- 1 file changed, 140 insertions(+), 100 deletions(-) diff --git a/dotcom-rendering/src/layouts/lib/furnitureArrangements.ts b/dotcom-rendering/src/layouts/lib/furnitureArrangements.ts index 74207883dff..c18dd689be5 100644 --- a/dotcom-rendering/src/layouts/lib/furnitureArrangements.ts +++ b/dotcom-rendering/src/layouts/lib/furnitureArrangements.ts @@ -1,5 +1,6 @@ import { css, type SerializedStyles } from '@emotion/react'; import { from, until } from '@guardian/source/foundations'; +import { type ColumnPreset, grid, type Line } from '../../grid'; export type LayoutType = 'standard' | 'media'; @@ -14,101 +15,111 @@ export type Area = type Breakpoint = 'mobile' | 'tablet' | 'desktop' | 'leftCol'; -const breakpointQueries: Record = { - mobile: until.tablet, - tablet: from.tablet, - desktop: from.desktop, - leftCol: from.leftCol, -}; +// Rows config +const tabletStandardRows: Rows = [ + ['title'], + ['headline'], + ['standfirst'], + ['main-media'], + ['meta'], + ['body'], +]; -// Raw CSS overrides per area per breakpoint. Entries are only needed when an area -// deviates from the default: centre column, single-column mobile layout with areas -// in DOM order (main-media → title → headline → standfirst → meta → body → right-column). +const desktopStandardRows: Rows = [ + ['title', 'right-column'], + ['headline', 'right-column'], + ['standfirst', 'right-column'], + ['main-media', 'right-column'], + ['meta', 'right-column'], + ['body', 'right-column'], +]; -type AreaCss = Partial>; -type LayoutCssMap = Partial>; +const mediaRowsUntilDesktop: Rows = [ + ['title'], + ['headline'], + ['main-media'], + ['standfirst'], + ['meta'], + ['body'], +]; -const standardCss: LayoutCssMap = { - title: { - tablet: 'grid-row: 1;', - leftCol: - 'grid-row: 1; grid-column: left-column-start / left-column-end;', - }, - headline: { - tablet: 'grid-row: 2;', - leftCol: 'grid-row: 1;', - }, - standfirst: { - tablet: 'grid-row: 3;', - leftCol: 'grid-row: 2;', - }, - 'main-media': { - tablet: 'grid-row: 4;', - leftCol: 'grid-row: 3;', +// Breakpoints not listed here inherit browser default flow (no grid-row applied) +const furnitureRowArrangements: Record = { + standard: { + tablet: tabletStandardRows, + desktop: desktopStandardRows, + leftCol: [ + ['title', 'headline', 'right-column'], + ['standfirst', 'right-column'], + ['meta', 'main-media', 'right-column'], + ['meta', 'body', 'right-column'], + ], }, - meta: { - tablet: 'grid-row: 5;', - leftCol: - 'grid-row: 3 / span 2; grid-column: left-column-start / left-column-end;', - }, - body: { - tablet: 'grid-row: 6;', - leftCol: 'grid-row: 4;', - }, - 'right-column': { - desktop: - 'grid-row: 1 / span 6; grid-column: right-column-start / right-column-end;', - leftCol: - 'grid-row: 1 / span 4; grid-column: right-column-start / right-column-end;', + media: { + mobile: mediaRowsUntilDesktop, + tablet: mediaRowsUntilDesktop, + desktop: [ + ['title'], + ['headline'], + ['main-media', 'right-column'], + ['standfirst', 'right-column'], + ['meta', 'right-column'], + ['body', 'right-column'], + ], + leftCol: [ + ['title', 'headline'], + ['meta', 'main-media', 'right-column'], + ['meta', 'standfirst', 'right-column'], + ['meta', 'body', 'right-column'], + ], }, }; -const mediaCss: LayoutCssMap = { - title: { - mobile: 'grid-row: 1;', - leftCol: - 'grid-row: 1; grid-column: left-column-start / left-column-end;', - }, - headline: { - mobile: 'grid-row: 2;', - leftCol: 'grid-row: 1;', - }, - 'main-media': { - mobile: 'grid-row: 3;', - desktop: - 'grid-row: 3; grid-column: centre-column-start / right-column-start;', - leftCol: - 'grid-row: 2; grid-column: centre-column-start / right-column-start;', - }, - standfirst: { - mobile: 'grid-row: 4;', - desktop: - 'grid-row: 4; grid-column: centre-column-start / right-column-start;', - leftCol: - 'grid-row: 3; grid-column: centre-column-start / right-column-start;', - }, - meta: { - mobile: 'grid-row: 5;', - leftCol: - 'grid-row: 2 / span 3; grid-column: left-column-start / left-column-end;', - }, - body: { - desktop: - 'grid-row: 6; grid-column: centre-column-start / right-column-start;', - leftCol: - 'grid-row: 4; grid-column: centre-column-start / right-column-start;', - }, - 'right-column': { - desktop: - 'grid-row: 3 / span 4; grid-column: right-column-start / right-column-end;', - leftCol: - 'grid-row: 2 / span 3; grid-column: right-column-start / right-column-end;', +// Columns config +const furnitureColumnDefaults: ColumnArrangementMap = { + title: { leftCol: 'left' }, + meta: { leftCol: 'left' }, + ['right-column']: { desktop: 'right' }, +}; + +// Array form means [gridLineStart, gridLineEnd] — used for custom-width spans +const furnitureColumnArrangements: Record = { + standard: furnitureColumnDefaults, + media: { + ...furnitureColumnDefaults, + 'main-media': { + desktop: ['centre-column-start', 'right-column-start'], + }, + standfirst: { + desktop: ['centre-column-start', 'right-column-start'], + }, + body: { + desktop: ['centre-column-start', 'right-column-start'], + }, }, }; -const layoutCssMaps: Record = { - standard: standardCss, - media: mediaCss, +// Types +type Rows = Area[][]; + +type ArrangementDefinition = { + mobile?: Rows; + tablet?: Rows; + desktop?: Rows; + leftCol?: Rows; +}; + +type BreakpointColumns = Partial< + Record +>; + +type ColumnArrangementMap = Partial>; + +const breakpointQueries: Record = { + mobile: until.tablet, + tablet: from.tablet, + desktop: from.desktop, + leftCol: from.leftCol, }; /** @@ -116,9 +127,12 @@ const layoutCssMaps: Record = { * default column, its row at each breakpoint, and any column overrides. * The grid item _must_ be inside a {@link grid} module container. * - * All items default to the centre column. Per-breakpoint overrides for - * `grid-row` and `grid-column` are applied on top via media queries, - * looked up from the plain CSS maps defined in this file. + * The output is built from three layers, applied in order (later wins): + * 1. **Default column** — all items start in the centre column. + * 2. **Row placement** — `grid-row` values per breakpoint, read directly + * from {@link furnitureRowArrangements}. + * 3. **Column placement** — column overrides per breakpoint from + * {@link furnitureColumnArrangements} (e.g. `meta` shifts left on wide screens). * * @param area - The named piece of article furniture to position (e.g. `'headline'`, `'body'`). * @param layoutType - See {@link LayoutType}. Determines which CSS map to use for lookups. @@ -131,20 +145,46 @@ export const gridItemCss = ( area: Area, layoutType: LayoutType, ): SerializedStyles => { - const areaOverrides = layoutCssMaps[layoutType][area] ?? {}; + const layoutRowConfig = furnitureRowArrangements[layoutType]; + const areaColumnsConfig = + furnitureColumnArrangements[layoutType][area] ?? {}; + + const rowPlacementCss = ( + Object.entries(layoutRowConfig) as [Breakpoint, Rows][] + ).flatMap(([breakpoint, rows]) => { + // Find which row indices the area appears in (1-indexed for CSS grid) + const rowIndicesOfArea = rows + .map((areas, i) => (areas.includes(area) ? i + 1 : null)) + .filter((i): i is number => i !== null); - const breakpointCss = Object.entries(areaOverrides).map( - ([bp, styles]) => css` - ${breakpointQueries[bp as Breakpoint]} { - ${styles} + if (rowIndicesOfArea.length === 0) return []; + + const startingRow = rowIndicesOfArea[0]; + const rowValue = + rowIndicesOfArea.length > 1 + ? `${startingRow} / span ${rowIndicesOfArea.length}` + : startingRow; + + return css` + ${breakpointQueries[breakpoint]} { + grid-row: ${rowValue}; } - `, + `; + }); + + const columnPlacementCss = Object.entries(areaColumnsConfig).map( + ([breakpoint, colOrSpan]) => { + const colStyle = Array.isArray(colOrSpan) + ? grid.between(colOrSpan[0], colOrSpan[1]) + : grid.column[colOrSpan]; + + return css` + ${from[breakpoint as keyof typeof from]} { + ${colStyle}; + } + `; + }, ); - // All items default to the centre column; breakpoint entries above - // override grid-row and grid-column as needed. - return css` - grid-column: centre-column-start / centre-column-end; - ${breakpointCss} - `; + return css([grid.column.centre, rowPlacementCss, columnPlacementCss]); }; From c3f699d7546803ccd00224d0838a1f572abe9625 Mon Sep 17 00:00:00 2001 From: Frederick O'Brien Date: Thu, 30 Apr 2026 21:48:18 +0100 Subject: [PATCH 22/23] Rebase tidying --- dotcom-rendering/src/grid.ts | 30 +++++++++++++++++------------- 1 file changed, 17 insertions(+), 13 deletions(-) diff --git a/dotcom-rendering/src/grid.ts b/dotcom-rendering/src/grid.ts index 9d813201ee6..15c02e59e08 100644 --- a/dotcom-rendering/src/grid.ts +++ b/dotcom-rendering/src/grid.ts @@ -176,18 +176,6 @@ const outerRules = (color?: string): string => ` } }`; -// ----- API ----- // - -/** - * Render Guardian grid vertical rules. - * - * Left and right rules are always present. - * A centre rule can optionally be enabled. - * - * Usage: - * css([grid.container, grid.verticalRules()]) - * css([grid.container, grid.verticalRules({ centre: true })]) - */ const verticalRules = (options: VerticalRuleOptions = {}): string => ` ${fromBreakpoint.tablet} { position: relative; @@ -200,7 +188,6 @@ const verticalRules = (options: VerticalRuleOptions = {}): string => ` bottom: 0; width: 1px; background-color: ${palette('--article-border')}; - pointer-events: none; content: ''; } @@ -229,6 +216,23 @@ const verticalRules = (options: VerticalRuleOptions = {}): string => ` // ----- API ----- // +/** + * Ask the element to span all grid columns between two grid lines. The lines + * can be specified either by `Line` name or by number. + * @param from The grid line to start from, either a `Line` name or a number. + * @param to The grid line to end at, either a `Line` name or a number. + * @returns {string} CSS to place the element on the grid. + * + * @example Will place the element in the centre column. + * const styles = css` + * ${grid.between('centre-column-start', 'centre-column-end')} + * `; + * + * @example Will place the element between lines 3 and 5. + * const styles = css` + * ${grid.between(3, 5)} + * `; + */ const between = (from: Line | number, to: Line | number): string => ` grid-column: ${from} / ${to}; `; From bede688e3a8f11fe377333adff54e0edfa906d72 Mon Sep 17 00:00:00 2001 From: Frederick O'Brien Date: Wed, 3 Jun 2026 18:06:04 +0100 Subject: [PATCH 23/23] Better readability --- dotcom-rendering/src/grid.ts | 44 +-- .../src/layouts/StandardLayout.tsx | 1 + .../src/layouts/lib/articleArrangements.ts | 303 ++++++++++-------- .../src/layouts/lib/furnitureArrangements.ts | 190 ----------- 4 files changed, 172 insertions(+), 366 deletions(-) delete mode 100644 dotcom-rendering/src/layouts/lib/furnitureArrangements.ts diff --git a/dotcom-rendering/src/grid.ts b/dotcom-rendering/src/grid.ts index 15c02e59e08..e97434d1ade 100644 --- a/dotcom-rendering/src/grid.ts +++ b/dotcom-rendering/src/grid.ts @@ -176,44 +176,6 @@ const outerRules = (color?: string): string => ` } }`; -const verticalRules = (options: VerticalRuleOptions = {}): string => ` - ${fromBreakpoint.tablet} { - position: relative; - - &::before, - &::after - ${options.centre ? ', & > *:first-child::before' : ''} { - position: absolute; - top: 0; - bottom: 0; - width: 1px; - background-color: ${palette('--article-border')}; - content: ''; - } - - /* LEFT OUTER RULE */ - &::before { - grid-column: centre-column-start; - transform: translateX(-${columnGap}); - - ${fromBreakpoint.leftCol} { - grid-column: left-column-start; - } - } - - /* RIGHT OUTER RULE */ - &::after { - grid-column: right-column-end; - transform: translateX(-1px); - - ${betweenBreakpoint.tablet.and.desktop} { - grid-column: centre-column-end; - } - } - - ${options.centre ? optionalCentreRule : ''} -`; - // ----- API ----- // /** @@ -326,4 +288,10 @@ const grid = { centreRule, } as const; +// ----- Types ----- // +type ColumnPreset = keyof typeof grid.column; + +// ----- Exports ----- // + +export type { Line, ColumnPreset }; export { grid }; diff --git a/dotcom-rendering/src/layouts/StandardLayout.tsx b/dotcom-rendering/src/layouts/StandardLayout.tsx index 8c304056f3f..4942ac6c129 100644 --- a/dotcom-rendering/src/layouts/StandardLayout.tsx +++ b/dotcom-rendering/src/layouts/StandardLayout.tsx @@ -259,6 +259,7 @@ export const StandardLayout = (props: WebProps | AppProps) => { )} + {/* GridItem order matters — mobile layout relies on DOM order for grid placement. See furnitureArrangements.ts if reordering. */}
= { - mobile: until.tablet, - tablet: from.tablet, - desktop: from.desktop, - leftCol: from.leftCol, -}; +// Rows config +const tabletStandardRows: Rows = [ + ['title'], + ['headline'], + ['standfirst'], + ['media'], + ['meta'], + ['body'], +]; -// Raw CSS overrides per area per breakpoint. Entries are only needed when an area -// deviates from the default: centre column, single-column mobile layout with areas -// in DOM order (media → title → headline → standfirst → meta → body → right-column). +const desktopStandardRows: Rows = [ + ['title', 'right-column'], + ['headline', 'right-column'], + ['standfirst', 'right-column'], + ['media', 'right-column'], + ['meta', 'right-column'], + ['body', 'right-column'], +]; -type AreaCss = Partial>; -type LayoutCssMap = Partial>; +const mediaRowsUntilDesktop: Rows = [ + ['title'], + ['headline'], + ['media'], + ['standfirst'], + ['meta'], + ['body'], +]; -const standardCss: LayoutCssMap = { - title: { - tablet: 'grid-row: 1;', - leftCol: `grid-row: 1; ${grid.column.left}`, - }, - headline: { - tablet: 'grid-row: 2;', - leftCol: 'grid-row: 1;', - }, - standfirst: { - tablet: 'grid-row: 3;', - leftCol: 'grid-row: 2;', +// Breakpoints not listed here inherit browser default flow (no grid-row applied) +const furnitureRowArrangements: Record = { + standard: { + tablet: tabletStandardRows, + desktop: desktopStandardRows, + leftCol: [ + ['title', 'headline', 'right-column'], + ['standfirst', 'right-column'], + ['meta', 'media', 'right-column'], + ['meta', 'body', 'right-column'], + ], + }, + showcase: { + desktop: desktopStandardRows, + leftCol: [ + ['title', 'headline'], + ['standfirst'], + ['meta', 'media'], + ['meta', 'body', 'right-column'], + ], }, media: { - tablet: 'grid-row: 4;', - leftCol: 'grid-row: 3;', - }, - meta: { - tablet: 'grid-row: 5;', - leftCol: `grid-row: 3 / span 2; ${grid.column.left};`, - }, - body: { - tablet: 'grid-row: 6;', - leftCol: 'grid-row: 4;', - }, - 'right-column': { - desktop: `grid-row: 1 / span 6; ${grid.column.right};`, - leftCol: `grid-row: 1 / span 4; ${grid.column.right};`, + mobile: mediaRowsUntilDesktop, + tablet: mediaRowsUntilDesktop, + desktop: [ + ['title'], + ['headline'], + ['media', 'right-column'], + ['standfirst', 'right-column'], + ['meta', 'right-column'], + ['body', 'right-column'], + ], + leftCol: [ + ['title', 'headline'], + ['meta', 'media', 'right-column'], + ['meta', 'standfirst', 'right-column'], + ['meta', 'body', 'right-column'], + ], }, }; -const showcaseCss: LayoutCssMap = { - title: { - tablet: 'grid-row: 1;', - leftCol: `grid-row: 1; ${grid.column.left}`, - }, - headline: { - tablet: 'grid-row: 2;', - leftCol: 'grid-row: 1;', - }, - standfirst: { - tablet: 'grid-row: 3;', - }, - media: { - tablet: 'grid-row: 4;', - leftCol: `grid-row: 2; ${grid.between( - 'centre-column-start', - 'right-column-end', - )}`, - }, - meta: { - tablet: 'grid-row: 5;', - leftCol: `grid-row: 2 / span 2; ${grid.column.left};`, - }, - body: { - tablet: 'grid-row: 6;', - leftCol: 'grid-row: 4;', - }, - 'right-column': { - desktop: `grid-row: 1 / span 6; ${grid.column.right};`, - leftCol: `grid-row: 3 / span 2; ${grid.column.right};`, - }, +// Columns config +const furnitureColumnDefaults: ColumnArrangementMap = { + title: { leftCol: 'left' }, + meta: { leftCol: 'left' }, + ['right-column']: { desktop: 'right' }, }; -const mediaCss: LayoutCssMap = { - title: { - mobile: 'grid-row: 1;', - tablet: 'grid-row: 1;', - leftCol: `grid-row: 1; ${grid.column.left};`, - }, - headline: { - mobile: 'grid-row: 2;', - tablet: 'grid-row: 2;', - leftCol: 'grid-row: 1;', +// Array form means [gridLineStart, gridLineEnd] — used for custom-width spans +const furnitureColumnArrangements: Record = { + standard: furnitureColumnDefaults, + showcase: { + ...furnitureColumnDefaults, + media: { + leftCol: ['centre-column-start', 'right-column-end'], + }, }, media: { - mobile: 'grid-row: 3;', - tablet: 'grid-row: 3;', - desktop: `grid-row: 3; ${grid.between( - 'centre-column-start', - 'right-column-start', - )};`, - leftCol: `grid-row: 2; ${grid.between( - 'centre-column-start', - 'right-column-start', - )};`, - }, - standfirst: { - mobile: 'grid-row: 4;', - tablet: 'grid-row: 4;', - desktop: `grid-row: 4; ${grid.between( - 'centre-column-start', - 'right-column-start', - )};`, - leftCol: `grid-row: 3; ${grid.between( - 'centre-column-start', - 'right-column-start', - )};`, - }, - meta: { - mobile: 'grid-row: 5;', - tablet: 'grid-row: 5;', - leftCol: `grid-row: 2 / span 3; ${grid.column.left};`, - }, - body: { - desktop: `grid-row: 6; ${grid.between( - 'centre-column-start', - 'right-column-start', - )};`, - leftCol: `grid-row: 4; ${grid.between( - 'centre-column-start', - 'right-column-start', - )};`, - }, - 'right-column': { - desktop: `grid-row: 3 / span 4; ${grid.column.right};`, - leftCol: `grid-row: 2 / span 3; ${grid.column.right};`, + ...furnitureColumnDefaults, + media: { + desktop: ['centre-column-start', 'right-column-start'], + }, + standfirst: { + desktop: ['centre-column-start', 'right-column-start'], + }, + body: { + desktop: ['centre-column-start', 'right-column-start'], + }, }, }; -const layoutCssMaps: Record = { - standard: standardCss, - showcase: showcaseCss, - media: mediaCss, +// Types +type Rows = Area[][]; + +type ArrangementDefinition = { + mobile?: Rows; + tablet?: Rows; + desktop?: Rows; + leftCol?: Rows; +}; + +type BreakpointColumns = Partial< + Record +>; + +type ColumnArrangementMap = Partial>; + +const breakpointQueries: Record = { + mobile: until.tablet, + tablet: from.tablet, + desktop: from.desktop, + leftCol: from.leftCol, }; +/** + * Returns the CSS `grid-row` value for an area at a given breakpoint, or null + * if the area doesn't appear in that breakpoint's row config. + * + * Consecutive row appearances are collapsed into a single span, e.g. rows + * [2, 3, 4] becomes "2 / span 3". + */ +const getRowValue = (area: Area, rows: Rows): string | number | null => { + const indices = rows + .map((areas, i) => (areas.includes(area) ? i + 1 : null)) + .filter((i): i is number => i !== null); + + if (indices.length === 0) return null; + + return indices.length > 1 + ? `${indices[0]} / span ${indices.length}` + : (indices[0] ?? null); +}; + +/** + * Returns the `grid-column` CSS string for a column config entry — either a + * named preset (e.g. 'left', 'right') or a custom [start, end] line pair. + */ +const getColumnStyle = ( + colOrSpan: ColumnPreset | [Line | number, Line | number], +): string => + Array.isArray(colOrSpan) + ? grid.between(colOrSpan[0], colOrSpan[1]) + : grid.column[colOrSpan]; + /** * Returns the Emotion CSS needed to position a single grid item — its * default column, its row at each breakpoint, and any column overrides. * The grid item _must_ be inside a {@link grid} module container. * - * All items default to the centre column. Per-breakpoint overrides for - * `grid-row` and `grid-column` are applied on top via media queries, - * looked up from the plain CSS maps defined in this file. + * The output is built from three layers, applied in order (later wins): + * 1. **Default column** — all items start in the centre column. + * 2. **Row placement** — `grid-row` values per breakpoint, read directly + * from {@link furnitureRowArrangements}. + * 3. **Column placement** — column overrides per breakpoint from + * {@link furnitureColumnArrangements} (e.g. `meta` shifts left on wide screens). * * @param area - The named piece of article furniture to position (e.g. `'headline'`, `'body'`). * @param layoutType - See {@link LayoutType}. Determines which CSS map to use for lookups. @@ -175,20 +192,30 @@ export const gridItemCss = ( area: Area, layoutType: LayoutType, ): SerializedStyles => { - const areaOverrides = layoutCssMaps[layoutType][area] ?? {}; + const layoutRowConfig = furnitureRowArrangements[layoutType]; + const areaColumnsConfig = + furnitureColumnArrangements[layoutType][area] ?? {}; + + const rowPlacementCss = ( + Object.entries(layoutRowConfig) as [Breakpoint, Rows][] + ).flatMap(([breakpoint, rows]) => { + const rowValue = getRowValue(area, rows); + if (rowValue === null) return []; + + return css` + ${breakpointQueries[breakpoint]} { + grid-row: ${rowValue}; + } + `; + }); - const breakpointCss = Object.entries(areaOverrides).map( - ([bp, styles]) => css` - ${breakpointQueries[bp as Breakpoint]} { - ${styles} + const columnPlacementCss = Object.entries(areaColumnsConfig).map( + ([breakpoint, colOrSpan]) => css` + ${from[breakpoint as keyof typeof from]} { + ${getColumnStyle(colOrSpan)}; } `, ); - // All items default to the centre column; breakpoint entries above - // override grid-row and grid-column as needed. - return css` - grid-column: centre-column-start / centre-column-end; - ${breakpointCss} - `; + return css([grid.column.centre, rowPlacementCss, columnPlacementCss]); }; diff --git a/dotcom-rendering/src/layouts/lib/furnitureArrangements.ts b/dotcom-rendering/src/layouts/lib/furnitureArrangements.ts deleted file mode 100644 index c18dd689be5..00000000000 --- a/dotcom-rendering/src/layouts/lib/furnitureArrangements.ts +++ /dev/null @@ -1,190 +0,0 @@ -import { css, type SerializedStyles } from '@emotion/react'; -import { from, until } from '@guardian/source/foundations'; -import { type ColumnPreset, grid, type Line } from '../../grid'; - -export type LayoutType = 'standard' | 'media'; - -export type Area = - | 'title' - | 'headline' - | 'standfirst' - | 'main-media' - | 'meta' - | 'body' - | 'right-column'; - -type Breakpoint = 'mobile' | 'tablet' | 'desktop' | 'leftCol'; - -// Rows config -const tabletStandardRows: Rows = [ - ['title'], - ['headline'], - ['standfirst'], - ['main-media'], - ['meta'], - ['body'], -]; - -const desktopStandardRows: Rows = [ - ['title', 'right-column'], - ['headline', 'right-column'], - ['standfirst', 'right-column'], - ['main-media', 'right-column'], - ['meta', 'right-column'], - ['body', 'right-column'], -]; - -const mediaRowsUntilDesktop: Rows = [ - ['title'], - ['headline'], - ['main-media'], - ['standfirst'], - ['meta'], - ['body'], -]; - -// Breakpoints not listed here inherit browser default flow (no grid-row applied) -const furnitureRowArrangements: Record = { - standard: { - tablet: tabletStandardRows, - desktop: desktopStandardRows, - leftCol: [ - ['title', 'headline', 'right-column'], - ['standfirst', 'right-column'], - ['meta', 'main-media', 'right-column'], - ['meta', 'body', 'right-column'], - ], - }, - media: { - mobile: mediaRowsUntilDesktop, - tablet: mediaRowsUntilDesktop, - desktop: [ - ['title'], - ['headline'], - ['main-media', 'right-column'], - ['standfirst', 'right-column'], - ['meta', 'right-column'], - ['body', 'right-column'], - ], - leftCol: [ - ['title', 'headline'], - ['meta', 'main-media', 'right-column'], - ['meta', 'standfirst', 'right-column'], - ['meta', 'body', 'right-column'], - ], - }, -}; - -// Columns config -const furnitureColumnDefaults: ColumnArrangementMap = { - title: { leftCol: 'left' }, - meta: { leftCol: 'left' }, - ['right-column']: { desktop: 'right' }, -}; - -// Array form means [gridLineStart, gridLineEnd] — used for custom-width spans -const furnitureColumnArrangements: Record = { - standard: furnitureColumnDefaults, - media: { - ...furnitureColumnDefaults, - 'main-media': { - desktop: ['centre-column-start', 'right-column-start'], - }, - standfirst: { - desktop: ['centre-column-start', 'right-column-start'], - }, - body: { - desktop: ['centre-column-start', 'right-column-start'], - }, - }, -}; - -// Types -type Rows = Area[][]; - -type ArrangementDefinition = { - mobile?: Rows; - tablet?: Rows; - desktop?: Rows; - leftCol?: Rows; -}; - -type BreakpointColumns = Partial< - Record ->; - -type ColumnArrangementMap = Partial>; - -const breakpointQueries: Record = { - mobile: until.tablet, - tablet: from.tablet, - desktop: from.desktop, - leftCol: from.leftCol, -}; - -/** - * Returns the Emotion CSS needed to position a single grid item — its - * default column, its row at each breakpoint, and any column overrides. - * The grid item _must_ be inside a {@link grid} module container. - * - * The output is built from three layers, applied in order (later wins): - * 1. **Default column** — all items start in the centre column. - * 2. **Row placement** — `grid-row` values per breakpoint, read directly - * from {@link furnitureRowArrangements}. - * 3. **Column placement** — column overrides per breakpoint from - * {@link furnitureColumnArrangements} (e.g. `meta` shifts left on wide screens). - * - * @param area - The named piece of article furniture to position (e.g. `'headline'`, `'body'`). - * @param layoutType - See {@link LayoutType}. Determines which CSS map to use for lookups. - * - * @example - * // In a React component: - *
- */ -export const gridItemCss = ( - area: Area, - layoutType: LayoutType, -): SerializedStyles => { - const layoutRowConfig = furnitureRowArrangements[layoutType]; - const areaColumnsConfig = - furnitureColumnArrangements[layoutType][area] ?? {}; - - const rowPlacementCss = ( - Object.entries(layoutRowConfig) as [Breakpoint, Rows][] - ).flatMap(([breakpoint, rows]) => { - // Find which row indices the area appears in (1-indexed for CSS grid) - const rowIndicesOfArea = rows - .map((areas, i) => (areas.includes(area) ? i + 1 : null)) - .filter((i): i is number => i !== null); - - if (rowIndicesOfArea.length === 0) return []; - - const startingRow = rowIndicesOfArea[0]; - const rowValue = - rowIndicesOfArea.length > 1 - ? `${startingRow} / span ${rowIndicesOfArea.length}` - : startingRow; - - return css` - ${breakpointQueries[breakpoint]} { - grid-row: ${rowValue}; - } - `; - }); - - const columnPlacementCss = Object.entries(areaColumnsConfig).map( - ([breakpoint, colOrSpan]) => { - const colStyle = Array.isArray(colOrSpan) - ? grid.between(colOrSpan[0], colOrSpan[1]) - : grid.column[colOrSpan]; - - return css` - ${from[breakpoint as keyof typeof from]} { - ${colStyle}; - } - `; - }, - ); - - return css([grid.column.centre, rowPlacementCss, columnPlacementCss]); -};