diff --git a/dotcom-rendering/src/components/ArticleHeadline.tsx b/dotcom-rendering/src/components/ArticleHeadline.tsx
index 3b0c141fc86..f7951d9f145 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';
@@ -214,30 +213,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 +244,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 +252,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}
@@ -440,6 +399,9 @@ export const ArticleHeadline = ({
isMatch,
starRating,
}: Props) => {
+ const isInverted =
+ format.display === ArticleDisplay.Immersive &&
+ format.design === ArticleDesign.Standard;
switch (format.display) {
case ArticleDisplay.Immersive: {
switch (format.design) {
@@ -465,12 +427,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 +459,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/Figure.tsx b/dotcom-rendering/src/components/Figure.tsx
index ccb113fb4b1..83a444ac608 100644
--- a/dotcom-rendering/src/components/Figure.tsx
+++ b/dotcom-rendering/src/components/Figure.tsx
@@ -1,6 +1,15 @@
import { css } from '@emotion/react';
-import { breakpoints, from, space, until } from '@guardian/source/foundations';
+import {
+ breakpoints,
+ from,
+ palette as sourcePalette,
+ space,
+ until,
+} from '@guardian/source/foundations';
+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';
type Props = {
@@ -12,8 +21,35 @@ type Props = {
className?: string;
type?: FEElement['_type'];
isTimeline?: boolean;
+ articleArrangement?: LayoutType;
};
+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;
@@ -263,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
@@ -273,6 +310,17 @@ export const Figure = ({
return (
{children}
+ {articleArrangement === 'immersiveLandscapeFeature' && (
+
+ )}
);
}
diff --git a/dotcom-rendering/src/components/MainMedia.tsx b/dotcom-rendering/src/components/MainMedia.tsx
index e912f480019..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,
@@ -12,6 +13,7 @@ import type { ServerSideTests, Switches } from '../types/config';
import type { FEElement } from '../types/content';
const mainMedia = css`
+ position: relative;
height: 100%;
${until.tablet} {
@@ -96,6 +98,7 @@ type Props = {
shouldHideAds: boolean;
contentType?: string;
contentLayout?: string;
+ articleArrangement?: LayoutType;
};
export const MainMedia = ({
@@ -114,6 +117,7 @@ export const MainMedia = ({
shouldHideAds,
contentType,
contentLayout,
+ articleArrangement,
}: Props) => {
return (
@@ -137,6 +141,7 @@ export const MainMedia = ({
shouldHideAds={shouldHideAds}
contentType={contentType}
contentLayout={contentLayout}
+ articleArrangement={articleArrangement}
/>
))}
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/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;
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 8d24f6d3b8f..48580b4d356 100644
--- a/dotcom-rendering/src/layouts/StandardLayout.tsx
+++ b/dotcom-rendering/src/layouts/StandardLayout.tsx
@@ -46,6 +46,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,
@@ -65,6 +66,7 @@ import type { ArticleDeprecated } from '../types/article';
import type { RenderingTarget } from '../types/renderingTarget';
import {
type Area,
+ getLayoutType,
gridItemCss,
type LayoutType,
} from './lib/articleArrangements';
@@ -121,6 +123,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 {
@@ -164,11 +183,12 @@ export const StandardLayout = (props: WebProps | AppProps) => {
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];
@@ -181,11 +201,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 (
<>
@@ -254,7 +299,7 @@ export const StandardLayout = (props: WebProps | AppProps) => {
)}
-
+
{isApps && renderAds && (
@@ -277,12 +322,43 @@ export const StandardLayout = (props: WebProps | AppProps) => {
!isLabs &&
css`
${from.leftCol} {
- ${grid.centreRule(3)}
+ ${grid.centreRule(
+ isImmersivePortrait
+ ? 4
+ : isImmersiveLandscape
+ ? 3
+ : 1,
+ )}
+ }
+ `,
+ isImmersivePortrait &&
+ css`
+ grid-template-rows: 0.25fr 1fr auto;
+ `,
+ isImmersiveLandscape &&
+ css`
+ ${from.desktop} {
+ grid-template-rows: auto auto ${ageWarning
+ ? '130px'
+ : '90px'};
}
`,
]}
>
-
+
{
hideCaption={isMedia}
shouldHideAds={article.shouldHideAds}
contentType={article.contentType}
- contentLayout={`${
- ArticleDisplay[format.display]
- }Layout`}
+ contentLayout={contentLayoutName}
+ articleArrangement={layoutType}
/>
{
isMatch={!!footballMatchUrl}
/>
-
+
{
area="meta"
layoutType={layoutType}
element="aside"
+ css={
+ layoutType === 'immersivePortraitDefault'
+ ? css`
+ ${from.leftCol} {
+ margin-right: -10px;
+ }
+ `
+ : undefined
+ }
>
-
- {isWeb &&
- format.theme === ArticleSpecial.Labs &&
- format.design !== ArticleDesign.Video ? (
-
- ) : (
-
- )}
-
+ {layoutType !== 'immersivePortraitDefault' && (
+
+ {isWeb &&
+ format.theme === ArticleSpecial.Labs &&
+ format.design !== ArticleDesign.Video ? (
+
+ ) : (
+
+ )}
+
+ )}
{isApps ? (
<>
@@ -430,6 +542,9 @@ export const StandardLayout = (props: WebProps | AppProps) => {
secondaryDateline={
article.webPublicationSecondaryDateDisplay
}
+ webPublicationDate={
+ article.webPublicationDate
+ }
isCommentable={article.isCommentable}
discussionApiUrl={
article.config.discussionApiUrl
@@ -438,9 +553,6 @@ export const StandardLayout = (props: WebProps | AppProps) => {
mainMediaElements={
article.mainMediaElements
}
- webPublicationDate={
- article.webPublicationDate
- }
/>
{!!article.affiliateLinksDisclaimer && (
@@ -622,6 +734,7 @@ export const StandardLayout = (props: WebProps | AppProps) => {
+
{isWeb && renderAds && !isLabs && (
{
/>
- {renderAds && (
-
- )}
+
>
)}
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';
+};
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}
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: