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: