diff --git a/dotcom-rendering/src/components/ArticleHeadline.tsx b/dotcom-rendering/src/components/ArticleHeadline.tsx
index c6c2ee2eb78..5ae764d0e73 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`
@@ -205,30 +205,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`
@@ -246,35 +236,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
@@ -283,59 +244,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}
@@ -430,6 +390,7 @@ export const ArticleHeadline = ({
hasAvatar,
isMatch,
starRating,
+ isInverted = false,
}: Props) => {
switch (format.display) {
case ArticleDisplay.Immersive: {
@@ -456,12 +417,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}
@@ -487,16 +449,17 @@ export const ArticleHeadline = ({
webPublicationDateDeprecated
}
format={format}
+ snapToInverted={true}
>
diff --git a/dotcom-rendering/src/components/Standfirst.tsx b/dotcom-rendering/src/components/Standfirst.tsx
index 61be63fe1a3..b178b92930b 100644
--- a/dotcom-rendering/src/components/Standfirst.tsx
+++ b/dotcom-rendering/src/components/Standfirst.tsx
@@ -264,7 +264,6 @@ const standfirstStyles = ({ display, design, theme }: ArticleFormat) => {
`;
default:
return css`
- max-width: 280px;
${from.tablet} {
max-width: 460px;
}
diff --git a/dotcom-rendering/src/grid.ts b/dotcom-rendering/src/grid.ts
index ad089db43e1..455229450c5 100644
--- a/dotcom-rendering/src/grid.ts
+++ b/dotcom-rendering/src/grid.ts
@@ -88,35 +88,55 @@ const paddedContainer = `
// ----- Vertical Rules ----- //
type VerticalRuleOptions = {
- centre?: boolean;
+ plusChild?: number;
};
/**
* Render Guardian grid vertical rules.
*
* Left and right rules are always present.
- * A centre rule can optionally be enabled.
+ * A centre rule can optionally be enabled, anchored to the top of the first
+ * child by default, or to the nth child if a number is passed.
*
* Usage:
* css([grid.container, grid.verticalRules()])
* css([grid.container, grid.verticalRules({ centre: true })])
+ * css([grid.container, grid.verticalRules({ centre: 3 })])
*/
-const optionalCentreRule = `/* CENTRE RULE */
- & > *:first-child::before {
- grid-column: centre-column-start;
- transform: translateX(-${columnGap});
- ${fromBreakpoint.leftCol} {
- transform: translateX(calc(-${columnGap} / 2));
- }
+
+// The centre rule is self-contained on the nth child element rather than on
+// the grid container, so that `top: 0` aligns to that element's top edge.
+// `bottom` uses a large negative value to extend the rule down to the
+// container's bottom; ensure `overflow: hidden` is set on the container
+const optionalCentreRule = (nth: number): string => `/* CENTRE RULE */
+ & > *:nth-child(${nth}) {
+ position: relative;
+
+ &::before {
+ position: absolute;
+ top: 0;
+ bottom: -9999px;
+ width: 1px;
+ background-color: ${palette('--article-border')};
+ content: '';
+ grid-column: centre-column-start;
+ transform: translateX(-${columnGap});
+
+ ${fromBreakpoint.leftCol} {
+ transform: translateX(calc(-${columnGap} / 2));
+ }
+ }
}`;
-const verticalRules = (options: VerticalRuleOptions = {}): string => `
+const verticalRules = (options: VerticalRuleOptions = {}): string => {
+ const { plusChild: centreChild } = options;
+
+ return `
${fromBreakpoint.tablet} {
position: relative;
&::before,
- &::after
- ${options.centre ? ', & > *:first-child::before' : ''} {
+ &::after {
position: absolute;
top: 0;
bottom: 0;
@@ -145,8 +165,9 @@ const verticalRules = (options: VerticalRuleOptions = {}): string => `
}
}
- ${options.centre ? optionalCentreRule : ''}
-`;
+ ${centreChild !== undefined ? optionalCentreRule(centreChild) : ''}
+ }`;
+};
// ----- API ----- //
@@ -251,6 +272,10 @@ const grid = {
verticalRules,
} as const;
+// ----- Types ----- //
+type ColumnPreset = keyof typeof grid.column;
+
// ----- Exports ----- //
+export type { Line, ColumnPreset };
export { grid };
diff --git a/dotcom-rendering/src/layouts/DecideLayout.tsx b/dotcom-rendering/src/layouts/DecideLayout.tsx
index aae1cbfca5c..d26077f8348 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';
@@ -58,7 +57,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 65ebfbc5994..82a459582f8 100644
--- a/dotcom-rendering/src/layouts/StandardLayout.tsx
+++ b/dotcom-rendering/src/layouts/StandardLayout.tsx
@@ -1,4 +1,4 @@
-import { css } from '@emotion/react';
+import { css, type SerializedStyles } from '@emotion/react';
import { log } from '@guardian/libs';
import {
from,
@@ -19,7 +19,6 @@ import { ArticleHeadline } from '../components/ArticleHeadline';
import { ArticleMetaApps } from '../components/ArticleMeta.apps';
import { ArticleMeta } from '../components/ArticleMeta.web';
import { ArticleTitle } from '../components/ArticleTitle';
-import { Border } from '../components/Border';
import { Carousel } from '../components/Carousel.island';
import { DecideLines } from '../components/DecideLines';
import { DirectoryPageNav } from '../components/DirectoryPageNav';
@@ -27,7 +26,6 @@ import { DiscussionLayout } from '../components/DiscussionLayout';
import { FootballMatchHeaderWrapper } from '../components/FootballMatchHeaderWrapper.island';
import { FootballMatchInfoWrapper } from '../components/FootballMatchInfoWrapper.island';
import { Footer } from '../components/Footer';
-import { GridItem } from '../components/GridItem';
import { GuardianLabsLines } from '../components/GuardianLabsLines';
import { HeaderAdSlot } from '../components/HeaderAdSlot';
import { Island } from '../components/Island';
@@ -39,15 +37,16 @@ import { MostViewedFooterData } from '../components/MostViewedFooterData.island'
import { MostViewedFooterLayout } from '../components/MostViewedFooterLayout';
import { MostViewedRightWithAd } from '../components/MostViewedRightWithAd.island';
import { OnwardsUpper } from '../components/OnwardsUpper.island';
-import { RightColumn } from '../components/RightColumn';
import { Section } from '../components/Section';
import { SlotBodyEnd } from '../components/SlotBodyEnd.island';
import { Standfirst } from '../components/Standfirst';
import { StickyBottomBanner } from '../components/StickyBottomBanner.island';
import { SubMeta } from '../components/SubMeta';
import { SubNav } from '../components/SubNav.island';
+import { grid } from '../grid';
import {
ArticleDesign,
+ ArticleDisplay,
type ArticleFormat,
ArticleSpecial,
} from '../lib/articleFormat';
@@ -61,247 +60,13 @@ import type { NavType } from '../model/extract-nav';
import { palette as themePalette } from '../palette';
import type { ArticleDeprecated } from '../types/article';
import type { RenderingTarget } from '../types/renderingTarget';
+import {
+ type Area,
+ gridItemCss,
+ type LayoutType,
+} from './lib/furnitureArrangements';
import { BannerWrapper, Stuck } from './lib/stickiness';
-const StandardGrid = ({
- children,
- isMatchReport,
- isMedia,
-}: {
- children: React.ReactNode;
- isMatchReport: boolean;
- isMedia: boolean;
-}) => (
-
- {children}
-
-);
-
-const maxWidth = css`
- ${from.desktop} {
- max-width: 620px;
- }
-`;
-
const stretchLines = css`
${until.phablet} {
margin-left: -20px;
@@ -313,6 +78,29 @@ const stretchLines = css`
}
`;
+interface GridItemProps {
+ area: Area;
+ layoutType: LayoutType;
+ element?: 'div' | 'aside';
+ customCss?: SerializedStyles;
+ children: React.ReactNode;
+}
+
+const GridItem = ({
+ area,
+ layoutType,
+ element: Element = 'div',
+ customCss,
+ children,
+}: GridItemProps) => (
+
+ {children}
+
+);
+
interface Props {
article: ArticleDeprecated;
format: ArticleFormat;
@@ -385,6 +173,37 @@ export const StandardLayout = (props: WebProps | AppProps) => {
const renderAds = canRenderAds(article);
+ const isImmersive = format.display === ArticleDisplay.Immersive;
+
+ const firstMainMediaElement = article.mainMediaElements[0];
+ const mainMediaUrl: string | undefined =
+ firstMainMediaElement?._type ===
+ 'model.dotcomrendering.pageElements.ImageBlockElement'
+ ? firstMainMediaElement.media.allImages[0]?.url
+ : undefined;
+
+ const orientation = (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 mainMediaOrientation =
+ mainMediaUrl != null ? orientation(mainMediaUrl) : 'landscape';
+
+ const layoutType: LayoutType =
+ isImmersive && mainMediaOrientation === 'landscape'
+ ? 'immersiveLandscape'
+ : isImmersive && mainMediaOrientation === 'portrait'
+ ? 'immersivePortrait'
+ : isVideo
+ ? 'media'
+ : 'standard';
+
return (
<>
{isWeb && (
@@ -459,163 +278,174 @@ export const StandardLayout = (props: WebProps | AppProps) => {
pageId={article.pageId}
pageTags={article.tags}
/>
-
-
-
-
-
-
-
-
-
-
-
-
- {format.theme === ArticleSpecial.Labs ? (
- <>>
- ) : (
-
- )}
-
-
-
-
-
-
-
-
-
-
- {isWeb &&
- format.theme === ArticleSpecial.Labs &&
- format.design !== ArticleDesign.Video ? (
-
- ) : (
-
- )}
-
+
+
+
+
+
+
+
+
+
+
+
+
+ {layoutType !== 'immersivePortrait' && (
+
+ {isWeb &&
+ format.theme === ArticleSpecial.Labs &&
+ format.design !== ArticleDesign.Video ? (
+
+ ) : (
+
+ )}
- {isApps ? (
- <>
-
-
-
-
-
- {!!article.affiliateLinksDisclaimer && (
-
- )}
-
- >
- ) : (
-
+ )}
+ {isApps ? (
+ <>
+
+
+
+
{
{!!article.affiliateLinksDisclaimer && (
)}
-
- )}
-
-
- {/* Only show Listen to Article button on App landscape views */}
- {isApps && (
-
- {!isVideo && (
-
-
-
-
-
- )}
- )}
-
-
-
-
- {isApps && (
-
+ ) : (
+
+ )}
+
+
+ {/* Only show Listen to Article button on App landscape views */}
+ {isApps && (
+
+ {!isVideo && (
+
)}
+
+ )}
+
+
+
- {showBodyEndSlot && (
-
-
-
- )}
-
-
+
+
+ )}
+
+ {showBodyEndSlot && (
+
+
+
+ )}
+
+
+
+
+
+
+
+
-
-
-
-
-
-
-
-
-
-
-
-
-
+
+
+
+
{isWeb && renderAds && !isLabs && (
= {
+ mobile: until.tablet,
+ tablet: from.tablet,
+ desktop: from.desktop,
+ leftCol: from.leftCol,
+ wide: from.wide,
+};
+
+// Raw CSS overrides per area per breakpoint. Entries are only needed when an area
+// deviates from the default: centre column, single-column mobile layout with areas
+// in DOM order (main-media → title → headline → standfirst → meta → body → right-column).
+
+type AreaCss = Partial>;
+type LayoutCssMap = Partial>;
+
+const standardCss: LayoutCssMap = {
+ title: {
+ tablet: 'grid-row: 1;',
+ leftCol:
+ 'grid-row: 1; grid-column: left-column-start / left-column-end;',
+ },
+ headline: {
+ tablet: 'grid-row: 2;',
+ leftCol: 'grid-row: 1;',
+ },
+ standfirst: {
+ tablet: 'grid-row: 3;',
+ leftCol: 'grid-row: 2;',
+ },
+ 'main-media': {
+ tablet: 'grid-row: 4;',
+ leftCol: 'grid-row: 3;',
+ },
+ meta: {
+ tablet: 'grid-row: 5;',
+ leftCol:
+ 'grid-row: 3 / span 2; grid-column: left-column-start / left-column-end;',
+ },
+ body: {
+ tablet: 'grid-row: 6;',
+ leftCol: 'grid-row: 4;',
+ },
+ 'right-column': {
+ desktop:
+ 'grid-row: 1 / span 6; grid-column: right-column-start / right-column-end;',
+ leftCol:
+ 'grid-row: 1 / span 4; grid-column: right-column-start / right-column-end;',
+ },
+};
+
+const mediaCss: LayoutCssMap = {
+ title: {
+ mobile: 'grid-row: 1;',
+ leftCol:
+ 'grid-row: 1; grid-column: left-column-start / left-column-end;',
+ },
+ headline: {
+ mobile: 'grid-row: 2;',
+ leftCol: 'grid-row: 1;',
+ },
+ 'main-media': {
+ mobile: 'grid-row: 3;',
+ desktop:
+ 'grid-row: 3; grid-column: centre-column-start / right-column-start;',
+ leftCol:
+ 'grid-row: 2; grid-column: centre-column-start / right-column-start;',
+ },
+ standfirst: {
+ mobile: 'grid-row: 4;',
+ desktop:
+ 'grid-row: 4; grid-column: centre-column-start / right-column-start;',
+ leftCol:
+ 'grid-row: 3; grid-column: centre-column-start / right-column-start;',
+ },
+ meta: {
+ mobile: 'grid-row: 5;',
+ leftCol:
+ 'grid-row: 2 / span 3; grid-column: left-column-start / left-column-end;',
+ },
+ body: {
+ desktop:
+ 'grid-row: 6; grid-column: centre-column-start / right-column-start;',
+ leftCol:
+ 'grid-row: 4; grid-column: centre-column-start / right-column-start;',
+ },
+ 'right-column': {
+ desktop:
+ 'grid-row: 3 / span 4; grid-column: right-column-start / right-column-end;',
+ leftCol:
+ 'grid-row: 2 / span 3; grid-column: right-column-start / right-column-end;',
+ },
+};
+
+const 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;',
+ },
+ 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;',
+ },
+ 'main-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;',
+ },
+ 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;',
+ },
+ 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 immersiveLandscapeCss: 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;',
+ },
+ 'main-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;',
+ },
+ standfirst: {
+ mobile: 'grid-row: 4;',
+ tablet: 'grid-row: 4;',
+ },
+ meta: {
+ mobile: 'grid-row: 5;',
+ tablet: 'grid-row: 5;',
+ desktop: 'grid-row: 5; grid-column: centre-column-start / 8;',
+ leftCol:
+ 'grid-row: 4 / span 2; 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 = {
+ standard: standardCss,
+ media: mediaCss,
+ immersiveLandscape: immersiveLandscapeCss,
+ immersivePortrait: immersivePortraitCss,
+};
+
+/**
+ * Returns the Emotion CSS needed to position a single grid item — its
+ * default column, its row at each breakpoint, and any column overrides.
+ * The grid item _must_ be inside a {@link grid} module container.
+ *
+ * All items default to the centre column. Per-breakpoint overrides for
+ * `grid-row` and `grid-column` are applied on top via media queries,
+ * looked up from the plain CSS maps defined in this file.
+ *
+ * @param area - The named piece of article furniture to position (e.g. `'headline'`, `'body'`).
+ * @param layoutType - See {@link LayoutType}. Determines which CSS map to use for lookups.
+ *
+ * @example
+ * // In a React component:
+ *
+ */
+export const gridItemCss = (
+ area: Area,
+ layoutType: LayoutType,
+): SerializedStyles => {
+ const areaOverrides = layoutCssMaps[layoutType][area] ?? {};
+
+ const breakpointCss = Object.entries(areaOverrides).map(
+ ([bp, styles]) => css`
+ ${breakpointQueries[bp as Breakpoint]} {
+ ${styles}
+ }
+ `,
+ );
+
+ // All items default to the centre column; breakpoint entries above
+ // override grid-row and grid-column as needed.
+ return css`
+ grid-column: centre-column-start / centre-column-end;
+ ${breakpointCss}
+ `;
+};
diff --git a/dotcom-rendering/src/paletteDeclarations.ts b/dotcom-rendering/src/paletteDeclarations.ts
index a3199607e75..d5e5a94dcd1 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:
@@ -193,19 +193,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: