Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
23 commits
Select commit Hold shift + click to select a range
518458a
Add vertical rules to grid module
frederickobrien Feb 27, 2026
d5bfedf
Fix not appearing
frederickobrien Mar 3, 2026
279b700
Roll back RightColumn changes
frederickobrien Mar 4, 2026
3b59d80
Human readable furniture row configuration
frederickobrien Mar 5, 2026
b7b41a2
Review polishes and refactors
frederickobrien Mar 6, 2026
b34ab09
Adjust media furniture layout
frederickobrien Mar 10, 2026
1c6fc69
More consistent row and column grid styling abstraction
frederickobrien Mar 10, 2026
01cdd83
Dom review polishes
frederickobrien Mar 11, 2026
3f00bf4
Incorporate grid rows into furniture layout config
frederickobrien Mar 11, 2026
c05dd6a
Type tidying
frederickobrien Mar 11, 2026
20963b2
Move all column settings into layout config
frederickobrien Mar 11, 2026
7cf9308
Deduplicate config
frederickobrien Mar 12, 2026
d8e10b1
Adjust standard layout to sit properly when there's no featured image
frederickobrien Mar 12, 2026
8cf863c
Tidying
frederickobrien Mar 17, 2026
6c08bac
Rebase tidy
frederickobrien Mar 18, 2026
29caa45
Rename furnitureLayouts to furnitureArrangements
frederickobrien Apr 15, 2026
73ceca5
Review suggestions
frederickobrien Apr 21, 2026
e52a33f
Readability improvements
frederickobrien Apr 22, 2026
24f5558
Documentation
frederickobrien Apr 22, 2026
31232d4
Replace abstraction with vanilla CSS approach
frederickobrien Apr 28, 2026
b26decf
Furniture arrangement abstractrion
frederickobrien Apr 29, 2026
c3f699d
Rebase tidying
frederickobrien Apr 30, 2026
bede688
Better readability
frederickobrien Jun 3, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 6 additions & 0 deletions dotcom-rendering/src/grid.ts
Original file line number Diff line number Diff line change
Expand Up @@ -288,4 +288,10 @@ const grid = {
centreRule,
} as const;

// ----- Types ----- //
type ColumnPreset = keyof typeof grid.column;

// ----- Exports ----- //

export type { Line, ColumnPreset };
export { grid };
303 changes: 165 additions & 138 deletions dotcom-rendering/src/layouts/lib/articleArrangements.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
import { css, type SerializedStyles } from '@emotion/react';
import { from, until } from '@guardian/source/foundations';
import { grid } from '../../grid';
import { type ColumnPreset, grid, type Line } from '../../grid';

export type LayoutType = 'standard' | 'showcase' | 'media';

Expand All @@ -13,156 +13,173 @@ export type Area =
| 'body'
| 'right-column';

// Breakpoint must stay in sync with the keys of `from` / `until` from
// @guardian/source/foundations. If those change, this type needs updating too.
type Breakpoint = 'mobile' | 'tablet' | 'desktop' | 'leftCol';

const breakpointQueries: Record<Breakpoint, string> = {
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<Record<Breakpoint, string>>;
type LayoutCssMap = Partial<Record<Area, AreaCss>>;
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<LayoutType, ArrangementDefinition> = {
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<LayoutType, ColumnArrangementMap> = {
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<LayoutType, LayoutCssMap> = {
standard: standardCss,
showcase: showcaseCss,
media: mediaCss,
// Types
type Rows = Area[][];

type ArrangementDefinition = {
mobile?: Rows;
tablet?: Rows;
desktop?: Rows;
leftCol?: Rows;
};

type BreakpointColumns = Partial<
Record<Breakpoint, ColumnPreset | [Line | number, Line | number]>
>;

type ColumnArrangementMap = Partial<Record<Area, BreakpointColumns>>;

const breakpointQueries: Record<Breakpoint, string> = {
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.
Expand All @@ -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]);
};
Loading