diff --git a/dotcom-rendering/playwright/tests/commercial.e2e.spec.ts b/dotcom-rendering/playwright/tests/commercial.e2e.spec.ts index 61c96351056..db823202659 100644 --- a/dotcom-rendering/playwright/tests/commercial.e2e.spec.ts +++ b/dotcom-rendering/playwright/tests/commercial.e2e.spec.ts @@ -19,7 +19,7 @@ test.describe('Commercial E2E tests', () => { 'merchandising', ]; - const totalSlotsExpected = 15; // All slots, even if unfilled ie. `display: none` + const totalSlotsExpected = 16; // All slots, even if unfilled ie. `display: none` const inlineSlots = totalSlotsExpected - fixedSlots.length; // We are excluding survey slot as they can be switched off diff --git a/dotcom-rendering/playwright/tests/lightbox.e2e.spec.ts b/dotcom-rendering/playwright/tests/lightbox.e2e.spec.ts index 5b902439b82..f5e3becad75 100644 --- a/dotcom-rendering/playwright/tests/lightbox.e2e.spec.ts +++ b/dotcom-rendering/playwright/tests/lightbox.e2e.spec.ts @@ -109,7 +109,7 @@ test.describe('Lightbox', () => { // Open lightbox by clicking on the fourth image within the article body // Lightbox should open at the fifth image as the first image is the main media image - await page.locator('article img').nth(3).click({ force: true }); + await page.locator('button.open-lightbox').nth(4).click(); await expectToBeVisible(page, '#gu-lightbox'); diff --git a/dotcom-rendering/src/components/ArticleHeadline.tsx b/dotcom-rendering/src/components/ArticleHeadline.tsx index 3b0c141fc86..0d32869f847 100644 --- a/dotcom-rendering/src/components/ArticleHeadline.tsx +++ b/dotcom-rendering/src/components/ArticleHeadline.tsx @@ -18,6 +18,7 @@ import { until, } from '@guardian/source/foundations'; import { grid } from '../../src/grid'; +import { type LayoutType } from '../layouts/lib/articleArrangements'; import { interactiveLegacyClasses } from '../layouts/lib/interactiveLegacyStyling'; import { getAgeWarning } from '../lib/age-warning'; import { @@ -27,7 +28,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'; @@ -39,6 +39,7 @@ import { StarRating } from './StarRating/StarRating'; type Props = { headlineString: string; format: ArticleFormat; + layoutType?: LayoutType; byline?: string; tags: TagType[]; webPublicationDateDeprecated: string; @@ -214,30 +215,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 +246,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 +254,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} @@ -433,6 +394,7 @@ const galleryStyles = css` export const ArticleHeadline = ({ headlineString, format, + layoutType, tags, byline, webPublicationDateDeprecated, @@ -440,6 +402,7 @@ export const ArticleHeadline = ({ isMatch, starRating, }: Props) => { + const isInverted = layoutType === 'immersiveLandscapeDefault'; switch (format.display) { case ArticleDisplay.Immersive: { switch (format.design) { @@ -465,12 +428,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 +460,17 @@ export const ArticleHeadline = ({ webPublicationDateDeprecated } format={format} + snapToInverted={true} >

diff --git a/dotcom-rendering/src/components/ArticleMeta.web.tsx b/dotcom-rendering/src/components/ArticleMeta.web.tsx index 5abd00eede4..71013659d5a 100644 --- a/dotcom-rendering/src/components/ArticleMeta.web.tsx +++ b/dotcom-rendering/src/components/ArticleMeta.web.tsx @@ -1,9 +1,11 @@ import { css } from '@emotion/react'; import { isUndefined } from '@guardian/libs'; import { between, from, space, until } from '@guardian/source/foundations'; +import { Hide } from '@guardian/source/react-components'; import { StraightLines } from '@guardian/source-development-kitchen/react-components'; import type { CSSProperties } from 'react'; import type { FEArticle } from '../frontend/feArticle'; +import type { LayoutType } from '../layouts/lib/articleArrangements'; import { interactiveLegacyClasses } from '../layouts/lib/interactiveLegacyStyling'; import { ArticleDesign, @@ -20,6 +22,7 @@ import { } from '../lib/articleMeta'; import { getAudioData } from '../lib/audio-data'; import { getSoleContributor } from '../lib/byline'; +import { decideMainMediaCaption } from '../lib/decide-caption'; import { palette as themePalette } from '../palette'; import { hasPreferredSourceButton } from '../preferredSource'; import type { Branding as BrandingType } from '../types/branding'; @@ -27,6 +30,7 @@ import type { FEElement } from '../types/content'; import type { TagType } from '../types/tag'; import { Avatar } from './Avatar'; import { Branding } from './Branding.island'; +import { Caption } from './Caption'; import { CommentCount } from './CommentCount.island'; import { useConfig } from './ConfigContext'; import { Contributor } from './Contributor'; @@ -40,6 +44,7 @@ import { TimeDateline } from './TimeDateline'; type Props = { format: ArticleFormat; + layoutType?: LayoutType; pageId: string; webTitle: string; byline?: string; @@ -106,11 +111,7 @@ const metaFlex = css` `; const preferredSourceMetaFlex = (hasButton: boolean): CSSProperties => - hasButton - ? { - marginBottom: 8, - } - : {}; + hasButton ? { marginBottom: 8 } : {}; const stretchLines = css` display: block; @@ -149,11 +150,7 @@ const metaExtras = (isPictureContent: boolean) => css` `; const preferredSourceMetaExtras = (hasButton: boolean): CSSProperties => - hasButton - ? { - paddingTop: 8, - } - : {}; + hasButton ? { paddingTop: 8 } : {}; const metaNumbers = (isPictureContent: boolean) => css` border-top: 1px solid ${themePalette('--article-border')}; @@ -246,6 +243,25 @@ const MetaAvatarContainer = ({ children }: { children: React.ReactNode }) => (

); +const ImmersiveMetaAvatarContainer = ({ + children, +}: { + children: React.ReactNode; +}) => ( +
+ {children} +
+); + const RowBelowLeftCol = ({ children }: { children: React.ReactNode }) => (
- <> + {isImmersive && isPortraitOrLandscapeImmersive ? ( +
{!!avatarUrl && ( - + - + )}
{isAudio && podcast && seriesTag && ( @@ -400,9 +430,66 @@ export const ArticleMeta = ({ /> )}
- - +
+ ) : ( + + <> + {!!avatarUrl && ( + + + + )} +
+ {isAudio && podcast && seriesTag && ( + + )} + + {shouldShowContributor(format) && ( + + )} + + {crossword?.creator && ( + + )} + {!isUndefined(webPublicationDate) && + isFilterArticle ? ( + + ) : ( + + )} +
+ +
+ )}
{showPreferredSource ? : null} + {format.display === ArticleDisplay.Immersive && + mainMediaElements?.[0] && ( + +
+ +
+
+ )}
); diff --git a/dotcom-rendering/src/components/ArticleTitle.tsx b/dotcom-rendering/src/components/ArticleTitle.tsx index de44e1eb319..e78a65319ea 100644 --- a/dotcom-rendering/src/components/ArticleTitle.tsx +++ b/dotcom-rendering/src/components/ArticleTitle.tsx @@ -1,6 +1,7 @@ import { css } from '@emotion/react'; import { from } from '@guardian/source/foundations'; import { grid } from '../../src/grid'; +import type { LayoutType } from '../layouts/lib/articleArrangements'; import { ArticleDesign, ArticleDisplay, @@ -11,8 +12,9 @@ import { SeriesSectionLink } from './SeriesSectionLink'; type Props = { format: ArticleFormat; - tags: TagType[]; + layoutType?: LayoutType; sectionLabel: string; + tags: TagType[]; sectionUrl: string; guardianBaseURL: string; isMatch?: boolean; @@ -28,17 +30,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; } `; @@ -57,6 +53,7 @@ const galleryStyles = css` export const ArticleTitle = ({ format, + layoutType, tags, sectionLabel, sectionUrl, @@ -80,6 +77,7 @@ export const ArticleTitle = ({ > { +const CameraIcon = ({ isMainMedia }: IconProps) => { return ( - + ); }; -const VideoIcon = ({ format, isMainMedia }: IconProps) => { +const VideoIcon = ({ isMainMedia }: IconProps) => { return ( - + ); @@ -325,9 +305,9 @@ export const Caption = ({ data-spacefinder-role="inline" > {mediaType === 'YoutubeVideo' || mediaType === 'SelfHostedVideo' ? ( - + ) : ( - + )} {!!captionText && ( { - const count = format.design === ArticleDesign.Comment ? 8 : 4; + const count = + format.design === ArticleDesign.Comment + ? 8 + : format.display === ArticleDisplay.Immersive + ? 1 + : 4; switch (format.theme) { case Pillar.Sport: 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/ImageBlockComponent.tsx b/dotcom-rendering/src/components/ImageBlockComponent.tsx index 2171767212a..fab73cf3ccd 100644 --- a/dotcom-rendering/src/components/ImageBlockComponent.tsx +++ b/dotcom-rendering/src/components/ImageBlockComponent.tsx @@ -1,3 +1,4 @@ +import type { LayoutType } from '../layouts/lib/articleArrangements'; import type { ArticleFormat } from '../lib/articleFormat'; import type { ImageBlockElement } from '../types/content'; import { ImageComponent } from './ImageComponent'; @@ -10,6 +11,7 @@ type Props = { isMainMedia?: boolean; isAvatar?: boolean; isTimeline?: boolean; + articleArrangement?: LayoutType; }; export const ImageBlockComponent = ({ @@ -20,6 +22,7 @@ export const ImageBlockComponent = ({ isMainMedia, isAvatar, isTimeline = false, + articleArrangement, }: Props) => { const { role } = element; return ( @@ -32,6 +35,7 @@ export const ImageBlockComponent = ({ title={title} isAvatar={isAvatar} isTimeline={isTimeline} + articleArrangement={articleArrangement} /> ); }; diff --git a/dotcom-rendering/src/components/ImageComponent.tsx b/dotcom-rendering/src/components/ImageComponent.tsx index acf1d4678ab..d7ad02686a9 100644 --- a/dotcom-rendering/src/components/ImageComponent.tsx +++ b/dotcom-rendering/src/components/ImageComponent.tsx @@ -9,6 +9,7 @@ import { palette as srcPalette, until, } from '@guardian/source/foundations'; +import type { LayoutType } from '../layouts/lib/articleArrangements'; import { ArticleDesign, ArticleDisplay, @@ -34,6 +35,7 @@ type Props = { title?: string; isAvatar?: boolean; isTimeline?: boolean; + articleArrangement?: LayoutType; }; const timelineBulletStyles = css` @@ -244,6 +246,7 @@ export const ImageComponent = ({ title, isAvatar, isTimeline = false, + articleArrangement, }: Props) => { const { renderingTarget } = useConfig(); // Its possible the tools wont send us any images urls @@ -270,6 +273,8 @@ export const ImageComponent = ({ } const isWeb = renderingTarget === 'Web'; + const isGridImmersive = + articleArrangement?.startsWith('immersive') ?? false; /** * We use height and width for two things. @@ -303,10 +308,10 @@ export const ImageComponent = ({ always be used if display === 'immersive' */ height: 100%; width: 100%; - min-height: 25rem; + min-height: ${isGridImmersive ? 0 : '25rem'}; ${from.desktop} { - min-height: 31.25rem; + min-height: ${isGridImmersive ? 0 : '31.25rem'}; } img { diff --git a/dotcom-rendering/src/components/MainMedia.tsx b/dotcom-rendering/src/components/MainMedia.tsx index 871c4606715..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, @@ -12,6 +13,7 @@ import type { Switches } from '../types/config'; import type { FEElement } from '../types/content'; const mainMedia = css` + position: relative; height: 100%; ${until.tablet} { @@ -95,6 +97,7 @@ type Props = { shouldHideAds: boolean; contentType?: string; contentLayout?: string; + articleArrangement?: LayoutType; }; export const MainMedia = ({ @@ -112,6 +115,7 @@ export const MainMedia = ({ shouldHideAds, contentType, contentLayout, + articleArrangement, }: Props) => { return (
@@ -134,6 +138,7 @@ export const MainMedia = ({ shouldHideAds={shouldHideAds} contentType={contentType} contentLayout={contentLayout} + articleArrangement={articleArrangement} /> ))}
diff --git a/dotcom-rendering/src/components/SeriesSectionLink.tsx b/dotcom-rendering/src/components/SeriesSectionLink.tsx index 803a78ba186..c10fe3cdfd5 100644 --- a/dotcom-rendering/src/components/SeriesSectionLink.tsx +++ b/dotcom-rendering/src/components/SeriesSectionLink.tsx @@ -11,6 +11,7 @@ import { textSansBold20, until, } from '@guardian/source/foundations'; +import type { LayoutType } from '../layouts/lib/articleArrangements'; import { interactiveLegacyClasses } from '../layouts/lib/interactiveLegacyStyling'; import { ArticleDesign, @@ -26,6 +27,7 @@ import { PulsingDot } from './PulsingDot.island'; type Props = { format: ArticleFormat; + layoutType?: LayoutType; tags: TagType[]; sectionLabel: string; sectionUrl: string; @@ -190,6 +192,7 @@ const sectionPadding = css` export const SeriesSectionLink = ({ format, + layoutType, tags, sectionLabel, sectionUrl, @@ -376,7 +379,38 @@ export const SeriesSectionLink = ({ ); } - // Immersives show nothing at all if there's no series tag + if ( + layoutType === 'immersivePortraitDefault' || + layoutType === 'immersivePortraitFeature' || + layoutType === 'immersiveLandscapeDefault' || + layoutType === 'immersiveLandscapeFeature' + ) { + return ( + <> + + {sectionLabel} + + + ); + } + // Other types of immersives show nothing at all if there's no series tag return null; } if (tag) { diff --git a/dotcom-rendering/src/components/Standfirst.tsx b/dotcom-rendering/src/components/Standfirst.tsx index 0567d1dbc99..c517894fcaa 100644 --- a/dotcom-rendering/src/components/Standfirst.tsx +++ b/dotcom-rendering/src/components/Standfirst.tsx @@ -213,7 +213,6 @@ const decidePadding = ({ display, design }: ArticleFormat) => { case ArticleDisplay.Immersive: return css` padding-bottom: 0; - padding-top: ${space[2]}px; ${from.tablet} { padding-bottom: 0; @@ -238,7 +237,6 @@ const decidePadding = ({ display, design }: ArticleFormat) => { case ArticleDisplay.Immersive: return css` padding-bottom: 0; - padding-top: ${space[2]}px; ${from.tablet} { padding-bottom: 0; @@ -268,7 +266,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 77803138313..3d828bdc45b 100644 --- a/dotcom-rendering/src/layouts/DecideLayout.tsx +++ b/dotcom-rendering/src/layouts/DecideLayout.tsx @@ -9,7 +9,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'; @@ -55,7 +54,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 8a855b830a5..b3536cefd1f 100644 --- a/dotcom-rendering/src/layouts/StandardLayout.tsx +++ b/dotcom-rendering/src/layouts/StandardLayout.tsx @@ -62,6 +62,8 @@ export const StandardLayout = (props: WebProps | AppProps) => { const isWeb = renderingTarget === 'Web'; const isApps = renderingTarget === 'Apps'; + const contentLayoutName = `${ArticleDisplay[format.display]}Layout`; + // 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. @@ -116,7 +118,9 @@ export const StandardLayout = (props: WebProps | AppProps) => { idApiUrl={article.config.idApiUrl} contributionsServiceUrl={contributionsServiceUrl} showSubNav={!isLabs && !isWorldCup2026} - showSlimNav={false} + showSlimNav={ + format.display === ArticleDisplay.Immersive + } hasPageSkinContentSelfConstrain={true} pageId={article.pageId} tagIds={article.tags.map((tag) => tag.id)} @@ -157,7 +161,7 @@ export const StandardLayout = (props: WebProps | AppProps) => { )} -
+
{isApps && renderAds && ( @@ -172,6 +176,7 @@ export const StandardLayout = (props: WebProps | AppProps) => { renderingTarget={renderingTarget} /> + {isWeb && renderAds && !isLabs && (
{ + 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 +132,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 footballMatchUrl = article.matchType === 'FootballMatchType' @@ -119,11 +143,42 @@ export const StandardLayoutArticleGrid = ({ const isFootballMatchReport = format.design === ArticleDesign.MatchReport && !!footballMatchUrl; - const layoutType: LayoutType = isMedia - ? 'media' - : isShowcase - ? 'showcase' - : 'standard'; + const mainMedia = article.mainMediaElements[0]; + const captionText = decideMainMediaCaption(mainMedia); + const mainMediaUrl: string | undefined = + mainMedia?._type === + 'model.dotcomrendering.pageElements.ImageBlockElement' + ? mainMedia.media.allImages[0]?.url + : undefined; + const mainMediaAspectRatio = + mainMedia?._type === + 'model.dotcomrendering.pageElements.ImageBlockElement' + ? mainMedia.media.allImages[0]?.fields.aspectRatio + : undefined; + + const mainMediaOrientation = + mainMediaUrl != null ? getImageOrientation(mainMediaUrl) : 'landscape'; + + const layoutType = getLayoutType({ + isImmersive, + isFeature, + orientation: mainMediaOrientation, + isMedia, + 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 (
- + - + - + - + - - {format.design !== ArticleDesign.Audio && ( -
- {isWeb && - format.theme === ArticleSpecial.Labs && - format.design !== ArticleDesign.Video ? ( - - ) : ( - - )} -
- )} + {isImmersive && ( + + + + + + )} + + {format.display !== ArticleDisplay.Immersive && + format.design !== ArticleDesign.Audio && + layoutType !== 'immersivePortraitDefault' && ( +
+ {isWeb && + format.theme === ArticleSpecial.Labs && + format.design !== ArticleDesign.Video ? ( + + ) : ( + + )} +
+ )} {isApps ? ( <> @@ -224,6 +405,7 @@ export const StandardLayoutArticleGrid = ({ {!!article.affiliateLinksDisclaimer && ( @@ -275,7 +458,13 @@ export const StandardLayoutArticleGrid = ({ )}
- + {/* Only show Listen to Article button on App landscape views */} {isApps && ( diff --git a/dotcom-rendering/src/layouts/lib/articleArrangements.ts b/dotcom-rendering/src/layouts/lib/articleArrangements.ts index 2cf5fb6843f..e7fee10daec 100644 --- a/dotcom-rendering/src/layouts/lib/articleArrangements.ts +++ b/dotcom-rendering/src/layouts/lib/articleArrangements.ts @@ -2,24 +2,33 @@ 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' | 'headline' | 'standfirst' + | 'caption' | 'media' | 'meta' | '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 +158,182 @@ 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)};`, + }, + caption: { + mobile: 'grid-row: 5;', + tablet: 'grid-row: 5;', + desktop: `grid-row: 5;`, + }, + meta: { + mobile: 'grid-row: 6;', + tablet: 'grid-row: 6;', + desktop: `grid-row: 4; ${grid.between('centre-column-start', 8)};`, + leftCol: `grid-row: 3 / span 2; ${grid.column.left};`, + }, + body: { + mobile: 'grid-row: 7;', + leftCol: 'grid-row: 4;', + }, + 'right-column': { + desktop: `grid-row: 5 / span 2; ${grid.column.right};`, + leftCol: `grid-row: 4; ${grid.column.right};`, + }, +}; + +const immersivePortraitFeatureCss: LayoutCssMap = { + title: { + mobile: 'grid-row: 2;', + tablet: 'grid-row: 2;', + desktop: `grid-row: 1; ${grid.between('centre-column-start', 8)};`, + leftCol: `grid-row: 1; ${grid.between('left-column-start', 9)};`, + }, + headline: { + mobile: 'grid-row: 3;', + tablet: 'grid-row: 3;', + 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: 1;', + tablet: 'grid-row: 1;', + 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)};`, + }, + caption: { + mobile: 'grid-row: 5;', + tablet: 'grid-row: 5;', + desktop: `grid-row: 5;`, + }, + meta: { + mobile: 'grid-row: 6;', + tablet: 'grid-row: 6;', + desktop: `grid-row: 4; ${grid.between('centre-column-start', 8)};`, + leftCol: `grid-row: 3 / span 2; ${grid.column.left};`, + }, + body: { + mobile: 'grid-row: 7;', + leftCol: 'grid-row: 4;', + }, + 'right-column': { + desktop: `grid-row: 5 / span 2; ${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;', + wide: `${grid.between('centre-column-start', 14)};`, + }, + 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;', + }, + caption: { + desktop: 'grid-row: 6;', + }, + meta: { + mobile: 'grid-row: 5;', + tablet: 'grid-row: 5;', + desktop: `grid-row: 7;`, + leftCol: `grid-row: 5 / span 2; ${grid.column.left};`, + }, + body: { + leftCol: 'grid-row: 6;', + }, + 'right-column': { + desktop: `grid-row: 6 / 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;', + }, + caption: { + desktop: 'grid-row: 6;', + }, + meta: { + desktop: `grid-row: 7;`, + leftCol: `grid-row: 5 / span 2; ${grid.column.left};`, + }, + body: { + leftCol: 'grid-row: 6;', + }, + 'right-column': { + desktop: `grid-row: 6 / span 3; ${grid.column.right};`, + }, +}; + const layoutCssMaps: Record = { standard: standardCss, showcase: showcaseCss, media: mediaCss, + immersiveLandscapeDefault: immersiveLandscapeDefaultCss, + immersiveLandscapeFeature: immersiveLandscapeFeatureCss, + immersivePortraitDefault: immersivePortraitDefaultCss, + immersivePortraitFeature: immersivePortraitFeatureCss, }; /** @@ -192,3 +373,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, + isMedia, + isShowcase, +}: { + isImmersive: boolean; + isFeature: boolean; + orientation: 'portrait' | 'landscape' | 'square'; + isMedia: 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 (isMedia) return 'media'; + if (isShowcase) return 'showcase'; + return 'standard'; +}; diff --git a/dotcom-rendering/src/lib/articleMeta.test.ts b/dotcom-rendering/src/lib/articleMeta.test.ts index dd7eebd8ebd..f064e36a97e 100644 --- a/dotcom-rendering/src/lib/articleMeta.test.ts +++ b/dotcom-rendering/src/lib/articleMeta.test.ts @@ -23,11 +23,29 @@ describe('shouldShowContributor', () => { ...standardFormat, display: ArticleDisplay.NumberedList, }; + const immersive = { ...standardFormat, display: ArticleDisplay.Immersive, }; + const immersivePortraitDefault = { + ...immersive, + layoutType: 'immersivePortraitDefault', + }; + const immersivePortraitFeature = { + ...immersive, + layoutType: 'immersivePortraitFeature', + }; + const immersiveLandscapeDefault = { + ...immersive, + layoutType: 'immersiveLandscapeDefault', + }; + const immersiveLandscapeFeature = { + ...immersive, + layoutType: 'immersiveLandscapeFeature', + }; + it('should return true if Standard display and Standard design', () => { expect(shouldShowContributor(standardFormat)).toBe(true); }); @@ -49,6 +67,22 @@ describe('shouldShowContributor', () => { }); it('should return false if Immersive display', () => { - expect(shouldShowContributor(immersive)).toBe(false); + expect(shouldShowContributor(immersive)).toBe(true); + }); + + it('should return true if Immersive display and immersivePortraitDefault layout', () => { + expect(shouldShowContributor(immersivePortraitDefault)).toBe(true); + }); + + it('should return true if Immersive display and immersivePortraitFeature layout', () => { + expect(shouldShowContributor(immersivePortraitFeature)).toBe(true); + }); + + it('should return true if Immersive display and immersiveLandscapeDefault layout', () => { + expect(shouldShowContributor(immersiveLandscapeDefault)).toBe(true); + }); + + it('should return true if Immersive display and immersiveLandscapeFeature layout', () => { + expect(shouldShowContributor(immersiveLandscapeFeature)).toBe(true); }); }); diff --git a/dotcom-rendering/src/lib/articleMeta.ts b/dotcom-rendering/src/lib/articleMeta.ts index 8b3543071f6..23b51b8a264 100644 --- a/dotcom-rendering/src/lib/articleMeta.ts +++ b/dotcom-rendering/src/lib/articleMeta.ts @@ -8,7 +8,6 @@ import { export const shouldShowAvatar = (format: ArticleFormat): boolean => { switch (format.display) { case ArticleDisplay.Immersive: - return false; case ArticleDisplay.Showcase: case ArticleDisplay.NumberedList: case ArticleDisplay.Standard: { @@ -32,7 +31,6 @@ export const shouldShowContributor = (format: ArticleFormat): boolean => { case ArticleDisplay.NumberedList: return true; case ArticleDisplay.Immersive: - return false; case ArticleDisplay.Showcase: case ArticleDisplay.Standard: { switch (format.design) { diff --git a/dotcom-rendering/src/lib/getZIndex.test.ts b/dotcom-rendering/src/lib/getZIndex.test.ts index 19f82962d88..78bb441772c 100644 --- a/dotcom-rendering/src/lib/getZIndex.test.ts +++ b/dotcom-rendering/src/lib/getZIndex.test.ts @@ -14,6 +14,12 @@ describe('getZIndex', () => { expect(getZIndex('tableOfContents')).toBeGreaterThan( getZIndex('articleHeadline'), ); + expect(getZIndex('subNavBanner')).toBeGreaterThan( + getZIndex('articleHeadline'), + ); + expect(getZIndex('subNavBanner')).toBeGreaterThan( + getZIndex('bodyArea'), + ); expect(getZIndex('card-nested-link')).toBeGreaterThan( getZIndex('card-link'), ); diff --git a/dotcom-rendering/src/lib/renderElement.tsx b/dotcom-rendering/src/lib/renderElement.tsx index 03043d1bef0..8414852af5f 100644 --- a/dotcom-rendering/src/lib/renderElement.tsx +++ b/dotcom-rendering/src/lib/renderElement.tsx @@ -69,6 +69,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; }; @@ -178,6 +180,7 @@ export const renderElement = ({ shouldHideAds, contentType, contentLayout, + articleArrangement, idApiUrl, }: Props) => { const isBlog = @@ -409,6 +412,7 @@ export const renderElement = ({ title={element.title} isAvatar={element.isAvatar} isTimeline={isTimeline} + articleArrangement={articleArrangement} /> ); case 'model.dotcomrendering.pageElements.InstagramBlockElement': @@ -1050,6 +1054,7 @@ export const RenderArticleElement = ({ shouldHideAds, contentType, contentLayout, + articleArrangement, idApiUrl, }: Props) => { const withUpdatedRole = updateRole(element, format); @@ -1078,6 +1083,7 @@ export const RenderArticleElement = ({ shouldHideAds, contentType, contentLayout, + articleArrangement, idApiUrl, }); @@ -1102,6 +1108,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 3fb76dea59d..b0bf5c22d02 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: