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..2b340fbeb64 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 && (
= {
+ mobile: until.tablet,
+ tablet: from.tablet,
+ desktop: from.desktop,
+ leftCol: from.leftCol,
+};
+
+// 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).
+
+type AreaCss = Partial>;
+type LayoutCssMap = Partial>;
+
+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;',
+ },
+ 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;',
+ },
+};
+
+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;',
+ },
+ 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;',
+ },
+};
+
+const layoutCssMaps: Record = {
+ standard: standardCss,
+ media: mediaCss,
+};
+
+/**
+ * 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.
+ *
+ * @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 areaOverrides = layoutCssMaps[layoutType][area] ?? {};
+
+ const breakpointCss = Object.entries(areaOverrides).map(
+ ([bp, styles]) => css`
+ ${breakpointQueries[bp as Breakpoint]} {
+ ${styles}
+ }
+ `,
+ );
+
+ // 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}
+ `;
+};