diff --git a/dotcom-rendering/src/grid.ts b/dotcom-rendering/src/grid.ts
index ad089db43e1..9161153a16d 100644
--- a/dotcom-rendering/src/grid.ts
+++ b/dotcom-rendering/src/grid.ts
@@ -251,6 +251,10 @@ const grid = {
verticalRules,
} 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 65ebfbc5994..6449b0924a6 100644
--- a/dotcom-rendering/src/layouts/StandardLayout.tsx
+++ b/dotcom-rendering/src/layouts/StandardLayout.tsx
@@ -1,7 +1,6 @@
-import { css } from '@emotion/react';
+import { css, type SerializedStyles } from '@emotion/react';
import { log } from '@guardian/libs';
import {
- from,
palette as sourcePalette,
space,
until,
@@ -19,7 +18,6 @@ import { ArticleHeadline } from '../components/ArticleHeadline';
import { ArticleMetaApps } from '../components/ArticleMeta.apps';
import { ArticleMeta } from '../components/ArticleMeta.web';
import { ArticleTitle } from '../components/ArticleTitle';
-import { Border } from '../components/Border';
import { Carousel } from '../components/Carousel.island';
import { DecideLines } from '../components/DecideLines';
import { DirectoryPageNav } from '../components/DirectoryPageNav';
@@ -27,7 +25,6 @@ import { DiscussionLayout } from '../components/DiscussionLayout';
import { FootballMatchHeaderWrapper } from '../components/FootballMatchHeaderWrapper.island';
import { FootballMatchInfoWrapper } from '../components/FootballMatchInfoWrapper.island';
import { Footer } from '../components/Footer';
-import { GridItem } from '../components/GridItem';
import { GuardianLabsLines } from '../components/GuardianLabsLines';
import { HeaderAdSlot } from '../components/HeaderAdSlot';
import { Island } from '../components/Island';
@@ -39,13 +36,13 @@ import { MostViewedFooterData } from '../components/MostViewedFooterData.island'
import { MostViewedFooterLayout } from '../components/MostViewedFooterLayout';
import { MostViewedRightWithAd } from '../components/MostViewedRightWithAd.island';
import { OnwardsUpper } from '../components/OnwardsUpper.island';
-import { RightColumn } from '../components/RightColumn';
import { Section } from '../components/Section';
import { SlotBodyEnd } from '../components/SlotBodyEnd.island';
import { Standfirst } from '../components/Standfirst';
import { StickyBottomBanner } from '../components/StickyBottomBanner.island';
import { SubMeta } from '../components/SubMeta';
import { SubNav } from '../components/SubNav.island';
+import { grid } from '../grid';
import {
ArticleDesign,
type ArticleFormat,
@@ -61,247 +58,13 @@ import type { NavType } from '../model/extract-nav';
import { palette as themePalette } from '../palette';
import type { ArticleDeprecated } from '../types/article';
import type { RenderingTarget } from '../types/renderingTarget';
+import {
+ type Area,
+ gridItemCss,
+ type LayoutType,
+} from './lib/furnitureArrangements';
import { BannerWrapper, Stuck } from './lib/stickiness';
-const StandardGrid = ({
- children,
- isMatchReport,
- isMedia,
-}: {
- children: React.ReactNode;
- isMatchReport: boolean;
- isMedia: boolean;
-}) => (
-
- {children}
-
-);
-
-const maxWidth = css`
- ${from.desktop} {
- max-width: 620px;
- }
-`;
-
const stretchLines = css`
${until.phablet} {
margin-left: -20px;
@@ -313,6 +76,29 @@ const stretchLines = css`
}
`;
+interface GridItemProps {
+ area: Area;
+ layoutType: LayoutType;
+ element?: 'div' | 'aside';
+ customCss?: SerializedStyles;
+ children: React.ReactNode;
+}
+
+const GridItem = ({
+ area,
+ layoutType,
+ element: Element = 'div',
+ customCss,
+ children,
+}: GridItemProps) => (
+
+ {children}
+
+);
+
interface Props {
article: ArticleDeprecated;
format: ArticleFormat;
@@ -385,6 +171,8 @@ export const StandardLayout = (props: WebProps | AppProps) => {
const renderAds = canRenderAds(article);
+ const layoutType: LayoutType = isMedia ? 'media' : 'standard';
+
return (
<>
{isWeb && (
@@ -459,163 +247,112 @@ export const StandardLayout = (props: WebProps | AppProps) => {
pageId={article.pageId}
pageTags={article.tags}
/>
-
-
+
+
+
-
-
-
-
-
-
-
-
-
-
- {format.theme === ArticleSpecial.Labs ? (
- <>>
+
+
+
+
+
+
+
+
+
+
+ {isWeb &&
+ format.theme === ArticleSpecial.Labs &&
+ format.design !== ArticleDesign.Video ? (
+
) : (
-
- )}
-
-
-
-
-
-
-
-
-
-
- {isWeb &&
- format.theme === ArticleSpecial.Labs &&
- format.design !== ArticleDesign.Video ? (
-
- ) : (
-
- )}
-
-
- {isApps ? (
- <>
-
-
-
-
-
- {!!article.affiliateLinksDisclaimer && (
-
- )}
-
- >
- ) : (
-
+ )}
+
+ {isApps ? (
+ <>
+
+
+
+
{
{!!article.affiliateLinksDisclaimer && (
)}
-
- )}
-
-
- {/* Only show Listen to Article button on App landscape views */}
- {isApps && (
-
- {!isVideo && (
-
-
-
-
-
- )}
- )}
-
-
-
-
- {isApps && (
-
+ ) : (
+
+ )}
+
+
+ {/* Only show Listen to Article button on App landscape views */}
+ {isApps && (
+
+ {!isVideo && (
+
)}
+
+ )}
+
+
+
- {showBodyEndSlot && (
-
-
-
- )}
-
-
+
+
+ )}
+
+ {showBodyEndSlot && (
+
+
+
+ )}
+
+
+
+
+
+
+
+
-
-
-
-
-
-
-
-
-
-
-
-
-
+
+
+
+
{isWeb && renderAds && !isLabs && (
= {
+ 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]);
+};