From f2d317efd7f2fcbc4697bc282574d8a816fe817f Mon Sep 17 00:00:00 2001 From: Frederick O'Brien Date: Wed, 11 Mar 2026 12:44:55 +0000 Subject: [PATCH 01/13] Type tidying --- dotcom-rendering/src/layouts/StandardLayout.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/dotcom-rendering/src/layouts/StandardLayout.tsx b/dotcom-rendering/src/layouts/StandardLayout.tsx index 8d24f6d3b8f..68d5c58af2c 100644 --- a/dotcom-rendering/src/layouts/StandardLayout.tsx +++ b/dotcom-rendering/src/layouts/StandardLayout.tsx @@ -45,7 +45,7 @@ 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 { type ColumnPreset, grid } from '../grid'; import { ArticleDesign, ArticleDisplay, From d087f53827c4aeb73f08aed499096749816fd28d Mon Sep 17 00:00:00 2001 From: Frederick O'Brien Date: Wed, 11 Mar 2026 13:00:05 +0000 Subject: [PATCH 02/13] Move all column settings into layout config --- dotcom-rendering/src/layouts/StandardLayout.tsx | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/dotcom-rendering/src/layouts/StandardLayout.tsx b/dotcom-rendering/src/layouts/StandardLayout.tsx index 68d5c58af2c..4e8dc66015c 100644 --- a/dotcom-rendering/src/layouts/StandardLayout.tsx +++ b/dotcom-rendering/src/layouts/StandardLayout.tsx @@ -45,7 +45,7 @@ import { Standfirst } from '../components/Standfirst'; import { StickyBottomBanner } from '../components/StickyBottomBanner.island'; import { SubMeta } from '../components/SubMeta'; import { SubNav } from '../components/SubNav.island'; -import { type ColumnPreset, grid } from '../grid'; +import { grid } from '../grid'; import { ArticleDesign, ArticleDisplay, @@ -69,6 +69,7 @@ import { type LayoutType, } from './lib/articleArrangements'; import { BannerWrapper, Stuck } from './lib/stickiness'; +import { Grid } from 'src/components/Masthead/Titlepiece/Grid'; const stretchLines = css` ${until.phablet} { From 744539d92ad1215238b8712193b976594a64d5ab Mon Sep 17 00:00:00 2001 From: Frederick O'Brien Date: Wed, 11 Mar 2026 14:39:49 +0000 Subject: [PATCH 03/13] Remove maxWidth wrapper divs --- dotcom-rendering/src/layouts/StandardLayout.tsx | 2 -- 1 file changed, 2 deletions(-) diff --git a/dotcom-rendering/src/layouts/StandardLayout.tsx b/dotcom-rendering/src/layouts/StandardLayout.tsx index 4e8dc66015c..9c2fadb1e4a 100644 --- a/dotcom-rendering/src/layouts/StandardLayout.tsx +++ b/dotcom-rendering/src/layouts/StandardLayout.tsx @@ -10,7 +10,6 @@ import { Hide } from '@guardian/source/react-components'; import { StraightLines } from '@guardian/source-development-kitchen/react-components'; import { AdPortals } from '../components/AdPortals.island'; import { AdSlot, MobileStickyContainer } from '../components/AdSlot.web'; -import { AffiliateDisclaimer } from '../components/AffiliateDisclaimer'; import { AppsEpic } from '../components/AppsEpic.island'; import { AppsFooter } from '../components/AppsFooter.island'; import { ArticleBody } from '../components/ArticleBody'; @@ -69,7 +68,6 @@ import { type LayoutType, } from './lib/articleArrangements'; import { BannerWrapper, Stuck } from './lib/stickiness'; -import { Grid } from 'src/components/Masthead/Titlepiece/Grid'; const stretchLines = css` ${until.phablet} { From 6eb25a82d549b624dcfba636ef8f1b7a7999ea2a Mon Sep 17 00:00:00 2001 From: Frederick O'Brien Date: Tue, 19 May 2026 15:25:40 +0100 Subject: [PATCH 04/13] Rough first pass --- .../src/layouts/StandardLayout.tsx | 832 ++++++++++-------- .../src/layouts/lib/articleArrangements.ts | 30 +- 2 files changed, 470 insertions(+), 392 deletions(-) diff --git a/dotcom-rendering/src/layouts/StandardLayout.tsx b/dotcom-rendering/src/layouts/StandardLayout.tsx index 9c2fadb1e4a..0c24acb106d 100644 --- a/dotcom-rendering/src/layouts/StandardLayout.tsx +++ b/dotcom-rendering/src/layouts/StandardLayout.tsx @@ -10,6 +10,7 @@ import { Hide } from '@guardian/source/react-components'; import { StraightLines } from '@guardian/source-development-kitchen/react-components'; import { AdPortals } from '../components/AdPortals.island'; import { AdSlot, MobileStickyContainer } from '../components/AdSlot.web'; +import { AffiliateDisclaimer } from '../components/AffiliateDisclaimer'; import { AppsEpic } from '../components/AppsEpic.island'; import { AppsFooter } from '../components/AppsFooter.island'; import { ArticleBody } from '../components/ArticleBody'; @@ -19,7 +20,6 @@ import { ArticleMetaApps } from '../components/ArticleMeta.apps'; import { ArticleMeta } from '../components/ArticleMeta.web'; import { ArticleTitle } from '../components/ArticleTitle'; import { Carousel } from '../components/Carousel.island'; -import { CricketMatchHeaderWrapper } from '../components/CricketMatchHeaderWrapper.island'; import { DecideLines } from '../components/DecideLines'; import { DirectoryPageNavIsland } from '../components/DirectoryPageNavIsland'; import { DiscussionLayout } from '../components/DiscussionLayout'; @@ -33,7 +33,6 @@ import { LabsHeader } from '../components/LabsHeader'; import { ListenToArticle } from '../components/ListenToArticle.island'; import { MainMedia } from '../components/MainMedia'; import { Masthead } from '../components/Masthead/Masthead'; -import { MatchHeaderFallback } from '../components/MatchHeaderFallback'; import { MostViewedFooterData } from '../components/MostViewedFooterData.island'; import { MostViewedFooterLayout } from '../components/MostViewedFooterLayout'; import { MostViewedRightWithAd } from '../components/MostViewedRightWithAd.island'; @@ -45,6 +44,7 @@ import { StickyBottomBanner } from '../components/StickyBottomBanner.island'; import { SubMeta } from '../components/SubMeta'; import { SubNav } from '../components/SubNav.island'; import { grid } from '../grid'; +import { getAgeWarning } from '../lib/age-warning'; import { ArticleDesign, ArticleDisplay, @@ -54,9 +54,9 @@ import { import { canRenderAds } from '../lib/canRenderAds'; import { getContributionsServiceUrl } from '../lib/contributions'; import { decideStoryPackageTrails } from '../lib/decideTrail'; +import type { EditionId } from '../lib/edition'; import { safeParseURL } from '../lib/parse'; import { parse } from '../lib/slot-machine-flags'; -import { useAB } from '../lib/useAB'; import { worldCupTagId } from '../lib/worldCup2026'; import type { NavType } from '../model/extract-nav'; import { palette as themePalette } from '../palette'; @@ -64,6 +64,7 @@ import type { ArticleDeprecated } from '../types/article'; import type { RenderingTarget } from '../types/renderingTarget'; import { type Area, + getLayoutType, gridItemCss, type LayoutType, } from './lib/articleArrangements'; @@ -120,6 +121,23 @@ interface AppProps extends Props { renderingTarget: 'Apps'; } +/** + * Works out the orientation of an image from its Guardian media URL, which + * encodes the crop dimensions in the path (e.g. `/1000_600_800_480/`). + * Falls back to 'landscape' if the URL doesn't match the expected pattern. + */ +const getImageOrientation = ( + url: string, +): 'portrait' | 'landscape' | 'square' => { + const match = url.match(/\/\d+_\d+_(\d+)_(\d+)\/\d+\.\w+$/); + if (!match) return 'landscape'; + const [, width, height] = match.map(Number); + if (width == null || height == null) return 'landscape'; + if (height > width) return 'portrait'; + if (width > height) return 'landscape'; + return 'square'; +}; + export const StandardLayout = (props: WebProps | AppProps) => { const { article, format, renderingTarget, serverTime } = props; const { @@ -149,25 +167,26 @@ export const StandardLayout = (props: WebProps | AppProps) => { ? article.matchStatsUrl : undefined; - const isFootballMatchReport = - format.design === ArticleDesign.MatchReport && !!footballMatchUrl; - - const cricketMatchUrl = - article.matchType === 'CricketMatchType' - ? article.matchStatsUrl + const footballMatchHeaderUrl = + article.matchType === 'FootballMatchType' + ? article.matchHeaderUrl : undefined; - const isCricketMatchReport = - format.design === ArticleDesign.MatchReport && !!cricketMatchUrl; + const footballMatchLeagueName = article.sectionLabel; + const footballMatchLeagueUrl = `${article.guardianBaseURL}/${article.sectionUrl}`; + + const isMatchReport = + format.design === ArticleDesign.MatchReport && !!footballMatchUrl; const isMedia = format.design === ArticleDesign.Video || format.design === ArticleDesign.Audio; + const isShowcase = format.display === ArticleDisplay.Showcase; + const isImmersive = format.display === ArticleDisplay.Immersive; + const isFeature = format.design === ArticleDesign.Feature; const isVideo = format.design === ArticleDesign.Video; - const isShowcase = format.display === ArticleDisplay.Showcase; - const showComments = article.isCommentable && !isPaidContent; const { branding } = article.commercialProperties[article.editionId]; @@ -180,11 +199,36 @@ export const StandardLayout = (props: WebProps | AppProps) => { const renderAds = canRenderAds(article); - const layoutType: LayoutType = isMedia - ? 'media' - : isShowcase - ? 'showcase' - : 'standard'; + const firstMainMediaElement = article.mainMediaElements[0]; + const mainMediaUrl: string | undefined = + firstMainMediaElement?._type === + 'model.dotcomrendering.pageElements.ImageBlockElement' + ? firstMainMediaElement.media.allImages[0]?.url + : undefined; + + const mainMediaOrientation = + mainMediaUrl != null ? getImageOrientation(mainMediaUrl) : 'landscape'; + + const layoutType = getLayoutType({ + isImmersive, + isFeature, + orientation: mainMediaOrientation, + isVideo, + isShowcase, + }); + const contentLayoutName = `${ArticleDisplay[format.display]}Layout`; + + const isImmersivePortrait = + layoutType === 'immersivePortraitDefault' || + layoutType === 'immersivePortraitFeature'; + const isImmersiveLandscape = + layoutType === 'immersiveLandscapeDefault' || + layoutType === 'immersiveLandscapeFeature'; + + const ageWarning = getAgeWarning( + article.tags, + article.webPublicationDateDeprecated, + ); return ( <> @@ -242,18 +286,19 @@ export const StandardLayout = (props: WebProps | AppProps) => { /> {isWeb && renderAds && hasSurveyAd && ( )} -
+
{isApps && renderAds && ( @@ -261,84 +306,146 @@ export const StandardLayout = (props: WebProps | AppProps) => { )} {/* GridItem order matters — mobile layout relies on DOM order for grid placement. - See furnitureArrangements.ts if reordering. */} - {/* This element is used to replace the article with the scorecard when the scorecard tab is clicked */} -
-
- - - - - - - - - - - - - + `, + ]} + > + + + + + + + + + + + + + + {layoutType !== 'immersivePortraitDefault' && (
{isWeb && format.theme === ArticleSpecial.Labs && @@ -351,70 +458,30 @@ export const StandardLayout = (props: WebProps | AppProps) => { /> )}
- {isApps ? ( - <> - - - - - - {!!article.affiliateLinksDisclaimer && ( - - )} - - - ) : ( - <> + )} + {isApps ? ( + <> + + + + { secondaryDateline={ article.webPublicationSecondaryDateDisplay } + webPublicationDate={ + article.webPublicationDate + } isCommentable={article.isCommentable} discussionApiUrl={ article.config.discussionApiUrl @@ -437,190 +507,211 @@ export const StandardLayout = (props: WebProps | AppProps) => { mainMediaElements={ article.mainMediaElements } - webPublicationDate={ - article.webPublicationDate - } /> {!!article.affiliateLinksDisclaimer && ( )} - - )} -
- - {/* Only show Listen to Article button on App landscape views */} - {isApps && ( - - {!isVideo && ( -
- - - -
- )}
- )} - - + ) : ( + <> + - - - {isApps && ( - - - + {!!article.affiliateLinksDisclaimer && ( + )} - - {showBodyEndSlot && ( - + )} +
+ + {/* Only show Listen to Article button on App landscape views */} + {isApps && ( + + {!isVideo && ( +
- - + + + +
)} - - - -
- + )} + + - + tags={article.tags} + isPaidContent={!!article.config.isPaidContent} + contributionsServiceUrl={ + contributionsServiceUrl + } + contentType={article.contentType} + isPreview={article.config.isPreview} + idUrl={article.config.idUrl ?? ''} + isDev={!!article.config.isDev} + keywordIds={article.config.keywordIds} + abTests={article.config.abTests} + tableOfContents={article.tableOfContents} + lang={article.lang} + isRightToLeftLang={article.isRightToLeftLang} + editionId={article.editionId} + shouldHideAds={article.shouldHideAds} + idApiUrl={article.config.idApiUrl} + /> + + + {isApps && ( + + + + )} + + {showBodyEndSlot && ( - - - -
-
+ )} + + + + + + + + + + + + + {isWeb && renderAds && !isLabs && (
{ /> - {renderAds && ( - - )} + )} @@ -851,82 +940,43 @@ export const StandardLayout = (props: WebProps | AppProps) => { }; const MatchHeaderContainer = ({ - isFootballMatchReport, - isCricketMatchReport, + isMatchReport, + footballMatchHeaderUrl, + leagueName, + leagueUrl, + editionId, renderingTarget, - article, - format, }: { - isFootballMatchReport: boolean; - isCricketMatchReport: boolean; + isMatchReport: boolean; + footballMatchHeaderUrl: string | undefined; + leagueName: string; + leagueUrl: string; + editionId: EditionId; renderingTarget: RenderingTarget; - article: ArticleDeprecated; - format: ArticleFormat; }) => { - const footballMatchHeaderUrl = - article.matchType === 'FootballMatchType' - ? article.matchHeaderUrl - : undefined; - - const footballMatchLeagueName = article.sectionLabel; - const footballMatchLeagueUrl = `${article.guardianBaseURL}/${article.sectionUrl}`; - - const cricketMatchHeaderUrl = - article.matchType === 'CricketMatchType' - ? article.matchHeaderUrl - : undefined; - - const ab = useAB(); - const isCricketRedesignEnabled = Boolean( - ab?.isUserInTestGroup('webx-cricket-redesign', 'enable'), - ); - - const isApps = renderingTarget === 'Apps'; - - if (isFootballMatchReport && footballMatchHeaderUrl) { - return ( - <> - - - - - - ); - } + if (isMatchReport && !!footballMatchHeaderUrl) { + const parsedUrl = safeParseURL(footballMatchHeaderUrl); + if (!parsedUrl.ok) { + log( + 'dotcom', + new Error( + `Failed to parse match header URL: ${footballMatchHeaderUrl}`, + ), + ); - if ( - !isApps && - cricketMatchHeaderUrl && - isCricketMatchReport && - isCricketRedesignEnabled - ) { + return null; + } return ( - <> - - - - - + + + ); } diff --git a/dotcom-rendering/src/layouts/lib/articleArrangements.ts b/dotcom-rendering/src/layouts/lib/articleArrangements.ts index 2cf5fb6843f..00c81dc3efb 100644 --- a/dotcom-rendering/src/layouts/lib/articleArrangements.ts +++ b/dotcom-rendering/src/layouts/lib/articleArrangements.ts @@ -2,7 +2,7 @@ import { css, type SerializedStyles } from '@emotion/react'; import { from, until } from '@guardian/source/foundations'; import { grid } from '../../grid'; -export type LayoutType = 'standard' | 'showcase' | 'media'; +export type LayoutType = 'standard' | 'showcase' | 'media' | 'portrait'; export type Area = | 'title' @@ -149,10 +149,38 @@ const mediaCss: LayoutCssMap = { }, }; +const portraitCss: LayoutCssMap = { + title: { + mobile: 'grid-row: 1;', + leftCol: 'grid-row: 1; grid-column: left-column-start / 9;', + }, + headline: { + mobile: 'grid-row: 2;', + leftCol: 'grid-row: 2; grid-column: left-column-start / 9;', + }, + media: { + mobile: 'grid-row: 3;', + leftCol: 'grid-row: 1 / span 3; grid-column: 10 / right-column-end;', + }, + standfirst: { + mobile: 'grid-row: 4;', + leftCol: 'grid-row: 3; grid-column: centre-column-start / 9;', + }, + meta: { + mobile: 'grid-row: 5;', + leftCol: + 'grid-row: 3; grid-column: left-column-start / left-column-end;', + }, + body: { + mobile: 'grid-row: 6;', + }, +}; + const layoutCssMaps: Record = { standard: standardCss, showcase: showcaseCss, media: mediaCss, + portrait: portraitCss, }; /** From 211c9c2af784c307bcd298964d8ce2312b4f0b2f Mon Sep 17 00:00:00 2001 From: Frederick O'Brien Date: Thu, 21 May 2026 16:23:11 +0100 Subject: [PATCH 05/13] Refactor grid centre rule, more breakpoints --- .../src/layouts/StandardLayout.tsx | 5 ++++ .../src/layouts/lib/articleArrangements.ts | 25 ++++++++++++++++--- 2 files changed, 27 insertions(+), 3 deletions(-) diff --git a/dotcom-rendering/src/layouts/StandardLayout.tsx b/dotcom-rendering/src/layouts/StandardLayout.tsx index 0c24acb106d..bf1083cb0d6 100644 --- a/dotcom-rendering/src/layouts/StandardLayout.tsx +++ b/dotcom-rendering/src/layouts/StandardLayout.tsx @@ -317,6 +317,7 @@ export const StandardLayout = (props: WebProps | AppProps) => { grid.container, grid.outerRules(), !isLabs && + layoutType !== 'portrait' && css` ${from.leftCol} { ${grid.centreRule( @@ -340,6 +341,10 @@ export const StandardLayout = (props: WebProps | AppProps) => { : '90px'}; } `, + layoutType === 'portrait' && + css` + grid-template-rows: 0.25fr 1fr auto; + `, ]} > = { mobile: until.tablet, tablet: from.tablet, desktop: from.desktop, leftCol: from.leftCol, + wide: from.wide, }; // Raw CSS overrides per area per breakpoint. Entries are only needed when an area @@ -152,28 +153,46 @@ const mediaCss: LayoutCssMap = { const portraitCss: LayoutCssMap = { title: { mobile: 'grid-row: 1;', + tablet: 'grid-row: 1;', + desktop: 'grid-row: 1; grid-column: centre-column-start / 8;', leftCol: 'grid-row: 1; grid-column: left-column-start / 9;', }, headline: { mobile: 'grid-row: 2;', + tablet: 'grid-row: 2;', + desktop: 'grid-row: 2; grid-column: centre-column-start / 8;', leftCol: 'grid-row: 2; grid-column: left-column-start / 9;', + wide: 'grid-row: 2; grid-column: left-column-start / 10;', }, media: { mobile: 'grid-row: 3;', - leftCol: 'grid-row: 1 / span 3; grid-column: 10 / right-column-end;', + tablet: 'grid-row: 3;', + desktop: 'grid-row: 1 / span 4; grid-column: 8 / right-column-end;', + leftCol: 'grid-row: 1 / span 3; grid-column: 9 / right-column-end;', + wide: 'grid-row: 1 / span 3; grid-column: 10 / right-column-end;', }, standfirst: { mobile: 'grid-row: 4;', - leftCol: 'grid-row: 3; grid-column: centre-column-start / 9;', + tablet: 'grid-row: 4;', + desktop: 'grid-row: 3; grid-column: centre-column-start / 7;', + leftCol: 'grid-row: 3; grid-column: centre-column-start / 8;', }, meta: { mobile: 'grid-row: 5;', + tablet: 'grid-row: 5;', + desktop: 'grid-row: 4; grid-column: centre-column-start / 8;', leftCol: 'grid-row: 3; grid-column: left-column-start / left-column-end;', }, body: { mobile: 'grid-row: 6;', }, + 'right-column': { + desktop: + 'grid-row: 5; grid-column: right-column-start / right-column-end;', + leftCol: + 'grid-row: 4; grid-column: right-column-start / right-column-end;', + }, }; const layoutCssMaps: Record = { From 6471809c8891350b8831264b76cd3073074b1c80 Mon Sep 17 00:00:00 2001 From: Frederick O'Brien Date: Tue, 26 May 2026 16:50:39 +0100 Subject: [PATCH 06/13] Rough implementation of portrait/landscape variants --- .../src/components/ArticleHeadline.tsx | 151 +-- .../src/components/ArticleTitle.tsx | 10 +- .../src/components/Standfirst.tsx | 1 - dotcom-rendering/src/layouts/DecideLayout.tsx | 5 +- .../src/layouts/ImmersiveLayout.tsx | 1024 ----------------- .../src/layouts/StandardLayout.tsx | 5 - .../src/layouts/lib/articleArrangements.ts | 53 +- dotcom-rendering/src/paletteDeclarations.ts | 15 +- 8 files changed, 112 insertions(+), 1152 deletions(-) delete mode 100644 dotcom-rendering/src/layouts/ImmersiveLayout.tsx diff --git a/dotcom-rendering/src/components/ArticleHeadline.tsx b/dotcom-rendering/src/components/ArticleHeadline.tsx index 3b0c141fc86..8beda81e2eb 100644 --- a/dotcom-rendering/src/components/ArticleHeadline.tsx +++ b/dotcom-rendering/src/components/ArticleHeadline.tsx @@ -27,7 +27,6 @@ import { ArticleSpecial, Pillar, } from '../lib/articleFormat'; -import { getZIndex } from '../lib/getZIndex'; import { palette as themePalette } from '../palette'; import type { StarRating as Rating } from '../types/content'; import type { TagType } from '../types/tag'; @@ -45,6 +44,7 @@ type Props = { hasAvatar?: boolean; isMatch?: boolean; starRating?: Rating; + isInverted?: boolean; }; const topPadding = css` @@ -214,30 +214,20 @@ const invertedStyles = css` box-decoration-break: clone; `; -const immersiveStyles = css` - min-height: 112px; - padding-bottom: ${space[6]}px; - padding-left: ${space[1]}px; - - ${from.mobileLandscape} { - padding-left: ${space[3]}px; - } - - ${from.tablet} { - padding-left: ${space[1]}px; - } - - margin-right: ${space[5]}px; -`; - const darkBackground = css` background-color: ${themePalette('--headline-background')}; `; const invertedText = css` - white-space: pre-wrap; - padding-bottom: ${space[1]}px; - padding-right: ${space[1]}px; + ${from.desktop} { + color: white; + background-color: black; + white-space: pre-wrap; + padding-bottom: ${space[1]}px; + padding-right: ${space[1]}px; + margin-left: -10px; + padding-left: 10px; + } `; const maxWidth = css` @@ -255,35 +245,6 @@ const invertedWrapper = css` margin-left: 6px; `; -const immersiveWrapper = css` - /* - Make sure we vertically align the headline font with the body font - */ - margin-left: 6px; - - ${from.tablet} { - margin-left: 16px; - } - - ${from.leftCol} { - margin-left: 25px; - } - - /* - We need this grow to ensure the headline fills the main content column - */ - flex-grow: 1; - /* - This z-index is what ensures the headline text shows above the pseudo black - box that extends the black background to the right - */ - z-index: ${getZIndex('articleHeadline')}; - - ${until.mobileLandscape} { - margin-right: 40px; - } -`; - // Due to MainMedia using position: relative, this seems to effect the rendering order // To mitigate we use z-index // TODO: find a cleaner solution @@ -292,59 +253,58 @@ const zIndex = css` `; const ageWarningMargins = (format: ArticleFormat) => { - if (format.design === ArticleDesign.Gallery) { + if ( + format.design === ArticleDesign.Gallery || + format.display === ArticleDisplay.Immersive + ) { return ''; } - return format.display === ArticleDisplay.Immersive - ? css` - margin-left: 0px; - margin-bottom: 0px; - - ${from.tablet} { - margin-left: 10px; - } + return css` + margin-top: 12px; + margin-left: -10px; + margin-bottom: 6px; - ${from.leftCol} { - margin-left: 20px; - } - ` - : css` - margin-top: 12px; - margin-left: -10px; - margin-bottom: 6px; - - ${from.tablet} { - margin-left: -20px; - } + ${from.tablet} { + margin-left: -20px; + } - ${from.leftCol} { - margin-left: -10px; - margin-top: 0; - } - `; + ${from.leftCol} { + margin-left: -10px; + margin-top: 0; + } + `; }; -const backgroundStyles = css` - background-color: ${themePalette('--age-warning-wrapper-background')}; -`; - const WithAgeWarning = ({ tags, webPublicationDateDeprecated, format, children, + snapToInverted = false, }: { tags: TagType[]; webPublicationDateDeprecated: string; format: ArticleFormat; children: React.ReactNode; + snapToInverted?: boolean; }) => { const age = getAgeWarning(tags, webPublicationDateDeprecated); if (age) { return ( <> -
+
{children} @@ -439,6 +399,7 @@ export const ArticleHeadline = ({ hasAvatar, isMatch, starRating, + isInverted = false, }: Props) => { switch (format.display) { case ArticleDisplay.Immersive: { @@ -465,12 +426,13 @@ export const ArticleHeadline = ({ format.theme === ArticleSpecial.Labs ? labsFont : headlineFont(format), - invertedText, - css` - color: ${themePalette( - '--headline-colour', - )}; - `, + isInverted + ? [invertedText, darkBackground] + : css` + color: ${themePalette( + '--headline-colour', + )}; + `, ]} > {headlineString} @@ -496,16 +458,17 @@ export const ArticleHeadline = ({ webPublicationDateDeprecated } format={format} + snapToInverted={true} >

diff --git a/dotcom-rendering/src/components/ArticleTitle.tsx b/dotcom-rendering/src/components/ArticleTitle.tsx index de44e1eb319..2cf4d717f3a 100644 --- a/dotcom-rendering/src/components/ArticleTitle.tsx +++ b/dotcom-rendering/src/components/ArticleTitle.tsx @@ -28,17 +28,11 @@ const sectionStyles = css` `; const immersiveMargins = css` - max-width: 400px; + max-width: 500px; min-width: 200px; margin-bottom: 4px; - /* - Make sure we vertically align the title font with the body font - */ ${from.tablet} { - margin-left: 16px; - } - ${from.leftCol} { - margin-left: 25px; + margin-left: -4px; } `; diff --git a/dotcom-rendering/src/components/Standfirst.tsx b/dotcom-rendering/src/components/Standfirst.tsx index 0567d1dbc99..e95212f3302 100644 --- a/dotcom-rendering/src/components/Standfirst.tsx +++ b/dotcom-rendering/src/components/Standfirst.tsx @@ -268,7 +268,6 @@ const standfirstStyles = ({ display, design, theme }: ArticleFormat) => { `; default: return css` - max-width: 280px; ${from.tablet} { max-width: 460px; } diff --git a/dotcom-rendering/src/layouts/DecideLayout.tsx b/dotcom-rendering/src/layouts/DecideLayout.tsx index b6dd7f4a27c..3e1ccd79cb8 100644 --- a/dotcom-rendering/src/layouts/DecideLayout.tsx +++ b/dotcom-rendering/src/layouts/DecideLayout.tsx @@ -10,7 +10,6 @@ import { GalleryLayout } from './GalleryLayout'; import { HostedArticleLayout } from './HostedArticleLayout'; import { HostedGalleryLayout } from './HostedGalleryLayout'; import { HostedVideoLayout } from './HostedVideoLayout'; -import { ImmersiveLayout } from './ImmersiveLayout'; import { InteractiveLayout } from './InteractiveLayout'; import { LiveLayout } from './LiveLayout'; import { NewsletterSignupLayout } from './NewsletterSignupLayout'; @@ -57,7 +56,7 @@ const DecideLayoutApps = ({ article, renderingTarget }: AppProps) => { } default: { return ( - { } default: { return ( - ( -
- {children} -
-); - -const maxWidth = css` - ${from.desktop} { - max-width: 620px; - } -`; - -const linesMargin = css` - ${from.leftCol} { - margin-top: ${space[5]}px; - } -`; - -const stretchLines = css` - ${until.phablet} { - margin-left: -20px; - margin-right: -20px; - } - ${until.mobileLandscape} { - margin-left: -10px; - margin-right: -10px; - } -`; - -interface CommonProps { - article: ArticleDeprecated; - format: ArticleFormat; - serverTime?: number; -} - -interface WebProps extends CommonProps { - NAV: NavType; - renderingTarget: 'Web'; -} - -interface AppProps extends CommonProps { - renderingTarget: 'Apps'; -} - -const Box = ({ children }: { children: React.ReactNode }) => ( -
- {children} -
-); - -export const ImmersiveLayout = (props: WebProps | AppProps) => { - const { article, format, renderingTarget, serverTime } = props; - - const { - config: { isPaidContent, host, hasSurveyAd }, - editionId, - } = article; - const isWeb = renderingTarget === 'Web'; - const isApps = renderingTarget === 'Apps'; - - const showBodyEndSlot = - isWeb && - (parse(article.slotMachineFlags ?? '').showBodyEnd || - article.config.switches.slotBodyEnd); - - // TODO: - // 1) Read 'forceEpic' value from URL parameter and use it to force the slot to render - // 2) Otherwise, ensure slot only renders if `article.config.shouldHideReaderRevenue` equals false. - - const showComments = article.isCommentable && !isPaidContent; - - const mainMedia = article.mainMediaElements[0]; - - const captionText = decideMainMediaCaption(mainMedia); - - const HEADLINE_OFFSET = mainMedia ? 120 : 0; - - const { branding } = article.commercialProperties[article.editionId]; - - const contributionsServiceUrl = getContributionsServiceUrl(article); - - const isLabs = format.theme === ArticleSpecial.Labs; - - /** - We need change the height values depending on whether the labs header is there or not to keep - the headlines appearing at a consistent height between labs and non labs immersive articles. - */ - - const labsHeaderHeight = LABS_HEADER_HEIGHT; - const combinedHeight = (minHeaderHeightPx + labsHeaderHeight).toString(); - - const navAndLabsHeaderHeight = isLabs - ? `${combinedHeight}px` - : `${minHeaderHeightPx}px`; - - const hasMainMediaStyles = css` - height: calc(80vh - ${navAndLabsHeaderHeight}); - /** - 80vh is normally enough but don't let the content shrink vertically too - much just in case - */ - min-height: calc(25rem - ${navAndLabsHeaderHeight}); - ${from.desktop} { - height: calc(100vh - ${navAndLabsHeaderHeight}); - min-height: calc(31.25rem - ${navAndLabsHeaderHeight}); - } - ${from.wide} { - min-height: calc(50rem - ${navAndLabsHeaderHeight}); - } - `; - const LeftColCaption = () => ( -
- -
- ); - - const renderAds = canRenderAds(article); - - return ( - <> - {isWeb && ( - tag.id)} - sectionId={article.config.section} - contentType={article.contentType} - /> - )} - - - - {format.theme === ArticleSpecial.Labs && ( - -
- -
-
- )} - -
-
- -
- {mainMedia && ( - <> -
-
} - > - -
- -
- -
-
-
- - )} -
- - {isWeb && renderAds && hasSurveyAd && ( - - )} - -
- {isApps && renderAds && ( - - - - )} -
- - {/* Above leftCol, the Caption is controlled by Section ^^ */} - - - - - - - {format.design === ArticleDesign.PhotoEssay ? ( - <> - ) : ( - - )} - - - <> - {!mainMedia && ( -
- -
- )} - -
- - <> - {!mainMedia && ( -
- -
- )} - -
- - - - - {!!article.byline && ( - - )} - {/* Only show Listen to Article button on App landscape views */} - {isApps && ( - -
- - - -
-
- )} -
- - {format.design === ArticleDesign.PhotoEssay && - !isLabs ? ( - <> - ) : ( -
-
- {format.theme === - ArticleSpecial.Labs ? ( - - ) : ( - - )} -
-
- )} -
- {isApps ? ( - <> - - - - - - {!!article.affiliateLinksDisclaimer && ( - - )} - - - ) : ( - <> - - {!!article.affiliateLinksDisclaimer && ( - - )} - - )} -
-
- - - - {showBodyEndSlot && ( - - - - )} - - - - - -
- - <> - {mainMedia && isWeb && renderAds && ( -
- { - - } -
- )} - -
-
-
-
-
- {!isLabs && isWeb && renderAds && ( -
- -
- )} - - {article.storyPackage && ( -
- - - -
- )} - - - - - - {showComments && ( -
- -
- )} - {!isPaidContent && ( -
- - - - - -
- )} - {!isLabs && isWeb && renderAds && ( -
- -
- )} -
- - {isWeb && props.NAV.subNavSections && ( -
- - - -
- )} - - {isWeb && ( - <> -
-
-
- - - - - - - {renderAds && ( - - )} - - )} - {isApps && ( -
- - - -
- )} - - ); -}; diff --git a/dotcom-rendering/src/layouts/StandardLayout.tsx b/dotcom-rendering/src/layouts/StandardLayout.tsx index bf1083cb0d6..0c24acb106d 100644 --- a/dotcom-rendering/src/layouts/StandardLayout.tsx +++ b/dotcom-rendering/src/layouts/StandardLayout.tsx @@ -317,7 +317,6 @@ export const StandardLayout = (props: WebProps | AppProps) => { grid.container, grid.outerRules(), !isLabs && - layoutType !== 'portrait' && css` ${from.leftCol} { ${grid.centreRule( @@ -341,10 +340,6 @@ export const StandardLayout = (props: WebProps | AppProps) => { : '90px'}; } `, - layoutType === 'portrait' && - css` - grid-template-rows: 0.25fr 1fr auto; - `, ]} > = { standard: standardCss, showcase: showcaseCss, media: mediaCss, - portrait: portraitCss, + immersiveLandscape: immersiveLandscapeCss, + immersivePortrait: immersivePortraitCss, }; /** diff --git a/dotcom-rendering/src/paletteDeclarations.ts b/dotcom-rendering/src/paletteDeclarations.ts index 22aee146b06..781d9d974ae 100644 --- a/dotcom-rendering/src/paletteDeclarations.ts +++ b/dotcom-rendering/src/paletteDeclarations.ts @@ -88,7 +88,7 @@ const textblockTextDark: PaletteFunction = () => 'inherit'; const headlineTextLight: PaletteFunction = ({ design, display, theme }) => { switch (display) { case ArticleDisplay.Immersive: - return sourcePalette.neutral[97]; + return sourcePalette.neutral[7]; default: { switch (design) { case ArticleDesign.Editorial: @@ -195,19 +195,8 @@ const headlineMatchTextLight: PaletteFunction = (format) => const headlineMatchTextDark: PaletteFunction = (format) => seriesTitleMatchTextDark(format); -const headlineBackgroundLight: PaletteFunction = ({ - display, - design, - theme, -}) => { +const headlineBackgroundLight: PaletteFunction = ({ display, design }) => { switch (display) { - case ArticleDisplay.Immersive: - switch (theme) { - case ArticleSpecial.SpecialReport: - return sourcePalette.specialReport[300]; - default: - return sourcePalette.neutral[7]; - } case ArticleDisplay.Showcase: case ArticleDisplay.NumberedList: case ArticleDisplay.Standard: From a87148c07a6cb987fcccc74b3ef7382233b36aaa Mon Sep 17 00:00:00 2001 From: Frederick O'Brien Date: Thu, 11 Jun 2026 15:41:01 +0100 Subject: [PATCH 07/13] Feature specific styling --- .../src/components/ArticleHeadline.tsx | 5 +- dotcom-rendering/src/components/Figure.tsx | 53 ++++++++++++++++++- dotcom-rendering/src/components/MainMedia.tsx | 1 + dotcom-rendering/src/grid.ts | 2 + 4 files changed, 57 insertions(+), 4 deletions(-) diff --git a/dotcom-rendering/src/components/ArticleHeadline.tsx b/dotcom-rendering/src/components/ArticleHeadline.tsx index 8beda81e2eb..96917e0fa74 100644 --- a/dotcom-rendering/src/components/ArticleHeadline.tsx +++ b/dotcom-rendering/src/components/ArticleHeadline.tsx @@ -44,7 +44,6 @@ type Props = { hasAvatar?: boolean; isMatch?: boolean; starRating?: Rating; - isInverted?: boolean; }; const topPadding = css` @@ -399,8 +398,10 @@ export const ArticleHeadline = ({ hasAvatar, isMatch, starRating, - isInverted = false, }: Props) => { + const isInverted = + format.design === ArticleDesign.Standard && + format.display === ArticleDisplay.Immersive; switch (format.display) { case ArticleDisplay.Immersive: { switch (format.design) { diff --git a/dotcom-rendering/src/components/Figure.tsx b/dotcom-rendering/src/components/Figure.tsx index ccb113fb4b1..ab99567f7a6 100644 --- a/dotcom-rendering/src/components/Figure.tsx +++ b/dotcom-rendering/src/components/Figure.tsx @@ -1,6 +1,18 @@ import { css } from '@emotion/react'; -import { breakpoints, from, space, until } from '@guardian/source/foundations'; -import { ArticleDesign, type ArticleFormat } from '../lib/articleFormat'; +import { + breakpoints, + from, + palette as sourcePalette, + space, + until, +} from '@guardian/source/foundations'; +import { + ArticleDesign, + ArticleDisplay, + type ArticleFormat, +} from '../lib/articleFormat'; +import { transparentColour } from '../lib/transparentColour'; +import { palette } from '../palette'; import type { FEElement, RoleType } from '../types/content'; type Props = { @@ -14,6 +26,32 @@ type Props = { isTimeline?: boolean; }; +const overlayMaskGradientStyles = (angle: string) => css` + mask-image: linear-gradient( + ${angle}, + rgb(0, 0, 0) 0%, + rgba(0, 0, 0, 0.9619) 12.5%, + rgba(0, 0, 0, 0.8536) 25%, + rgba(0, 0, 0, 0.6913) 37.5%, + rgba(0, 0, 0, 0.5) 50%, + rgba(0, 0, 0, 0.3087) 62.5%, + rgba(0, 0, 0, 0.1464) 75%, + rgba(0, 0, 0, 0.0381) 87.5%, + transparent 100% + ); +`; + +const blurStyles = css` + position: absolute; + inset: 0; + background-color: ${palette('--article-background')}; + backdrop-filter: blur(12px) brightness(0.5); + @supports not (backdrop-filter: blur(12px)) { + background-color: ${transparentColour(sourcePalette.neutral[10], 0.7)}; + } + ${overlayMaskGradientStyles('0deg')}; +`; + const roleCss = { inline: css` margin-top: ${space[3]}px; @@ -273,6 +311,17 @@ export const Figure = ({ return (
{children} + {format.display === ArticleDisplay.Immersive && ( +
+ )}
); } diff --git a/dotcom-rendering/src/components/MainMedia.tsx b/dotcom-rendering/src/components/MainMedia.tsx index e912f480019..96869f41479 100644 --- a/dotcom-rendering/src/components/MainMedia.tsx +++ b/dotcom-rendering/src/components/MainMedia.tsx @@ -12,6 +12,7 @@ import type { ServerSideTests, Switches } from '../types/config'; import type { FEElement } from '../types/content'; const mainMedia = css` + position: relative; height: 100%; ${until.tablet} { diff --git a/dotcom-rendering/src/grid.ts b/dotcom-rendering/src/grid.ts index a02e5c3f861..5e7a0111455 100644 --- a/dotcom-rendering/src/grid.ts +++ b/dotcom-rendering/src/grid.ts @@ -159,6 +159,7 @@ const outerRules = (color?: string): string => ` &::before { grid-column: centre-column-start; transform: translateX(-${columnGap}); + z-index: 10; ${fromBreakpoint.leftCol} { grid-column: left-column-start; @@ -169,6 +170,7 @@ const outerRules = (color?: string): string => ` &::after { grid-column: right-column-end; transform: translateX(-1px); + z-index: 10; ${betweenBreakpoint.tablet.and.desktop} { grid-column: centre-column-end; From dd621369b3c01c74d85d92e3cd8368edcd48fd78 Mon Sep 17 00:00:00 2001 From: Frederick O'Brien Date: Tue, 23 Jun 2026 15:43:13 +0100 Subject: [PATCH 08/13] Use grid module helpers for columns --- .../src/layouts/lib/articleArrangements.ts | 47 ++++++++----------- 1 file changed, 20 insertions(+), 27 deletions(-) diff --git a/dotcom-rendering/src/layouts/lib/articleArrangements.ts b/dotcom-rendering/src/layouts/lib/articleArrangements.ts index b79d70fb720..0da7bcc5219 100644 --- a/dotcom-rendering/src/layouts/lib/articleArrangements.ts +++ b/dotcom-rendering/src/layouts/lib/articleArrangements.ts @@ -159,45 +159,42 @@ const immersivePortraitCss: LayoutCssMap = { title: { mobile: 'grid-row: 1;', tablet: 'grid-row: 1;', - desktop: 'grid-row: 1; grid-column: centre-column-start / 8;', - leftCol: 'grid-row: 1; grid-column: left-column-start / 9;', + desktop: `grid-row: 1; ${grid.between('centre-column-start', 8)};`, + leftCol: `grid-row: 1; ${grid.between('left-column-start', 9)};`, }, headline: { mobile: 'grid-row: 2;', tablet: 'grid-row: 2;', - desktop: 'grid-row: 2; grid-column: centre-column-start / 8;', - leftCol: 'grid-row: 2; grid-column: left-column-start / 9;', - wide: 'grid-row: 2; grid-column: left-column-start / 10;', + desktop: `grid-row: 2; ${grid.between('centre-column-start', 8)};`, + leftCol: `grid-row: 2; ${grid.between('left-column-start', 9)};`, + wide: `grid-row: 2; ${grid.between('left-column-start', 10)};`, }, media: { mobile: 'grid-row: 3;', tablet: 'grid-row: 3;', - desktop: 'grid-row: 1 / span 4; grid-column: 8 / right-column-end;', - leftCol: 'grid-row: 1 / span 3; grid-column: 9 / right-column-end;', - wide: 'grid-row: 1 / span 3; grid-column: 10 / right-column-end;', + desktop: `grid-row: 1 / span 4; ${grid.between('centre-column-start', 'right-column-end')};`, + leftCol: `grid-row: 1 / span 3; ${grid.between('left-column-start', 'right-column-end')};`, + wide: `grid-row: 1 / span 3; ${grid.between('left-column-start', 'right-column-end')};`, }, standfirst: { mobile: 'grid-row: 4;', tablet: 'grid-row: 4;', - desktop: 'grid-row: 3; grid-column: centre-column-start / 7;', - leftCol: 'grid-row: 3; grid-column: centre-column-start / 8;', - wide: 'grid-row: 3; grid-column: centre-column-start / 9;', + desktop: `grid-row: 3; ${grid.between('centre-column-start', 7)};`, + leftCol: `grid-row: 3; ${grid.between('centre-column-start', 8)};`, + wide: `grid-row: 3; ${grid.between('centre-column-start', 9)};`, }, meta: { mobile: 'grid-row: 5;', tablet: 'grid-row: 5;', - desktop: 'grid-row: 4; grid-column: centre-column-start / 8;', - leftCol: - 'grid-row: 3; grid-column: left-column-start / left-column-end;', + desktop: `grid-row: 4; ${grid.between('centre-column-start', 8)};`, + leftCol: `grid-row: 3; ${grid.column.left};`, }, body: { mobile: 'grid-row: 6;', }, 'right-column': { - desktop: - 'grid-row: 5; grid-column: right-column-start / right-column-end;', - leftCol: - 'grid-row: 4; grid-column: right-column-start / right-column-end;', + desktop: `grid-row: 5; ${grid.column.right};`, + leftCol: `grid-row: 4; ${grid.column.right};`, }, }; @@ -215,10 +212,8 @@ const immersiveLandscapeCss: LayoutCssMap = { media: { mobile: 'grid-row: 3;', tablet: 'grid-row: 3;', - desktop: - 'grid-row: 1 / span 3; grid-column: centre-column-start / right-column-end;', - leftCol: - 'grid-row: 1 / span 3; grid-column: left-column-start / right-column-end;', + desktop: `grid-row: 1 / span 3; ${grid.between('centre-column-start', 'right-column-end')};`, + leftCol: `grid-row: 1 / span 3; ${grid.between('left-column-start', 'right-column-end')};`, }, standfirst: { mobile: 'grid-row: 4;', @@ -228,16 +223,14 @@ const immersiveLandscapeCss: LayoutCssMap = { meta: { mobile: 'grid-row: 5;', tablet: 'grid-row: 5;', - desktop: 'grid-row: 6; grid-column: centre-column-start / 8;', - leftCol: - 'grid-row: 6 / span 2; grid-column: left-column-start / left-column-end;', + desktop: `grid-row: 6; ${grid.between('centre-column-start', 8)};`, + leftCol: `grid-row: 6 / span 2; ${grid.column.left};`, }, body: { mobile: 'grid-row: 6;', }, 'right-column': { - desktop: - 'grid-row: 5 / span 3; grid-column: right-column-start / right-column-end;', + desktop: `grid-row: 5 / span 3; ${grid.column.right};`, }, }; From 231cf0228727ca04bd7ca61e16a5fb4e1845210c Mon Sep 17 00:00:00 2001 From: Frederick O'Brien Date: Tue, 23 Jun 2026 16:14:51 +0100 Subject: [PATCH 09/13] First, clumsy pass at immersive forking --- .../src/layouts/lib/articleArrangements.ts | 83 +++++++++++++++++-- 1 file changed, 77 insertions(+), 6 deletions(-) diff --git a/dotcom-rendering/src/layouts/lib/articleArrangements.ts b/dotcom-rendering/src/layouts/lib/articleArrangements.ts index 0da7bcc5219..5a192931bce 100644 --- a/dotcom-rendering/src/layouts/lib/articleArrangements.ts +++ b/dotcom-rendering/src/layouts/lib/articleArrangements.ts @@ -6,8 +6,10 @@ export type LayoutType = | 'standard' | 'showcase' | 'media' - | 'immersiveLandscape' - | 'immersivePortrait'; + | 'immersiveLandscapeDefault' + | 'immersiveLandscapeFeature' + | 'immersivePortraitDefault' + | 'immersivePortraitFeature'; export type Area = | 'title' @@ -155,7 +157,7 @@ const mediaCss: LayoutCssMap = { }, }; -const immersivePortraitCss: LayoutCssMap = { +const immersivePortraitDefaultCss: LayoutCssMap = { title: { mobile: 'grid-row: 1;', tablet: 'grid-row: 1;', @@ -198,7 +200,50 @@ const immersivePortraitCss: LayoutCssMap = { }, }; -const immersiveLandscapeCss: LayoutCssMap = { +const immersivePortraitFeatureCss: LayoutCssMap = { + title: { + mobile: 'grid-row: 1;', + tablet: 'grid-row: 1;', + desktop: `grid-row: 1; ${grid.between('centre-column-start', 8)};`, + leftCol: `grid-row: 1; ${grid.between('left-column-start', 9)};`, + }, + headline: { + mobile: 'grid-row: 2;', + tablet: 'grid-row: 2;', + desktop: `grid-row: 2; ${grid.between('centre-column-start', 8)};`, + leftCol: `grid-row: 2; ${grid.between('left-column-start', 9)};`, + wide: `grid-row: 2; ${grid.between('left-column-start', 10)};`, + }, + media: { + mobile: 'grid-row: 3;', + tablet: 'grid-row: 3;', + desktop: `grid-row: 1 / span 4; ${grid.between('centre-column-start', 'right-column-end')};`, + leftCol: `grid-row: 1 / span 3; ${grid.between('left-column-start', 'right-column-end')};`, + wide: `grid-row: 1 / span 3; ${grid.between('left-column-start', 'right-column-end')};`, + }, + standfirst: { + mobile: 'grid-row: 4;', + tablet: 'grid-row: 4;', + desktop: `grid-row: 3; ${grid.between('centre-column-start', 7)};`, + leftCol: `grid-row: 3; ${grid.between('centre-column-start', 8)};`, + wide: `grid-row: 3; ${grid.between('centre-column-start', 9)};`, + }, + meta: { + mobile: 'grid-row: 5;', + tablet: 'grid-row: 5;', + desktop: `grid-row: 4; ${grid.between('centre-column-start', 8)};`, + leftCol: `grid-row: 3; ${grid.column.left};`, + }, + body: { + mobile: 'grid-row: 6;', + }, + 'right-column': { + desktop: `grid-row: 5; ${grid.column.right};`, + leftCol: `grid-row: 4; ${grid.column.right};`, + }, +}; + +const immersiveLandscapeDefaultCss: LayoutCssMap = { title: { mobile: 'grid-row: 1;', tablet: 'grid-row: 1;', @@ -234,12 +279,38 @@ const immersiveLandscapeCss: LayoutCssMap = { }, }; +const immersiveLandscapeFeatureCss: LayoutCssMap = { + title: { + desktop: 'grid-row: 2;', + }, + headline: { + desktop: 'grid-row: 3 / span 2;', + }, + media: { + mobile: `${grid.column.all}`, + desktop: `grid-row: 1 / span 3; ${grid.between('centre-column-start', 'right-column-end')};`, + leftCol: `grid-row: 1 / span 3; ${grid.between('left-column-start', 'right-column-end')};`, + }, + standfirst: { + desktop: 'grid-row: 5;', + }, + meta: { + desktop: `grid-row: 6; ${grid.between('centre-column-start', 8)};`, + leftCol: `grid-row: 6 / span 2; ${grid.column.left};`, + }, + 'right-column': { + desktop: `grid-row: 5 / span 3; ${grid.column.right};`, + }, +}; + const layoutCssMaps: Record = { standard: standardCss, showcase: showcaseCss, media: mediaCss, - immersiveLandscape: immersiveLandscapeCss, - immersivePortrait: immersivePortraitCss, + immersiveLandscapeDefault: immersiveLandscapeDefaultCss, + immersiveLandscapeFeature: immersiveLandscapeFeatureCss, + immersivePortraitDefault: immersivePortraitDefaultCss, + immersivePortraitFeature: immersivePortraitFeatureCss, }; /** From 39b646d97277623ab1edd7a1d5927d9072852cba Mon Sep 17 00:00:00 2001 From: Frederick O'Brien Date: Tue, 30 Jun 2026 16:47:05 +0100 Subject: [PATCH 10/13] Distinguish between feature and non-feature immersives --- .../src/components/ArticleHeadline.tsx | 4 +-- dotcom-rendering/src/components/Figure.tsx | 23 ++++++------ .../src/layouts/lib/articleArrangements.ts | 35 +++++++++++++++++++ 3 files changed, 49 insertions(+), 13 deletions(-) diff --git a/dotcom-rendering/src/components/ArticleHeadline.tsx b/dotcom-rendering/src/components/ArticleHeadline.tsx index 96917e0fa74..f7951d9f145 100644 --- a/dotcom-rendering/src/components/ArticleHeadline.tsx +++ b/dotcom-rendering/src/components/ArticleHeadline.tsx @@ -400,8 +400,8 @@ export const ArticleHeadline = ({ starRating, }: Props) => { const isInverted = - format.design === ArticleDesign.Standard && - format.display === ArticleDisplay.Immersive; + format.display === ArticleDisplay.Immersive && + format.design === ArticleDesign.Standard; switch (format.display) { case ArticleDisplay.Immersive: { switch (format.design) { diff --git a/dotcom-rendering/src/components/Figure.tsx b/dotcom-rendering/src/components/Figure.tsx index ab99567f7a6..7534acd1cd1 100644 --- a/dotcom-rendering/src/components/Figure.tsx +++ b/dotcom-rendering/src/components/Figure.tsx @@ -311,17 +311,18 @@ export const Figure = ({ return (
{children} - {format.display === ArticleDisplay.Immersive && ( -
- )} + {format.display === ArticleDisplay.Immersive && + format.design === ArticleDesign.Feature && ( +
+ )}
); } diff --git a/dotcom-rendering/src/layouts/lib/articleArrangements.ts b/dotcom-rendering/src/layouts/lib/articleArrangements.ts index 5a192931bce..2812f4f6c49 100644 --- a/dotcom-rendering/src/layouts/lib/articleArrangements.ts +++ b/dotcom-rendering/src/layouts/lib/articleArrangements.ts @@ -350,3 +350,38 @@ export const gridItemCss = ( ${breakpointCss} `; }; + +/** + * Determines which {@link LayoutType} to render. Immersive layouts are + * split by orientation (portrait vs. landscape/square) and by whether the + * format is a Feature, since each combination has a distinct grid + * arrangement. Non-immersive formats fall back to media/showcase/standard. + */ +export const getLayoutType = ({ + isImmersive, + isFeature, + orientation, + isVideo, + isShowcase, +}: { + isImmersive: boolean; + isFeature: boolean; + orientation: 'portrait' | 'landscape' | 'square'; + isVideo: boolean; + isShowcase: boolean; +}): LayoutType => { + if (isImmersive) { + if (orientation === 'portrait') { + return isFeature + ? 'immersivePortraitFeature' + : 'immersivePortraitDefault'; + } + // Square images are treated the same as landscape for immersive layouts. + return isFeature + ? 'immersiveLandscapeFeature' + : 'immersiveLandscapeDefault'; + } + if (isVideo) return 'media'; + if (isShowcase) return 'showcase'; + return 'standard'; +}; From 86a943f7d7e1e9d0e495af6dfd1d3f59e815731f Mon Sep 17 00:00:00 2001 From: Frederick O'Brien Date: Tue, 30 Jun 2026 17:46:29 +0100 Subject: [PATCH 11/13] Fix portrait main media config --- .../src/layouts/lib/articleArrangements.ts | 12 ++++++------ 1 file changed, 6 insertions(+), 6 deletions(-) diff --git a/dotcom-rendering/src/layouts/lib/articleArrangements.ts b/dotcom-rendering/src/layouts/lib/articleArrangements.ts index 2812f4f6c49..e6e64cffe34 100644 --- a/dotcom-rendering/src/layouts/lib/articleArrangements.ts +++ b/dotcom-rendering/src/layouts/lib/articleArrangements.ts @@ -174,9 +174,9 @@ const immersivePortraitDefaultCss: LayoutCssMap = { media: { mobile: 'grid-row: 3;', tablet: 'grid-row: 3;', - desktop: `grid-row: 1 / span 4; ${grid.between('centre-column-start', 'right-column-end')};`, - leftCol: `grid-row: 1 / span 3; ${grid.between('left-column-start', 'right-column-end')};`, - wide: `grid-row: 1 / span 3; ${grid.between('left-column-start', 'right-column-end')};`, + desktop: `grid-row: 1 / span 4; ${grid.between(8, 'right-column-end')};`, + leftCol: `grid-row: 1 / span 3; ${grid.between(9, 'right-column-end')};`, + wide: `grid-row: 1 / span 3; ${grid.between(10, 'right-column-end')};`, }, standfirst: { mobile: 'grid-row: 4;', @@ -217,9 +217,9 @@ const immersivePortraitFeatureCss: LayoutCssMap = { media: { mobile: 'grid-row: 3;', tablet: 'grid-row: 3;', - desktop: `grid-row: 1 / span 4; ${grid.between('centre-column-start', 'right-column-end')};`, - leftCol: `grid-row: 1 / span 3; ${grid.between('left-column-start', 'right-column-end')};`, - wide: `grid-row: 1 / span 3; ${grid.between('left-column-start', 'right-column-end')};`, + desktop: `grid-row: 1 / span 4; ${grid.between(8, 'right-column-end')};`, + leftCol: `grid-row: 1 / span 3; ${grid.between(9, 'right-column-end')};`, + wide: `grid-row: 1 / span 3; ${grid.between(10, 'right-column-end')};`, }, standfirst: { mobile: 'grid-row: 4;', From 05d9d264024af840d6b513d0de5f0529323efd1a Mon Sep 17 00:00:00 2001 From: Frederick O'Brien Date: Tue, 30 Jun 2026 18:21:43 +0100 Subject: [PATCH 12/13] Tie main media styling to article arrangement --- dotcom-rendering/src/components/Figure.tsx | 32 ++--- dotcom-rendering/src/components/MainMedia.tsx | 4 + .../src/layouts/StandardLayout.tsx | 132 ++++++++++++------ dotcom-rendering/src/lib/renderElement.tsx | 4 + 4 files changed, 109 insertions(+), 63 deletions(-) diff --git a/dotcom-rendering/src/components/Figure.tsx b/dotcom-rendering/src/components/Figure.tsx index 7534acd1cd1..83a444ac608 100644 --- a/dotcom-rendering/src/components/Figure.tsx +++ b/dotcom-rendering/src/components/Figure.tsx @@ -6,11 +6,8 @@ import { space, until, } from '@guardian/source/foundations'; -import { - ArticleDesign, - ArticleDisplay, - type ArticleFormat, -} from '../lib/articleFormat'; +import type { LayoutType } from '../layouts/lib/articleArrangements'; +import { ArticleDesign, type ArticleFormat } from '../lib/articleFormat'; import { transparentColour } from '../lib/transparentColour'; import { palette } from '../palette'; import type { FEElement, RoleType } from '../types/content'; @@ -24,6 +21,7 @@ type Props = { className?: string; type?: FEElement['_type']; isTimeline?: boolean; + articleArrangement?: LayoutType; }; const overlayMaskGradientStyles = (angle: string) => css` @@ -301,6 +299,7 @@ export const Figure = ({ className = '', type, isTimeline = false, + articleArrangement = 'standard', }: Props) => { if (isMainMedia && !isTimeline) { // Don't add in-body styles for main media elements @@ -311,18 +310,17 @@ export const Figure = ({ return (
{children} - {format.display === ArticleDisplay.Immersive && - format.design === ArticleDesign.Feature && ( -
- )} + {articleArrangement === 'immersiveLandscapeFeature' && ( +
+ )}
); } diff --git a/dotcom-rendering/src/components/MainMedia.tsx b/dotcom-rendering/src/components/MainMedia.tsx index 96869f41479..a3b2f1897ba 100644 --- a/dotcom-rendering/src/components/MainMedia.tsx +++ b/dotcom-rendering/src/components/MainMedia.tsx @@ -1,5 +1,6 @@ import { css } from '@emotion/react'; import { breakpoints, space, until } from '@guardian/source/foundations'; +import type { LayoutType } from '../layouts/lib/articleArrangements'; import { ArticleDesign, ArticleDisplay, @@ -97,6 +98,7 @@ type Props = { shouldHideAds: boolean; contentType?: string; contentLayout?: string; + articleArrangement?: LayoutType; }; export const MainMedia = ({ @@ -115,6 +117,7 @@ export const MainMedia = ({ shouldHideAds, contentType, contentLayout, + articleArrangement, }: Props) => { return (
@@ -138,6 +141,7 @@ export const MainMedia = ({ shouldHideAds={shouldHideAds} contentType={contentType} contentLayout={contentLayout} + articleArrangement={articleArrangement} /> ))}
diff --git a/dotcom-rendering/src/layouts/StandardLayout.tsx b/dotcom-rendering/src/layouts/StandardLayout.tsx index 0c24acb106d..47934ee2269 100644 --- a/dotcom-rendering/src/layouts/StandardLayout.tsx +++ b/dotcom-rendering/src/layouts/StandardLayout.tsx @@ -20,6 +20,7 @@ import { ArticleMetaApps } from '../components/ArticleMeta.apps'; import { ArticleMeta } from '../components/ArticleMeta.web'; import { ArticleTitle } from '../components/ArticleTitle'; import { Carousel } from '../components/Carousel.island'; +import { CricketMatchHeaderWrapper } from '../components/CricketMatchHeaderWrapper.island'; import { DecideLines } from '../components/DecideLines'; import { DirectoryPageNavIsland } from '../components/DirectoryPageNavIsland'; import { DiscussionLayout } from '../components/DiscussionLayout'; @@ -33,6 +34,7 @@ import { LabsHeader } from '../components/LabsHeader'; import { ListenToArticle } from '../components/ListenToArticle.island'; import { MainMedia } from '../components/MainMedia'; import { Masthead } from '../components/Masthead/Masthead'; +import { MatchHeaderFallback } from '../components/MatchHeaderFallback'; import { MostViewedFooterData } from '../components/MostViewedFooterData.island'; import { MostViewedFooterLayout } from '../components/MostViewedFooterLayout'; import { MostViewedRightWithAd } from '../components/MostViewedRightWithAd.island'; @@ -54,9 +56,9 @@ import { import { canRenderAds } from '../lib/canRenderAds'; import { getContributionsServiceUrl } from '../lib/contributions'; import { decideStoryPackageTrails } from '../lib/decideTrail'; -import type { EditionId } from '../lib/edition'; import { safeParseURL } from '../lib/parse'; import { parse } from '../lib/slot-machine-flags'; +import { useAB } from '../lib/useAB'; import { worldCupTagId } from '../lib/worldCup2026'; import type { NavType } from '../model/extract-nav'; import { palette as themePalette } from '../palette'; @@ -167,16 +169,16 @@ export const StandardLayout = (props: WebProps | AppProps) => { ? article.matchStatsUrl : undefined; - const footballMatchHeaderUrl = - article.matchType === 'FootballMatchType' - ? article.matchHeaderUrl - : undefined; + const isFootballMatchReport = + format.design === ArticleDesign.MatchReport && !!footballMatchUrl; - const footballMatchLeagueName = article.sectionLabel; - const footballMatchLeagueUrl = `${article.guardianBaseURL}/${article.sectionUrl}`; + const cricketMatchUrl = + article.matchType === 'CricketMatchType' + ? article.matchStatsUrl + : undefined; - const isMatchReport = - format.design === ArticleDesign.MatchReport && !!footballMatchUrl; + const isCricketMatchReport = + format.design === ArticleDesign.MatchReport && !!cricketMatchUrl; const isMedia = format.design === ArticleDesign.Video || @@ -286,12 +288,11 @@ export const StandardLayout = (props: WebProps | AppProps) => { /> {isWeb && renderAds && hasSurveyAd && ( @@ -604,7 +605,7 @@ export const StandardLayout = (props: WebProps | AppProps) => { idApiUrl={article.config.idApiUrl} /> @@ -940,43 +941,82 @@ export const StandardLayout = (props: WebProps | AppProps) => { }; const MatchHeaderContainer = ({ - isMatchReport, - footballMatchHeaderUrl, - leagueName, - leagueUrl, - editionId, + isFootballMatchReport, + isCricketMatchReport, renderingTarget, + article, + format, }: { - isMatchReport: boolean; - footballMatchHeaderUrl: string | undefined; - leagueName: string; - leagueUrl: string; - editionId: EditionId; + isFootballMatchReport: boolean; + isCricketMatchReport: boolean; renderingTarget: RenderingTarget; + article: ArticleDeprecated; + format: ArticleFormat; }) => { - if (isMatchReport && !!footballMatchHeaderUrl) { - const parsedUrl = safeParseURL(footballMatchHeaderUrl); - if (!parsedUrl.ok) { - log( - 'dotcom', - new Error( - `Failed to parse match header URL: ${footballMatchHeaderUrl}`, - ), - ); + const footballMatchHeaderUrl = + article.matchType === 'FootballMatchType' + ? article.matchHeaderUrl + : undefined; - return null; - } + const footballMatchLeagueName = article.sectionLabel; + const footballMatchLeagueUrl = `${article.guardianBaseURL}/${article.sectionUrl}`; + + const cricketMatchHeaderUrl = + article.matchType === 'CricketMatchType' + ? article.matchHeaderUrl + : undefined; + + const ab = useAB(); + const isCricketRedesignEnabled = Boolean( + ab?.isUserInTestGroup('webx-cricket-redesign', 'enable'), + ); + + const isApps = renderingTarget === 'Apps'; + + if (isFootballMatchReport && footballMatchHeaderUrl) { return ( - - - + <> + + + + + + ); + } + + if ( + !isApps && + cricketMatchHeaderUrl && + isCricketMatchReport && + isCricketRedesignEnabled + ) { + return ( + <> + + + + + ); } diff --git a/dotcom-rendering/src/lib/renderElement.tsx b/dotcom-rendering/src/lib/renderElement.tsx index f877210f059..694334c9a58 100644 --- a/dotcom-rendering/src/lib/renderElement.tsx +++ b/dotcom-rendering/src/lib/renderElement.tsx @@ -68,6 +68,7 @@ import { } from '../components/WitnessBlockComponent'; import { YoutubeBlockComponent } from '../components/YoutubeBlockComponent.island'; import { YoutubeEmbedBlockComponent } from '../components/YoutubeEmbedBlockComponent'; +import type { LayoutType } from '../layouts/lib/articleArrangements'; import { interactiveLegacyFigureClasses, isInteractive, @@ -107,6 +108,7 @@ type Props = { shouldHideAds: boolean; contentType?: string; contentLayout?: string; + articleArrangement?: LayoutType; idApiUrl?: string; }; @@ -1050,6 +1052,7 @@ export const RenderArticleElement = ({ shouldHideAds, contentType, contentLayout, + articleArrangement, idApiUrl, }: Props) => { const withUpdatedRole = updateRole(element, format); @@ -1103,6 +1106,7 @@ export const RenderArticleElement = ({ type={element._type} format={format} isTimeline={isTimeline} + articleArrangement={articleArrangement} > {el} From 0dafbd31efaaa836d1d696ce32435025ad6a74a9 Mon Sep 17 00:00:00 2001 From: Frederick O'Brien Date: Wed, 1 Jul 2026 14:17:56 +0100 Subject: [PATCH 13/13] Restore wrapper --- .../src/layouts/StandardLayout.tsx | 718 +++++++++--------- 1 file changed, 370 insertions(+), 348 deletions(-) diff --git a/dotcom-rendering/src/layouts/StandardLayout.tsx b/dotcom-rendering/src/layouts/StandardLayout.tsx index 47934ee2269..48580b4d356 100644 --- a/dotcom-rendering/src/layouts/StandardLayout.tsx +++ b/dotcom-rendering/src/layouts/StandardLayout.tsx @@ -307,182 +307,227 @@ export const StandardLayout = (props: WebProps | AppProps) => { )} {/* GridItem order matters — mobile layout relies on DOM order for grid placement. - See furnitureArrangements.ts if reordering. */} -
+
- - - - - - - - - - - - - - {layoutType !== 'immersivePortraitDefault' && ( -
- {isWeb && - format.theme === ArticleSpecial.Labs && - format.design !== ArticleDesign.Video ? ( - - ) : ( - - )} -
- )} - {isApps ? ( - <> - - - - + > + +
+ + + + + + + + + + + {layoutType !== 'immersivePortraitDefault' && ( +
+ {isWeb && + format.theme === ArticleSpecial.Labs && + format.design !== ArticleDesign.Video ? ( + + ) : ( + + )} +
+ )} + {isApps ? ( + <> + + + + + + {!!article.affiliateLinksDisclaimer && ( + + )} + + + ) : ( + <> { {!!article.affiliateLinksDisclaimer && ( )} + + )} +
+ + {/* Only show Listen to Article button on App landscape views */} + {isApps && ( + + {!isVideo && ( +
+ + + +
+ )}
- - ) : ( - <> - + + - {!!article.affiliateLinksDisclaimer && ( - + + {isApps && ( + + + )} - - )} -
- - {/* Only show Listen to Article button on App landscape views */} - {isApps && ( - - {!isVideo && ( -
- - - -
+ + )} -
- )} - - + + +
+ - - - {isApps && ( - - - - )} - - {showBodyEndSlot && ( + `} + > + - - )} - - - - - - - - - - - -
+ + +
+

{isWeb && renderAds && !isLabs && (