From e3eb674dbeaa6ebcc5fff4a8db3106b060ec2813 Mon Sep 17 00:00:00 2001 From: Frederick O'Brien Date: Tue, 19 May 2026 15:25:40 +0100 Subject: [PATCH 1/7] Rough first pass --- .../src/layouts/StandardLayoutArticleGrid.tsx | 164 ++++++++++++--- .../src/layouts/lib/articleArrangements.ts | 197 +++++++++++++++++- 2 files changed, 335 insertions(+), 26 deletions(-) diff --git a/dotcom-rendering/src/layouts/StandardLayoutArticleGrid.tsx b/dotcom-rendering/src/layouts/StandardLayoutArticleGrid.tsx index 4d6aab78791..006e0b2b1a6 100644 --- a/dotcom-rendering/src/layouts/StandardLayoutArticleGrid.tsx +++ b/dotcom-rendering/src/layouts/StandardLayoutArticleGrid.tsx @@ -22,6 +22,7 @@ import { SlotBodyEnd } from '../components/SlotBodyEnd.island'; import { Standfirst } from '../components/Standfirst'; import { SubMeta } from '../components/SubMeta'; import { grid } from '../grid'; +import { getAgeWarning } from '../lib/age-warning'; import { ArticleDesign, ArticleDisplay, @@ -36,6 +37,7 @@ import type { ArticleDeprecated } from '../types/article'; import type { RenderingTarget } from '../types/renderingTarget'; import { type Area, + getLayoutType, gridItemCss, type LayoutType, } from './lib/articleArrangements'; @@ -59,6 +61,23 @@ interface GridItemProps { children: React.ReactNode; } +/** + * 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'; +}; + const GridItem = ({ area, layoutType, @@ -110,6 +129,8 @@ export const StandardLayoutArticleGrid = ({ 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; @@ -121,11 +142,36 @@ export const StandardLayoutArticleGrid = ({ const isFootballMatchReport = format.design === ArticleDesign.MatchReport && !!footballMatchUrl; - 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 (
- + - + - + - -
- {isWeb && - format.theme === ArticleSpecial.Labs && - format.design !== ArticleDesign.Video ? ( - - ) : ( - - )} -
+ + {layoutType !== 'immersivePortraitDefault' && ( +
+ {isWeb && + format.theme === ArticleSpecial.Labs && + format.design !== ArticleDesign.Video ? ( + + ) : ( + + )} +
+ )} {isApps ? ( <> @@ -263,11 +379,11 @@ export const StandardLayoutArticleGrid = ({ secondaryDateline={ article.webPublicationSecondaryDateDisplay } + webPublicationDate={article.webPublicationDate} isCommentable={article.isCommentable} discussionApiUrl={article.config.discussionApiUrl} shortUrlId={article.config.shortUrlId} mainMediaElements={article.mainMediaElements} - webPublicationDate={article.webPublicationDate} /> {!!article.affiliateLinksDisclaimer && ( diff --git a/dotcom-rendering/src/layouts/lib/articleArrangements.ts b/dotcom-rendering/src/layouts/lib/articleArrangements.ts index 2cf5fb6843f..e6e64cffe34 100644 --- a/dotcom-rendering/src/layouts/lib/articleArrangements.ts +++ b/dotcom-rendering/src/layouts/lib/articleArrangements.ts @@ -2,7 +2,14 @@ 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' + | 'immersiveLandscapeDefault' + | 'immersiveLandscapeFeature' + | 'immersivePortraitDefault' + | 'immersivePortraitFeature'; export type Area = | 'title' @@ -13,13 +20,14 @@ export type Area = | 'body' | 'right-column'; -type Breakpoint = 'mobile' | 'tablet' | 'desktop' | 'leftCol'; +type Breakpoint = 'mobile' | 'tablet' | 'desktop' | 'leftCol' | 'wide'; const breakpointQueries: Record = { 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 @@ -149,10 +157,160 @@ const mediaCss: LayoutCssMap = { }, }; +const immersivePortraitDefaultCss: 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(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;', + 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 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(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;', + 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;', + desktop: 'grid-row: 2;', + }, + headline: { + mobile: 'grid-row: 2;', + tablet: 'grid-row: 2;', + desktop: 'grid-row: 3 / span 2;', + }, + media: { + mobile: 'grid-row: 3;', + tablet: 'grid-row: 3;', + 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;', + tablet: 'grid-row: 4;', + desktop: 'grid-row: 5;', + }, + meta: { + mobile: 'grid-row: 5;', + tablet: 'grid-row: 5;', + 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};`, + }, +}; + +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, + immersiveLandscapeDefault: immersiveLandscapeDefaultCss, + immersiveLandscapeFeature: immersiveLandscapeFeatureCss, + immersivePortraitDefault: immersivePortraitDefaultCss, + immersivePortraitFeature: immersivePortraitFeatureCss, }; /** @@ -192,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 1423bbdad8184a39ae2a838ae179236fb4aff91c Mon Sep 17 00:00:00 2001 From: Frederick O'Brien Date: Thu, 21 May 2026 16:23:11 +0100 Subject: [PATCH 2/7] Refactor grid centre rule, more breakpoints --- .../src/layouts/StandardLayout.tsx | 905 ++++++++++-------- 1 file changed, 497 insertions(+), 408 deletions(-) diff --git a/dotcom-rendering/src/layouts/StandardLayout.tsx b/dotcom-rendering/src/layouts/StandardLayout.tsx index 6c0d9dfc14f..006e0b2b1a6 100644 --- a/dotcom-rendering/src/layouts/StandardLayout.tsx +++ b/dotcom-rendering/src/layouts/StandardLayout.tsx @@ -1,71 +1,138 @@ -import { palette as sourcePalette } from '@guardian/source/foundations'; -import { AdPortals } from '../components/AdPortals.island'; -import { AdSlot, MobileStickyContainer } from '../components/AdSlot.web'; -import { AppsFooter } from '../components/AppsFooter.island'; -import { Carousel } from '../components/Carousel.island'; -import { CricketMatchHeaderWrapper } from '../components/CricketMatchHeaderWrapper.island'; -import { DirectoryPageNavIsland } from '../components/DirectoryPageNavIsland'; -import { DiscussionLayout } from '../components/DiscussionLayout'; -import { FootballMatchHeaderWrapper } from '../components/FootballMatchHeaderWrapper.island'; -import { Footer } from '../components/Footer'; -import { HeaderAdSlot } from '../components/HeaderAdSlot'; +import { css } from '@emotion/react'; +import { log } from '@guardian/libs'; +import { from, space, until } from '@guardian/source/foundations'; +import { Hide } from '@guardian/source/react-components'; +import { StraightLines } from '@guardian/source-development-kitchen/react-components'; +import { AffiliateDisclaimer } from '../components/AffiliateDisclaimer'; +import { AppsEpic } from '../components/AppsEpic.island'; +import { ArticleBody } from '../components/ArticleBody'; +import { ArticleContainer } from '../components/ArticleContainer'; +import { ArticleHeadline } from '../components/ArticleHeadline'; +import { ArticleMetaApps } from '../components/ArticleMeta.apps'; +import { ArticleMeta } from '../components/ArticleMeta.web'; +import { ArticleTitle } from '../components/ArticleTitle'; +import { DecideLines } from '../components/DecideLines'; +import { FootballMatchInfoWrapper } from '../components/FootballMatchInfoWrapper.island'; +import { GuardianLabsLines } from '../components/GuardianLabsLines'; import { Island } from '../components/Island'; -import { LabsHeader } from '../components/LabsHeader'; -import { Masthead } from '../components/Masthead/Masthead'; -import { MatchHeaderFallback } from '../components/MatchHeaderFallback'; -import { MostViewedFooterData } from '../components/MostViewedFooterData.island'; -import { MostViewedFooterLayout } from '../components/MostViewedFooterLayout'; -import { OnwardsUpper } from '../components/OnwardsUpper.island'; -import { Section } from '../components/Section'; -import { StickyBottomBanner } from '../components/StickyBottomBanner.island'; -import { SubNav } from '../components/SubNav.island'; +import { ListenToArticle } from '../components/ListenToArticle.island'; +import { MainMedia } from '../components/MainMedia'; +import { MostViewedRightWithAd } from '../components/MostViewedRightWithAd.island'; +import { SlotBodyEnd } from '../components/SlotBodyEnd.island'; +import { Standfirst } from '../components/Standfirst'; +import { SubMeta } from '../components/SubMeta'; +import { grid } from '../grid'; +import { getAgeWarning } from '../lib/age-warning'; import { ArticleDesign, ArticleDisplay, type ArticleFormat, ArticleSpecial, } from '../lib/articleFormat'; -import { canRenderAds } from '../lib/canRenderAds'; import { getContributionsServiceUrl } from '../lib/contributions'; -import { decideStoryPackageTrails } from '../lib/decideTrail'; -import { useAB } from '../lib/useAB'; -import { worldCupTagId } from '../lib/worldCup2026'; -import type { NavType } from '../model/extract-nav'; +import { safeParseURL } from '../lib/parse'; +import { parse } from '../lib/slot-machine-flags'; import { palette as themePalette } from '../palette'; import type { ArticleDeprecated } from '../types/article'; import type { RenderingTarget } from '../types/renderingTarget'; -import { BannerWrapper, Stuck } from './lib/stickiness'; -import { StandardLayoutArticleGrid } from './StandardLayoutArticleGrid'; +import { + type Area, + getLayoutType, + gridItemCss, + type LayoutType, +} from './lib/articleArrangements'; + +const stretchLines = css` + ${until.phablet} { + margin-left: -20px; + margin-right: -20px; + } + ${until.mobileLandscape} { + margin-left: -10px; + margin-right: -10px; + } +`; + +interface GridItemProps { + area: Area; + layoutType: LayoutType; + element?: 'div' | 'aside'; + className?: string; + children: React.ReactNode; +} + +/** + * 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'; +}; -interface Props { +const GridItem = ({ + area, + layoutType, + element: Element = 'div', + className, + children, +}: GridItemProps) => ( + + {children} + +); + +export const StandardLayoutArticleGrid = ({ + article, + format, + renderingTarget, +}: { article: ArticleDeprecated; format: ArticleFormat; renderingTarget: RenderingTarget; - serverTime?: number; -} - -interface WebProps extends Props { - NAV: NavType; - renderingTarget: 'Web'; -} - -interface AppProps extends Props { - renderingTarget: 'Apps'; -} - -export const StandardLayout = (props: WebProps | AppProps) => { - const { article, format, renderingTarget, serverTime } = props; +}) => { const { - config: { isPaidContent, host, hasSurveyAd }, - editionId, + config: { host }, } = article; - const isWeb = renderingTarget === 'Web'; const isApps = renderingTarget === 'Apps'; + const renderAds = isWeb && !article.shouldHideAds; + + const contributionsServiceUrl = getContributionsServiceUrl(article); + + 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 { branding } = article.commercialProperties[article.editionId]; + + const footballMatchStatsUrl = + article.matchType === 'FootballMatchType' + ? article.matchStatsUrl + : undefined; + + const isLabs = format.theme === ArticleSpecial.Labs; + 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 footballMatchUrl = article.matchType === 'FootballMatchType' @@ -75,283 +142,325 @@ export const StandardLayout = (props: WebProps | AppProps) => { const isFootballMatchReport = format.design === ArticleDesign.MatchReport && !!footballMatchUrl; - const cricketMatchUrl = - article.matchType == 'CricketMatchType' ? article.matchUrl : undefined; - - const isCricketMatchReport = - format.design === ArticleDesign.MatchReport && !!cricketMatchUrl; - - const showComments = article.isCommentable && !isPaidContent; - - const contributionsServiceUrl = getContributionsServiceUrl(article); - - const isLabs = format.theme === ArticleSpecial.Labs; - - const isWorldCup2026 = article.tags.some((tag) => tag.id === worldCupTagId); + const firstMainMediaElement = article.mainMediaElements[0]; + const mainMediaUrl: string | undefined = + firstMainMediaElement?._type === + 'model.dotcomrendering.pageElements.ImageBlockElement' + ? firstMainMediaElement.media.allImages[0]?.url + : undefined; - const renderAds = canRenderAds(article); + 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 ( - <> - {isWeb && ( -
- {renderAds && ( - -
- -
-
- )} - tag.id)} - sectionId={article.config.section} - contentType={article.contentType} - /> -
- )} - - {format.theme === ArticleSpecial.Labs && ( - -
- -
-
- )} - - - - - {isWeb && renderAds && hasSurveyAd && ( - - )} - -
- {isApps && renderAds && ( - - - - )} - - {/* This element is used to replace the article with the scorecard when the scorecard tab is clicked */} -
- -
- {isWeb && renderAds && !isLabs && ( -
- -
- )} - - {article.storyPackage && ( -
+ + + + + + + + + + + + + + {layoutType !== 'immersivePortraitDefault' && ( +
+ {isWeb && + format.theme === ArticleSpecial.Labs && + format.design !== ArticleDesign.Video ? ( + + ) : ( + )} - borderColour={themePalette('--article-border')} - > - - + )} + {isApps ? ( + <> + + + + + - -
+ {!!article.affiliateLinksDisclaimer && ( + + )} + + + ) : ( + <> + + {!!article.affiliateLinksDisclaimer && ( + + )} + )} - - - + + {/* Only show Listen to Article button on App landscape views */} + {isApps && ( + + {!isVideo && ( +
+ + + +
+ )} +
+ )} + + + -
- {showComments && ( -
- -
- )} - - {!isPaidContent && ( -
- - - - - -
- )} - {isWeb && renderAds && !isLabs && ( -
- -
- )} -
- {isWeb && ( - <> - {props.NAV.subNavSections && ( -
- - - -
+ + )} -
-
-
- - - + { article.pageType.isMinuteArticle } isPaidContent={article.pageType.isPaidContent} - isPreview={!!article.config.isPreview} - isSensitive={article.config.isSensitive} pageId={article.pageId} sectionId={article.config.section} shouldHideReaderRevenue={ article.shouldHideReaderRevenue } - remoteBannerSwitch={ - !!article.config.switches.remoteBanner - } tags={article.tags} - host={host} + renderAds={renderAds} + isLabs={isLabs} + articleEndSlot={ + !!article.config.switches.articleEndSlot + } + isSensitive={article.config.isSensitive} /> - - {renderAds && ( - )} - - )} - - {isApps && ( - <> -
+ + + + + + - - - -
- - )} - + + +
+
+
); }; -const MatchHeaderContainer = ({ - isFootballMatchReport, - isCricketMatchReport, - renderingTarget, - article, - format, +const MatchInfoContainer = ({ + isMatchReport, + footballMatchStatsUrl, }: { - isFootballMatchReport: boolean; - isCricketMatchReport: boolean; - renderingTarget: RenderingTarget; - article: ArticleDeprecated; - format: ArticleFormat; + isMatchReport: boolean; + footballMatchStatsUrl: string | undefined; }) => { - 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 ( - !isApps && - cricketMatchHeaderUrl && - isCricketMatchReport && - isCricketRedesignEnabled - ) { + if (isMatchReport && !!footballMatchStatsUrl) { + const parsedUrl = safeParseURL(footballMatchStatsUrl); + if (!parsedUrl.ok) { + log( + 'dotcom', + new Error( + `Failed to parse match stats URL: ${footballMatchStatsUrl}`, + ), + ); + + return null; + } return ( - <> - - - - - + + + ); } From 49aed4fca92782d09bd4435dcff37434642df8ac Mon Sep 17 00:00:00 2001 From: Frederick O'Brien Date: Tue, 26 May 2026 16:50:39 +0100 Subject: [PATCH 3/7] 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 | 1022 ----------------- dotcom-rendering/src/paletteDeclarations.ts | 15 +- 6 files changed, 62 insertions(+), 1142 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/paletteDeclarations.ts b/dotcom-rendering/src/paletteDeclarations.ts index 39528551531..56f82c29652 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 28c64e81a16c8f304a8fc40efe614d906e08d19c Mon Sep 17 00:00:00 2001 From: Frederick O'Brien Date: Thu, 11 Jun 2026 15:41:01 +0100 Subject: [PATCH 4/7] 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 871c4606715..7b980173c99 100644 --- a/dotcom-rendering/src/components/MainMedia.tsx +++ b/dotcom-rendering/src/components/MainMedia.tsx @@ -12,6 +12,7 @@ import type { 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 e1603703e44ccaefb4df2cdaad1878feb0596d55 Mon Sep 17 00:00:00 2001 From: Frederick O'Brien Date: Tue, 30 Jun 2026 16:47:05 +0100 Subject: [PATCH 5/7] Distinguish between feature and non-feature immersives --- .../src/components/ArticleHeadline.tsx | 4 ++-- dotcom-rendering/src/components/Figure.tsx | 23 ++++++++++--------- 2 files changed, 14 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 && ( +
+ )}
); } From 12a8e031a13d46dad978c7b88d20ebb91f3f6557 Mon Sep 17 00:00:00 2001 From: Frederick O'Brien Date: Tue, 30 Jun 2026 18:21:43 +0100 Subject: [PATCH 6/7] 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 | 1 + dotcom-rendering/src/lib/renderElement.tsx | 4 +++ 4 files changed, 24 insertions(+), 17 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 7b980173c99..3a2d30e23f5 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, @@ -96,6 +97,7 @@ type Props = { shouldHideAds: boolean; contentType?: string; contentLayout?: string; + articleArrangement?: LayoutType; }; export const MainMedia = ({ @@ -113,6 +115,7 @@ export const MainMedia = ({ shouldHideAds, contentType, contentLayout, + articleArrangement, }: Props) => { return (
@@ -135,6 +138,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 006e0b2b1a6..377c2c4693c 100644 --- a/dotcom-rendering/src/layouts/StandardLayout.tsx +++ b/dotcom-rendering/src/layouts/StandardLayout.tsx @@ -236,6 +236,7 @@ export const StandardLayoutArticleGrid = ({ shouldHideAds={article.shouldHideAds} contentType={article.contentType} contentLayout={contentLayoutName} + articleArrangement={layoutType} /> { const withUpdatedRole = updateRole(element, format); @@ -1093,6 +1096,7 @@ export const RenderArticleElement = ({ type={element._type} format={format} isTimeline={isTimeline} + articleArrangement={articleArrangement} > {el} From 444bb069901dd3b5cf92699b0439a0ba1ae2243c Mon Sep 17 00:00:00 2001 From: Frederick O'Brien Date: Tue, 7 Jul 2026 16:15:55 +0100 Subject: [PATCH 7/7] Rebase tidying --- .../src/components/ArticleHeadline.tsx | 5 +- .../src/layouts/StandardLayout.tsx | 906 ++++++++---------- .../src/layouts/StandardLayoutArticleGrid.tsx | 10 +- 3 files changed, 419 insertions(+), 502 deletions(-) diff --git a/dotcom-rendering/src/components/ArticleHeadline.tsx b/dotcom-rendering/src/components/ArticleHeadline.tsx index f7951d9f145..bcfd9d920b0 100644 --- a/dotcom-rendering/src/components/ArticleHeadline.tsx +++ b/dotcom-rendering/src/components/ArticleHeadline.tsx @@ -44,6 +44,7 @@ type Props = { hasAvatar?: boolean; isMatch?: boolean; starRating?: Rating; + isInverted?: boolean; }; const topPadding = css` @@ -398,10 +399,8 @@ export const ArticleHeadline = ({ hasAvatar, isMatch, starRating, + isInverted, }: Props) => { - const isInverted = - format.display === ArticleDisplay.Immersive && - format.design === ArticleDesign.Standard; switch (format.display) { case ArticleDisplay.Immersive: { switch (format.design) { diff --git a/dotcom-rendering/src/layouts/StandardLayout.tsx b/dotcom-rendering/src/layouts/StandardLayout.tsx index 377c2c4693c..6c0d9dfc14f 100644 --- a/dotcom-rendering/src/layouts/StandardLayout.tsx +++ b/dotcom-rendering/src/layouts/StandardLayout.tsx @@ -1,138 +1,71 @@ -import { css } from '@emotion/react'; -import { log } from '@guardian/libs'; -import { from, space, until } from '@guardian/source/foundations'; -import { Hide } from '@guardian/source/react-components'; -import { StraightLines } from '@guardian/source-development-kitchen/react-components'; -import { AffiliateDisclaimer } from '../components/AffiliateDisclaimer'; -import { AppsEpic } from '../components/AppsEpic.island'; -import { ArticleBody } from '../components/ArticleBody'; -import { ArticleContainer } from '../components/ArticleContainer'; -import { ArticleHeadline } from '../components/ArticleHeadline'; -import { ArticleMetaApps } from '../components/ArticleMeta.apps'; -import { ArticleMeta } from '../components/ArticleMeta.web'; -import { ArticleTitle } from '../components/ArticleTitle'; -import { DecideLines } from '../components/DecideLines'; -import { FootballMatchInfoWrapper } from '../components/FootballMatchInfoWrapper.island'; -import { GuardianLabsLines } from '../components/GuardianLabsLines'; +import { palette as sourcePalette } from '@guardian/source/foundations'; +import { AdPortals } from '../components/AdPortals.island'; +import { AdSlot, MobileStickyContainer } from '../components/AdSlot.web'; +import { AppsFooter } from '../components/AppsFooter.island'; +import { Carousel } from '../components/Carousel.island'; +import { CricketMatchHeaderWrapper } from '../components/CricketMatchHeaderWrapper.island'; +import { DirectoryPageNavIsland } from '../components/DirectoryPageNavIsland'; +import { DiscussionLayout } from '../components/DiscussionLayout'; +import { FootballMatchHeaderWrapper } from '../components/FootballMatchHeaderWrapper.island'; +import { Footer } from '../components/Footer'; +import { HeaderAdSlot } from '../components/HeaderAdSlot'; import { Island } from '../components/Island'; -import { ListenToArticle } from '../components/ListenToArticle.island'; -import { MainMedia } from '../components/MainMedia'; -import { MostViewedRightWithAd } from '../components/MostViewedRightWithAd.island'; -import { SlotBodyEnd } from '../components/SlotBodyEnd.island'; -import { Standfirst } from '../components/Standfirst'; -import { SubMeta } from '../components/SubMeta'; -import { grid } from '../grid'; -import { getAgeWarning } from '../lib/age-warning'; +import { LabsHeader } from '../components/LabsHeader'; +import { Masthead } from '../components/Masthead/Masthead'; +import { MatchHeaderFallback } from '../components/MatchHeaderFallback'; +import { MostViewedFooterData } from '../components/MostViewedFooterData.island'; +import { MostViewedFooterLayout } from '../components/MostViewedFooterLayout'; +import { OnwardsUpper } from '../components/OnwardsUpper.island'; +import { Section } from '../components/Section'; +import { StickyBottomBanner } from '../components/StickyBottomBanner.island'; +import { SubNav } from '../components/SubNav.island'; import { ArticleDesign, ArticleDisplay, type ArticleFormat, ArticleSpecial, } from '../lib/articleFormat'; +import { canRenderAds } from '../lib/canRenderAds'; import { getContributionsServiceUrl } from '../lib/contributions'; -import { safeParseURL } from '../lib/parse'; -import { parse } from '../lib/slot-machine-flags'; +import { decideStoryPackageTrails } from '../lib/decideTrail'; +import { useAB } from '../lib/useAB'; +import { worldCupTagId } from '../lib/worldCup2026'; +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, - getLayoutType, - gridItemCss, - type LayoutType, -} from './lib/articleArrangements'; - -const stretchLines = css` - ${until.phablet} { - margin-left: -20px; - margin-right: -20px; - } - ${until.mobileLandscape} { - margin-left: -10px; - margin-right: -10px; - } -`; - -interface GridItemProps { - area: Area; - layoutType: LayoutType; - element?: 'div' | 'aside'; - className?: string; - children: React.ReactNode; -} - -/** - * 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'; -}; +import { BannerWrapper, Stuck } from './lib/stickiness'; +import { StandardLayoutArticleGrid } from './StandardLayoutArticleGrid'; -const GridItem = ({ - area, - layoutType, - element: Element = 'div', - className, - children, -}: GridItemProps) => ( - - {children} - -); - -export const StandardLayoutArticleGrid = ({ - article, - format, - renderingTarget, -}: { +interface Props { article: ArticleDeprecated; format: ArticleFormat; renderingTarget: RenderingTarget; -}) => { - const { - config: { host }, - } = article; - const isWeb = renderingTarget === 'Web'; - const isApps = renderingTarget === 'Apps'; - const renderAds = isWeb && !article.shouldHideAds; - - const contributionsServiceUrl = getContributionsServiceUrl(article); + serverTime?: number; +} - const showBodyEndSlot = - isWeb && - (parse(article.slotMachineFlags ?? '').showBodyEnd || - article.config.switches.slotBodyEnd); +interface WebProps extends Props { + NAV: NavType; + renderingTarget: 'Web'; +} - const { branding } = article.commercialProperties[article.editionId]; +interface AppProps extends Props { + renderingTarget: 'Apps'; +} - const footballMatchStatsUrl = - article.matchType === 'FootballMatchType' - ? article.matchStatsUrl - : undefined; +export const StandardLayout = (props: WebProps | AppProps) => { + const { article, format, renderingTarget, serverTime } = props; + const { + config: { isPaidContent, host, hasSurveyAd }, + editionId, + } = article; - const isLabs = format.theme === ArticleSpecial.Labs; - 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 isWeb = renderingTarget === 'Web'; + const isApps = renderingTarget === 'Apps'; - const isVideo = format.design === ArticleDesign.Video; + // 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 footballMatchUrl = article.matchType === 'FootballMatchType' @@ -142,326 +75,283 @@ export const StandardLayoutArticleGrid = ({ const isFootballMatchReport = format.design === ArticleDesign.MatchReport && !!footballMatchUrl; - const firstMainMediaElement = article.mainMediaElements[0]; - const mainMediaUrl: string | undefined = - firstMainMediaElement?._type === - 'model.dotcomrendering.pageElements.ImageBlockElement' - ? firstMainMediaElement.media.allImages[0]?.url - : undefined; + const cricketMatchUrl = + article.matchType == 'CricketMatchType' ? article.matchUrl : 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, - ); + const isCricketMatchReport = + format.design === ArticleDesign.MatchReport && !!cricketMatchUrl; + + const showComments = article.isCommentable && !isPaidContent; + + const contributionsServiceUrl = getContributionsServiceUrl(article); + + const isLabs = format.theme === ArticleSpecial.Labs; + + const isWorldCup2026 = article.tags.some((tag) => tag.id === worldCupTagId); + + const renderAds = canRenderAds(article); return ( -
- - - - - - - - - - - - - - {layoutType !== 'immersivePortraitDefault' && ( -
- {isWeb && - format.theme === ArticleSpecial.Labs && - format.design !== ArticleDesign.Video ? ( - - ) : ( - - )} -
+ <> + {isWeb && ( +
+ {renderAds && ( + +
+ +
+
+ )} + tag.id)} + sectionId={article.config.section} + contentType={article.contentType} + /> +
+ )} + + {format.theme === ArticleSpecial.Labs && ( + +
+ +
+
+ )} + + + + + {isWeb && renderAds && hasSurveyAd && ( + + )} + +
+ {isApps && renderAds && ( + + + )} - {isApps ? ( - <> - - - - - + +

+ {isWeb && renderAds && !isLabs && ( +
+ +
+ )} + + {article.storyPackage && ( +
+ + - {!!article.affiliateLinksDisclaimer && ( - - )} - - - ) : ( - <> - - {!!article.affiliateLinksDisclaimer && ( - - )} - - )} - - - {/* Only show Listen to Article button on App landscape views */} - {isApps && ( - - {!isVideo && ( -
- - - -
- )} -
+
+
)} - - + - + + {showComments && ( +
+ +
+ )} + + {!isPaidContent && ( +
+ + + + + +
+ )} - {isApps && ( - + + + )} + + {isWeb && ( + <> + {props.NAV.subNavSections && ( +
- - + + + +
)} - - {showBodyEndSlot && ( - - +