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..7549d4b4cd6 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 { @@ -39,6 +40,7 @@ import { StarRating } from './StarRating/StarRating'; type Props = { headlineString: string; format: ArticleFormat; + layoutType?: LayoutType; byline?: string; tags: TagType[]; webPublicationDateDeprecated: string; @@ -214,7 +216,16 @@ const invertedStyles = css` box-decoration-break: clone; `; -const immersiveStyles = css` +const legacyInvertedStyles = css` + white-space: pre-wrap; + padding-right: ${space[1]}px; + padding-bottom: ${space[1]}px; + box-shadow: -6px 0 0 ${themePalette('--headline-background')}; + /* Box decoration is required to push the box shadow out on Firefox */ + box-decoration-break: clone; +`; + +const legacyImmersiveStyles = css` min-height: 112px; padding-bottom: ${space[6]}px; padding-left: ${space[1]}px; @@ -230,16 +241,47 @@ const immersiveStyles = css` margin-right: ${space[5]}px; `; -const darkBackground = css` - background-color: ${themePalette('--headline-background')}; +const legacyImmersiveWrapper = css` + margin-left: 6px; + + ${from.tablet} { + margin-left: 16px; + } + + ${from.leftCol} { + margin-left: 25px; + } + + flex-grow: 1; + z-index: ${getZIndex('articleHeadline')}; + + ${until.mobileLandscape} { + margin-right: 40px; + } `; -const invertedText = css` +const legacyInvertedText = css` white-space: pre-wrap; padding-bottom: ${space[1]}px; padding-right: ${space[1]}px; `; +const darkBackground = css` + background-color: ${themePalette('--headline-background')}; +`; + +const invertedText = css` + ${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` ${from.desktop} { max-width: 620px; @@ -255,35 +297,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 @@ -291,60 +304,83 @@ const zIndex = css` z-index: 1; `; -const ageWarningMargins = (format: ArticleFormat) => { +const ageWarningMargins = ( + format: ArticleFormat, + isLegacyImmersive: boolean, +) => { if (format.design === ArticleDesign.Gallery) { return ''; } - return format.display === ArticleDisplay.Immersive - ? css` - margin-left: 0px; - margin-bottom: 0px; + if (format.display === ArticleDisplay.Immersive) { + return isLegacyImmersive + ? css` + margin-left: 0; + margin-bottom: 0; - ${from.tablet} { - margin-left: 10px; - } + ${from.tablet} { + margin-left: 10px; + } - ${from.leftCol} { - margin-left: 20px; - } - ` - : css` - margin-top: 12px; - margin-left: -10px; - margin-bottom: 6px; - - ${from.tablet} { - margin-left: -20px; - } + ${from.leftCol} { + margin-left: 20px; + } + ` + : ''; + } + return css` + margin-top: 12px; + margin-left: -10px; + margin-bottom: 6px; - ${from.leftCol} { - margin-left: -10px; - margin-top: 0; - } - `; -}; + ${from.tablet} { + margin-left: -20px; + } -const backgroundStyles = css` - background-color: ${themePalette('--age-warning-wrapper-background')}; -`; + ${from.leftCol} { + margin-left: -10px; + margin-top: 0; + } + `; +}; const WithAgeWarning = ({ tags, webPublicationDateDeprecated, format, children, + snapToInverted = false, + isLegacyImmersive = false, }: { tags: TagType[]; webPublicationDateDeprecated: string; format: ArticleFormat; children: React.ReactNode; + snapToInverted?: boolean; + isLegacyImmersive?: boolean; }) => { const age = getAgeWarning(tags, webPublicationDateDeprecated); if (age) { return ( <> -
+
{children} @@ -433,6 +469,7 @@ const galleryStyles = css` export const ArticleHeadline = ({ headlineString, format, + layoutType, tags, byline, webPublicationDateDeprecated, @@ -440,6 +477,8 @@ export const ArticleHeadline = ({ isMatch, starRating, }: Props) => { + const isInverted = layoutType === 'immersiveLandscapeDefault'; + const isLegacyImmersive = layoutType == null; switch (format.display) { case ArticleDisplay.Immersive: { switch (format.design) { @@ -465,12 +504,22 @@ export const ArticleHeadline = ({ format.theme === ArticleSpecial.Labs ? labsFont : headlineFont(format), - invertedText, - css` - color: ${themePalette( - '--headline-colour', - )}; - `, + isLegacyImmersive + ? [ + legacyInvertedText, + css` + color: ${themePalette( + '--headline-colour', + )}; + `, + ] + : isInverted + ? [invertedText, darkBackground] + : css` + color: ${themePalette( + '--headline-colour', + )}; + `, ]} > {headlineString} @@ -496,16 +545,28 @@ export const ArticleHeadline = ({ webPublicationDateDeprecated } format={format} + snapToInverted={isInverted} + isLegacyImmersive={isLegacyImmersive} >

diff --git a/dotcom-rendering/src/components/ArticleMeta.apps.tsx b/dotcom-rendering/src/components/ArticleMeta.apps.tsx index de5333c68e5..9137b14e906 100644 --- a/dotcom-rendering/src/components/ArticleMeta.apps.tsx +++ b/dotcom-rendering/src/components/ArticleMeta.apps.tsx @@ -4,6 +4,7 @@ import { from, space, until } from '@guardian/source/foundations'; import { StraightLines } from '@guardian/source-development-kitchen/react-components'; import type { ReactNode } from 'react'; import { grid } from '../grid'; +import type { LayoutType } from '../layouts/lib/articleArrangements'; import { interactiveLegacyClasses } from '../layouts/lib/interactiveLegacyStyling'; import { ArticleDesign, @@ -36,6 +37,7 @@ import { PodcastMeta } from './PodcastMeta'; type Props = { format: ArticleFormat; + layoutType?: LayoutType; byline?: string; tags: TagType[]; primaryDateline: string; @@ -242,6 +244,7 @@ const galleryMetaContainer = css` export const ArticleMetaApps = ({ branding, format, + layoutType, byline, tags, primaryDateline, @@ -253,9 +256,10 @@ export const ArticleMetaApps = ({ headline, mainMediaElements, }: Props) => { + const isImmersiveGrid = layoutType?.startsWith('immersive') ?? false; const soleContributor = getSoleContributor(tags, byline); const authorName = soleContributor?.title ?? 'Author Image'; - const avatarUrl = shouldShowAvatar(format) + const avatarUrl = shouldShowAvatar(format, isImmersiveGrid) ? soleContributor?.bylineLargeImageUrl : undefined; @@ -328,13 +332,14 @@ export const ArticleMetaApps = ({ )} - {shouldShowContributor(format) && !!byline && ( - - )} + {shouldShowContributor(format, isImmersiveGrid) && + !!byline && ( + + )} {shouldShowFollowButtons( isComment || isAnalysis || isImmersive, ) && diff --git a/dotcom-rendering/src/components/ArticleMeta.web.tsx b/dotcom-rendering/src/components/ArticleMeta.web.tsx index 5abd00eede4..e165c0b6636 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 }) => (
- <> + {isImmersiveGrid ? ( +
{!!avatarUrl && ( - + - + )}
{isAudio && podcast && seriesTag && ( @@ -369,7 +394,7 @@ export const ArticleMeta = ({ /> )} - {shouldShowContributor(format) && ( + {shouldShowContributor(format, isImmersiveGrid) && ( )}
- - +
+ ) : ( + + <> + {!!avatarUrl && ( + + + + )} +
+ {isAudio && podcast && seriesTag && ( + + )} + + {shouldShowContributor( + format, + isImmersiveGrid, + ) && ( + + )} + {crossword?.creator && ( + + )} + + {!isUndefined(webPublicationDate) && + isFilterArticle ? ( + + ) : ( + + )} +
+ +
+ )}
{showPreferredSource ? : null} + {isImmersive && mainMediaElements?.[0] && ( + +
+ +
+
+ )}
); diff --git a/dotcom-rendering/src/components/ArticleTitle.tsx b/dotcom-rendering/src/components/ArticleTitle.tsx index de44e1eb319..78b1de7ce82 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; @@ -27,13 +29,19 @@ const sectionStyles = css` } `; -const immersiveMargins = css` +const immersiveGridMargins = css` + max-width: 500px; + min-width: 200px; + margin-bottom: 4px; + ${from.tablet} { + margin-left: -4px; + } +`; + +const legacyImmersiveMargins = css` max-width: 400px; min-width: 200px; margin-bottom: 4px; - /* - Make sure we vertically align the title font with the body font - */ ${from.tablet} { margin-left: 16px; } @@ -57,6 +65,7 @@ const galleryStyles = css` export const ArticleTitle = ({ format, + layoutType, tags, sectionLabel, sectionUrl, @@ -74,12 +83,15 @@ export const ArticleTitle = ({
css` @@ -251,12 +253,17 @@ const galleryStyles = css` } `; -const CameraIcon = ({ format, isMainMedia }: IconProps) => { +const CameraIcon = ({ + format, + isMainMedia, + showIconBelowLeftCol, +}: IconProps) => { return ( @@ -265,12 +272,17 @@ const CameraIcon = ({ format, isMainMedia }: IconProps) => { ); }; -const VideoIcon = ({ format, isMainMedia }: IconProps) => { +const VideoIcon = ({ + format, + isMainMedia, + showIconBelowLeftCol, +}: IconProps) => { return ( { // Sometimes captions come thorough as a single blank space, so we trim here to ignore those const noCaption = !captionText?.trim(); @@ -325,9 +338,17 @@ export const Caption = ({ data-spacefinder-role="inline" > {mediaType === 'YoutubeVideo' || mediaType === 'SelfHostedVideo' ? ( - + ) : ( - + )} {!!captionText && ( 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/LabsHeader.stories.tsx b/dotcom-rendering/src/components/LabsHeader.stories.tsx index 7bda8b0c501..bc75f03f2cc 100644 --- a/dotcom-rendering/src/components/LabsHeader.stories.tsx +++ b/dotcom-rendering/src/components/LabsHeader.stories.tsx @@ -12,7 +12,7 @@ export const Default = () => {
diff --git a/dotcom-rendering/src/components/LabsHeader.tsx b/dotcom-rendering/src/components/LabsHeader.tsx index 77076f444b8..fe699895fa0 100644 --- a/dotcom-rendering/src/components/LabsHeader.tsx +++ b/dotcom-rendering/src/components/LabsHeader.tsx @@ -17,7 +17,13 @@ import { LABS_HEADER_HEIGHT } from '../lib/labs-constants'; import LabsLogo from '../static/logos/the-guardian-labs.svg'; import { Details } from './Details'; -const FlexWrapper = ({ children }: { children: React.ReactNode }) => ( +const FlexWrapper = ({ + children, + textColour, +}: { + children: React.ReactNode; + textColour: string; +}) => (
( display: flex; justify-content: space-between; - color: ${palette.neutral[7]}; + color: ${textColour}; `} > {children} @@ -90,11 +96,17 @@ const Title = () => (
); -const About = () => ( +const About = ({ + backgroundColour, + textColour, +}: { + backgroundColour: string; + textColour: string; +}) => (
( ${from.mobileLandscape} { padding: ${space[3]}px 20px; } - - > a { - color: black; - } `} >

@@ -125,6 +133,7 @@ const About = () => ( iconSide="right" size="xsmall" priority="subdued" + theme={{ textSubdued: textColour }} icon={} href="https://www.theguardian.com/info/2016/jan/25/content-funding" > @@ -138,13 +147,32 @@ const Logo = ({ editionId }: { editionId: EditionId }) => ( href={`https://www.theguardian.com/guardian-labs${getLabsUrlSuffix( editionId, )}`} + cssOverrides={css` + display: flex; + color: inherit; + svg { + fill: currentColor; + } + + &:hover { + color: inherit; + } + `} > ); -export const LabsHeader = ({ editionId }: { editionId: EditionId }) => ( - +export const LabsHeader = ({ + editionId, + textColour = palette.neutral[100], + backgroundColour = palette.labs[100], +}: { + editionId: EditionId; + textColour?: string; + backgroundColour?: string; +}) => ( + @@ -166,7 +194,10 @@ export const LabsHeader = ({ editionId }: { editionId: EditionId }) => ( } `} > - <About /> + <About + backgroundColour={backgroundColour} + textColour={textColour} + /> </Details> </HeaderSection> </Left> 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 ( <div css={[mainMedia, chooseWrapper(format)]}> @@ -134,6 +138,7 @@ export const MainMedia = ({ shouldHideAds={shouldHideAds} contentType={contentType} contentLayout={contentLayout} + articleArrangement={articleArrangement} /> ))} </div> diff --git a/dotcom-rendering/src/components/SeriesSectionLink.tsx b/dotcom-rendering/src/components/SeriesSectionLink.tsx index 803a78ba186..780ffce63aa 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, @@ -222,6 +225,11 @@ export const SeriesSectionLink = ({ ? themePalette('--series-title-match-text') : themePalette('--series-title-text'); + /** Used by the separate 'article section' link, kept distinct from the series tag's colour */ + const sectionTitleColour = isMatch + ? themePalette('--series-title-match-text') + : themePalette('--article-section-link-text'); + if ( format.display === ArticleDisplay.Immersive && (format.design === ArticleDesign.Comment || @@ -271,9 +279,7 @@ export const SeriesSectionLink = ({ displayBlock, breakWord, css` - color: ${themePalette( - '--series-title-text', - )}; + color: ${sectionTitleColour}; background-color: ${themePalette( '--section-title-background', )}; @@ -308,7 +314,7 @@ export const SeriesSectionLink = ({ fontStyles(format), breakWord, css` - color: ${titleColour}; + color: ${sectionTitleColour}; background-color: ${themePalette( '--section-title-background', )}; @@ -376,7 +382,38 @@ export const SeriesSectionLink = ({ </div> ); } - // Immersives show nothing at all if there's no series tag + if ( + layoutType === 'immersivePortraitDefault' || + layoutType === 'immersivePortraitFeature' || + layoutType === 'immersiveLandscapeDefault' || + layoutType === 'immersiveLandscapeFeature' + ) { + return ( + <> + <a + href={`${guardianBaseURL}/${sectionUrl}`} + css={[ + sectionLabelLink, + css` + color: ${sectionTitleColour}; + background-color: ${themePalette( + '--section-title-background', + )}; + `, + marginRight, + fontStyles(format), + breakWord, + ]} + data-component="section" + data-link-name="article section" + className={interactiveLegacyClasses.labelLink} + > + <span>{sectionLabel}</span> + </a> + </> + ); + } + // Other types of immersives show nothing at all if there's no series tag return null; } if (tag) { @@ -439,7 +476,7 @@ export const SeriesSectionLink = ({ displayBlock, breakWord, css` - color: ${titleColour}; + color: ${sectionTitleColour}; background-color: ${themePalette( '--section-title-background', )}; @@ -481,10 +518,7 @@ export const SeriesSectionLink = ({ css={[ sectionLabelLink, css` - color: ${titleColour}; - background-color: ${themePalette( - '--section-title-background', - )}; + color: ${sectionTitleColour}; `, marginRight, fontStyles(format), diff --git a/dotcom-rendering/src/components/Standfirst.tsx b/dotcom-rendering/src/components/Standfirst.tsx index 0567d1dbc99..da5fb9130b1 100644 --- a/dotcom-rendering/src/components/Standfirst.tsx +++ b/dotcom-rendering/src/components/Standfirst.tsx @@ -15,6 +15,7 @@ import { } from '@guardian/source/foundations'; import sanitise from 'sanitize-html'; import { grid } from '../../src/grid'; +import type { LayoutType } from '../layouts/lib/articleArrangements'; import { interactiveLegacyClasses } from '../layouts/lib/interactiveLegacyStyling'; import { ArticleDesign, @@ -27,6 +28,7 @@ import { palette } from '../palette'; type Props = { format: ArticleFormat; standfirst: string; + layoutType?: LayoutType; }; const nestedStyles = (format: ArticleFormat) => { @@ -390,7 +392,16 @@ const hoverStyles = css` } `; -export const Standfirst = ({ format, standfirst }: Props) => { +const immersiveGridOverrides = css` + padding-top: 0; + max-width: none; + + ${from.tablet} { + max-width: none; + } +`; + +export const Standfirst = ({ format, standfirst, layoutType }: Props) => { if (standfirst.trim() === '') { return null; } @@ -403,6 +414,9 @@ export const Standfirst = ({ format, standfirst }: Props) => { decideFont(format), decidePadding(format), hoverStyles, + layoutType?.startsWith('immersive') + ? immersiveGridOverrides + : undefined, ]} className={ format.design === ArticleDesign.Interactive diff --git a/dotcom-rendering/src/layouts/DecideLayout.stories.tsx b/dotcom-rendering/src/layouts/DecideLayout.stories.tsx index e65424200ac..a235ea2febe 100644 --- a/dotcom-rendering/src/layouts/DecideLayout.stories.tsx +++ b/dotcom-rendering/src/layouts/DecideLayout.stories.tsx @@ -27,6 +27,7 @@ import { import { getCurrentPillar } from '../lib/layoutHelpers'; import { extractNAV } from '../model/extract-nav'; import { type Article, enhanceArticleType } from '../types/article'; +import type { ImageBlockElement } from '../types/content'; import { DecideLayout, type Props as DecideLayoutProps } from './DecideLayout'; export type HydratedLayoutDecoratorArgs = { @@ -50,11 +51,7 @@ const HydratedLayout: Decorator< display: article.display, theme: article.theme, }; - const colourScheme = - (isObject(context.parameters.config) && - context.parameters.config.renderingTarget === 'Apps' - ? context.args.colourScheme - : 'light') ?? 'light'; + const colourScheme = context.args.colourScheme ?? 'light'; const paletteDecorator = colourSchemeDecorator( colourScheme, )<DecideLayoutProps>([format]); @@ -118,6 +115,13 @@ const webParameters = { }, }; +const webDarkParameters = { + config: { + renderingTarget: 'Web', + darkModeAvailable: true, + }, +}; + export const WebStandardStandardNewsLight = { args: { article: enhanceArticleType(StandardStandardNewsFixture, 'Web'), @@ -229,13 +233,130 @@ export const AppsPictureShowcaseOpinionDark: Story = { * * Example: https://www.chromatic.com/test?appId=63e251470cfbe61776b0ef19&id=675aaa4f3aa384bd64bde3a1 */ +const photoEssayImmersiveLabsArticle = enhanceArticleType( + PhotoEssayImmersiveLabsFixture, + 'Web', +); + +const portraitMainMedia = PhotoEssayImmersiveLabsFixture.blocks + .flatMap((block) => block.elements) + .find( + (element): element is ImageBlockElement => + element._type === + 'model.dotcomrendering.pageElements.ImageBlockElement' && + element.media.allImages[0]?.fields.aspectRatio === '4:5', + ); + +if (portraitMainMedia == null) { + throw new Error('The Labs fixture must contain a portrait image'); +} + +const photoEssayImmersiveLabsPortraitArticle = enhanceArticleType( + { + ...PhotoEssayImmersiveLabsFixture, + mainMediaElements: [portraitMainMedia], + }, + 'Web', +); + +const labsImmersiveArticle = ({ + orientation, + design, +}: { + orientation: 'portrait' | 'landscape'; + design: ArticleDesign.PhotoEssay | ArticleDesign.Feature; +}): Article => ({ + ...(orientation === 'portrait' + ? photoEssayImmersiveLabsPortraitArticle + : photoEssayImmersiveLabsArticle), + design, +}); + +const immersiveLabsParameters = { + ...webParameters, +}; + export const WebPhotoEssayImmersiveLabsLight: Story = { args: { - article: enhanceArticleType(PhotoEssayImmersiveLabsFixture, 'Web'), + article: labsImmersiveArticle({ + orientation: 'landscape', + design: ArticleDesign.PhotoEssay, + }), + }, + parameters: immersiveLabsParameters, +}; + +export const WebPhotoEssayImmersiveLabsDark: Story = { + args: { + article: WebPhotoEssayImmersiveLabsLight.args?.article, + colourScheme: 'dark', + }, + parameters: { + ...immersiveLabsParameters, + ...webDarkParameters, + }, +}; + +export const WebPhotoEssayImmersiveLabsPortraitLight: Story = { + args: { + article: labsImmersiveArticle({ + orientation: 'portrait', + design: ArticleDesign.PhotoEssay, + }), + }, + parameters: immersiveLabsParameters, +}; + +export const WebPhotoEssayImmersiveLabsPortraitDark: Story = { + args: { + article: WebPhotoEssayImmersiveLabsPortraitLight.args?.article, + colourScheme: 'dark', + }, + parameters: { + ...immersiveLabsParameters, + ...webDarkParameters, + }, +}; + +export const WebFeatureImmersiveLabsLandscapeLight: Story = { + args: { + article: labsImmersiveArticle({ + orientation: 'landscape', + design: ArticleDesign.Feature, + }), + }, + parameters: immersiveLabsParameters, +}; + +export const WebFeatureImmersiveLabsLandscapeDark: Story = { + args: { + article: WebFeatureImmersiveLabsLandscapeLight.args?.article, + colourScheme: 'dark', + }, + parameters: { + ...immersiveLabsParameters, + ...webDarkParameters, + }, +}; + +export const WebFeatureImmersiveLabsPortraitLight: Story = { + args: { + article: labsImmersiveArticle({ + orientation: 'portrait', + design: ArticleDesign.Feature, + }), + }, + parameters: immersiveLabsParameters, +}; + +export const WebFeatureImmersiveLabsPortraitDark: Story = { + args: { + article: WebFeatureImmersiveLabsPortraitLight.args?.article, + colourScheme: 'dark', }, parameters: { - ...webParameters, - chromatic: { disableSnapshot: true }, + ...immersiveLabsParameters, + ...webDarkParameters, }, }; diff --git a/dotcom-rendering/src/layouts/DecideLayout.tsx b/dotcom-rendering/src/layouts/DecideLayout.tsx index 77803138313..2a763cf1e56 100644 --- a/dotcom-rendering/src/layouts/DecideLayout.tsx +++ b/dotcom-rendering/src/layouts/DecideLayout.tsx @@ -1,4 +1,8 @@ -import { ArticleDesign, ArticleDisplay } from '../lib/articleFormat'; +import { + ArticleDesign, + ArticleDisplay, + ArticleSpecial, +} from '../lib/articleFormat'; import type { NavType } from '../model/extract-nav'; import type { Article } from '../types/article'; import type { RenderingTarget } from '../types/renderingTarget'; @@ -54,7 +58,14 @@ const DecideLayoutApps = ({ article, renderingTarget }: AppProps) => { ); } default: { - return ( + return article.theme === ArticleSpecial.Labs ? ( + <StandardLayout + article={article.frontendData} + format={format} + renderingTarget={renderingTarget} + serverTime={serverTime} + /> + ) : ( <ImmersiveLayout article={article.frontendData} format={format} @@ -230,7 +241,15 @@ const DecideLayoutWeb = ({ article, NAV, renderingTarget }: WebProps) => { ); } default: { - return ( + return article.theme === ArticleSpecial.Labs ? ( + <StandardLayout + article={article.frontendData} + format={format} + NAV={NAV} + renderingTarget={renderingTarget} + serverTime={serverTime} + /> + ) : ( <ImmersiveLayout article={article.frontendData} format={format} diff --git a/dotcom-rendering/src/layouts/FrontLayout.tsx b/dotcom-rendering/src/layouts/FrontLayout.tsx index d2507a918bf..0b7e0072158 100644 --- a/dotcom-rendering/src/layouts/FrontLayout.tsx +++ b/dotcom-rendering/src/layouts/FrontLayout.tsx @@ -246,7 +246,7 @@ export const FrontLayout = ({ front, NAV }: Props) => { <Section fullWidth={true} showTopBorder={false} - backgroundColour={sourcePalette.labs[400]} + backgroundColour={sourcePalette.labs[100]} borderColour={sourcePalette.neutral[60]} sectionId="labs-header" > diff --git a/dotcom-rendering/src/layouts/FullPageInteractiveLayout.tsx b/dotcom-rendering/src/layouts/FullPageInteractiveLayout.tsx index a799f339ce1..3998e36f578 100644 --- a/dotcom-rendering/src/layouts/FullPageInteractiveLayout.tsx +++ b/dotcom-rendering/src/layouts/FullPageInteractiveLayout.tsx @@ -234,7 +234,7 @@ export const FullPageInteractiveLayout = (props: WebProps | AppsProps) => { fullWidth={true} showTopBorder={false} padSides={true} - backgroundColour={sourcePalette.labs[400]} + backgroundColour={sourcePalette.labs[100]} borderColour={sourcePalette.neutral[60]} sectionId="labs-header" > @@ -257,7 +257,7 @@ export const FullPageInteractiveLayout = (props: WebProps | AppsProps) => { fullWidth={true} showTopBorder={false} padSides={true} - backgroundColour={sourcePalette.labs[400]} + backgroundColour={sourcePalette.labs[100]} borderColour={sourcePalette.neutral[60]} sectionId="labs-header" > diff --git a/dotcom-rendering/src/layouts/GalleryLayout.tsx b/dotcom-rendering/src/layouts/GalleryLayout.tsx index c00dccf3a5e..fb8cd0351d2 100644 --- a/dotcom-rendering/src/layouts/GalleryLayout.tsx +++ b/dotcom-rendering/src/layouts/GalleryLayout.tsx @@ -436,7 +436,7 @@ const GalleryLabsHeader = (props: { <Section fullWidth={true} showTopBorder={false} - backgroundColour={sourcePalette.labs[400]} + backgroundColour={sourcePalette.labs[100]} borderColour={sourcePalette.neutral[60]} sectionId="labs-header" element="aside" diff --git a/dotcom-rendering/src/layouts/ImmersiveLayout.tsx b/dotcom-rendering/src/layouts/ImmersiveLayout.tsx index 69cf8833b4b..a8e65de4e33 100644 --- a/dotcom-rendering/src/layouts/ImmersiveLayout.tsx +++ b/dotcom-rendering/src/layouts/ImmersiveLayout.tsx @@ -342,7 +342,7 @@ export const ImmersiveLayout = (props: WebProps | AppProps) => { <Section fullWidth={true} showTopBorder={false} - backgroundColour={sourcePalette.labs[400]} + backgroundColour={sourcePalette.labs[100]} borderColour={sourcePalette.neutral[60]} sectionId="labs-header" > diff --git a/dotcom-rendering/src/layouts/InteractiveLayout.tsx b/dotcom-rendering/src/layouts/InteractiveLayout.tsx index 3b4708d1212..955c66951f9 100644 --- a/dotcom-rendering/src/layouts/InteractiveLayout.tsx +++ b/dotcom-rendering/src/layouts/InteractiveLayout.tsx @@ -296,7 +296,7 @@ export const InteractiveLayout = (props: WebProps | AppsProps) => { <Section fullWidth={true} showTopBorder={false} - backgroundColour={sourcePalette.labs[400]} + backgroundColour={sourcePalette.labs[100]} borderColour={sourcePalette.neutral[60]} sectionId="labs-header" > diff --git a/dotcom-rendering/src/layouts/StandardLayout.tsx b/dotcom-rendering/src/layouts/StandardLayout.tsx index 8a855b830a5..764314e7381 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)} @@ -131,12 +135,16 @@ export const StandardLayout = (props: WebProps | AppProps) => { <Section fullWidth={true} showTopBorder={false} - backgroundColour={sourcePalette.labs[400]} + backgroundColour={sourcePalette.labs[100]} borderColour={sourcePalette.neutral[60]} sectionId="labs-header" element="aside" > - <LabsHeader editionId={editionId} /> + <LabsHeader + editionId={editionId} + textColour={sourcePalette.neutral[100]} + backgroundColour={sourcePalette.labs[100]} + /> </Section> </Stuck> )} @@ -157,7 +165,7 @@ export const StandardLayout = (props: WebProps | AppProps) => { <AdSlot position="survey" display={format.display} /> )} - <main data-layout={`${ArticleDisplay[format.display]}Layout`}> + <main data-layout={contentLayoutName}> {isApps && renderAds && ( <Island priority="critical"> <AdPortals /> @@ -172,6 +180,7 @@ export const StandardLayout = (props: WebProps | AppProps) => { renderingTarget={renderingTarget} /> </div> + {isWeb && renderAds && !isLabs && ( <Section fullWidth={true} diff --git a/dotcom-rendering/src/layouts/StandardLayoutArticleGrid.tsx b/dotcom-rendering/src/layouts/StandardLayoutArticleGrid.tsx index e366da5e64d..b996bc38dbf 100644 --- a/dotcom-rendering/src/layouts/StandardLayoutArticleGrid.tsx +++ b/dotcom-rendering/src/layouts/StandardLayoutArticleGrid.tsx @@ -11,6 +11,7 @@ import { ArticleHeadline } from '../components/ArticleHeadline'; import { ArticleMetaApps } from '../components/ArticleMeta.apps'; import { ArticleMeta } from '../components/ArticleMeta.web'; import { ArticleTitle } from '../components/ArticleTitle'; +import { Caption } from '../components/Caption'; import { DecideLines } from '../components/DecideLines'; import { FootballMatchInfoWrapper } from '../components/FootballMatchInfoWrapper.island'; import { GuardianLabsLines } from '../components/GuardianLabsLines'; @@ -22,6 +23,7 @@ import { SlotBodyEnd } from '../components/SlotBodyEnd.island'; import { Standfirst } from '../components/Standfirst'; import { SubMeta } from '../components/SubMeta'; import { grid } from '../grid'; +import { getAgeWarning } from '../lib/age-warning'; import { ArticleDesign, ArticleDisplay, @@ -29,6 +31,8 @@ import { ArticleSpecial, } from '../lib/articleFormat'; import { getContributionsServiceUrl } from '../lib/contributions'; +import { decideMainMediaCaption } from '../lib/decide-caption'; +import { getZIndex } from '../lib/getZIndex'; import { safeParseURL } from '../lib/parse'; import { parse } from '../lib/slot-machine-flags'; import { palette as themePalette } from '../palette'; @@ -36,6 +40,7 @@ import type { ArticleDeprecated } from '../types/article'; import type { RenderingTarget } from '../types/renderingTarget'; import { type Area, + getLayoutType, gridItemCss, type LayoutType, } from './lib/articleArrangements'; @@ -59,6 +64,23 @@ interface GridItemProps { children: React.ReactNode; } +/** + * Works out the orientation of an image from its Guardian media URL, which + * encodes the crop dimensions in the path (e.g. `/1000_600_800_480/`). + * Falls back to 'landscape' if the URL doesn't match the expected pattern. + */ +const getImageOrientation = ( + url: string, +): 'portrait' | 'landscape' | 'square' => { + const match = url.match(/\/\d+_\d+_(\d+)_(\d+)\/\d+\.\w+$/); + if (!match) return 'landscape'; + const [, width, height] = match.map(Number); + if (width == null || height == null) return 'landscape'; + if (height > width) return 'portrait'; + if (width > height) return 'landscape'; + return 'square'; +}; + const GridItem = ({ area, layoutType, @@ -110,6 +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,52 @@ 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 centreRuleColumn = (() => { + switch (layoutType) { + case 'immersivePortraitDefault': + case 'immersivePortraitFeature': + case 'immersiveLandscapeDefault': + return 4; + default: + return 3; + } + })(); + + const ageWarning = getAgeWarning( + article.tags, + article.webPublicationDateDeprecated, + ); return ( <article @@ -133,15 +198,60 @@ export const StandardLayoutArticleGrid = ({ `, grid.container, grid.outerRules(), + isLabs && + isImmersive && + css` + &::before, + &::after { + z-index: ${getZIndex('immersiveGridOuterRules')}; + } + `, !isLabs && css` ${from.leftCol} { - ${grid.centreRule(3)} + ${grid.centreRule(centreRuleColumn)} + } + `, + isImmersivePortrait && + css` + ${from.desktop} { + grid-template-rows: 0.25fr 1fr auto; + } + `, + isImmersiveLandscape && + css` + ${from.desktop} { + grid-template-rows: auto auto ${ageWarning != null + ? '130px' + : '90px'} auto auto auto auto auto; + ${grid.centreRule( + layoutType === 'immersiveLandscapeFeature' + ? 3 + : 4, + )} } `, ]} > - <GridItem area="media" layoutType={layoutType}> + <GridItem + area="media" + layoutType={layoutType} + css={ + isImmersive + ? css` + align-self: start; + ${mainMediaAspectRatio != null && + `aspect-ratio: ${mainMediaAspectRatio.replace(':', ' / ')};`} + + ${from.desktop} { + ${isImmersiveLandscape && + `margin-left: -20px; + margin-right: -20px;`} + } + ` + : undefined + } + > <MainMedia format={format} elements={article.mainMediaElements} @@ -156,12 +266,29 @@ export const StandardLayoutArticleGrid = ({ hideCaption={isMedia} shouldHideAds={article.shouldHideAds} contentType={article.contentType} - contentLayout={`${ArticleDisplay[format.display]}Layout`} + contentLayout={contentLayoutName} + articleArrangement={layoutType} /> </GridItem> - <GridItem area="title" layoutType={layoutType} element="aside"> + <GridItem + area="title" + layoutType={layoutType} + element="aside" + css={[ + isImmersive && + css` + z-index: ${getZIndex('articleHeadline')}; + `, + isImmersivePortrait && + css` + align-self: end; + margin-bottom: 2px; + `, + ]} + > <ArticleTitle format={format} + layoutType={layoutType} tags={article.tags} sectionLabel={article.sectionLabel} sectionUrl={article.sectionUrl} @@ -169,9 +296,35 @@ export const StandardLayoutArticleGrid = ({ isMatch={!!footballMatchUrl} /> </GridItem> - <GridItem area="headline" layoutType={layoutType}> + <GridItem + area="headline" + layoutType={layoutType} + css={[ + isImmersive && + css` + z-index: ${getZIndex('articleHeadline')}; + `, + (layoutType === 'immersivePortraitDefault' || + layoutType === 'immersivePortraitFeature') && + css` + ${from.desktop} { + border-bottom: 1px solid + ${themePalette('--article-border')}; + border-top: 1px solid + ${themePalette('--article-border')}; + } + `, + isImmersiveLandscape && + css` + ${from.desktop} { + padding-bottom: ${space[8]}px; + } + `, + ]} + > <ArticleHeadline format={format} + layoutType={layoutType} headlineString={article.headline} tags={article.tags} byline={article.byline} @@ -181,30 +334,81 @@ export const StandardLayoutArticleGrid = ({ starRating={article.starRating} /> </GridItem> - <GridItem area="standfirst" layoutType={layoutType}> - <Standfirst format={format} standfirst={article.standfirst} /> + <GridItem + area="standfirst" + layoutType={layoutType} + css={[ + isImmersiveLandscape && + css` + ${from.desktop} { + padding-bottom: ${space[8]}px; + } + `, + ]} + > + <Standfirst + format={format} + standfirst={article.standfirst} + layoutType={layoutType} + /> </GridItem> - <GridItem area="meta" layoutType={layoutType} element="aside"> - {format.design !== ArticleDesign.Audio && ( - <div css={stretchLines}> - {isWeb && - format.theme === ArticleSpecial.Labs && - format.design !== ArticleDesign.Video ? ( - <GuardianLabsLines /> - ) : ( - <DecideLines - format={format} - color={themePalette('--article-border')} - /> - )} - </div> - )} + {isImmersive && ( + <GridItem + area="caption" + layoutType={layoutType} + css={css` + padding-top: ${space[2]}px; + `} + > + <Hide from="leftCol"> + <Caption + captionText={captionText} + format={format} + shouldLimitWidth={false} + isLeftCol={true} + isMainMedia={true} + showIconBelowLeftCol={true} + /> + </Hide> + </GridItem> + )} + <GridItem + area="meta" + layoutType={layoutType} + element="aside" + css={ + layoutType === 'immersivePortraitDefault' + ? css` + ${from.leftCol} { + margin-right: -10px; + } + ` + : undefined + } + > + {format.display !== ArticleDisplay.Immersive && + format.design !== ArticleDesign.Audio && + layoutType !== 'immersivePortraitDefault' && ( + <div css={stretchLines}> + {isWeb && + format.theme === ArticleSpecial.Labs && + format.design !== ArticleDesign.Video ? ( + <GuardianLabsLines /> + ) : ( + <DecideLines + format={format} + color={themePalette('--article-border')} + /> + )} + </div> + )} {isApps ? ( <> <Hide from="leftCol"> <ArticleMetaApps branding={branding} format={format} + layoutType={layoutType} byline={article.byline} tags={article.tags} primaryDateline={ @@ -224,6 +428,7 @@ export const StandardLayoutArticleGrid = ({ <Hide until="leftCol"> <ArticleMeta branding={branding} + layoutType={layoutType} format={format} pageId={article.pageId} webTitle={article.webTitle} @@ -253,6 +458,7 @@ export const StandardLayoutArticleGrid = ({ <> <ArticleMeta branding={branding} + layoutType={layoutType} format={format} pageId={article.pageId} webTitle={article.webTitle} @@ -263,11 +469,11 @@ export const StandardLayoutArticleGrid = ({ secondaryDateline={ article.webPublicationSecondaryDateDisplay } + webPublicationDate={article.webPublicationDate} isCommentable={article.isCommentable} discussionApiUrl={article.config.discussionApiUrl} shortUrlId={article.config.shortUrlId} mainMediaElements={article.mainMediaElements} - webPublicationDate={article.webPublicationDate} /> {!!article.affiliateLinksDisclaimer && ( <AffiliateDisclaimer /> @@ -275,7 +481,13 @@ export const StandardLayoutArticleGrid = ({ </> )} </GridItem> - <GridItem area="body" layoutType={layoutType}> + <GridItem + area="body" + layoutType={layoutType} + css={css` + z-index: ${getZIndex('bodyArea')}; + `} + > {/* Only show Listen to Article button on App landscape views */} {isApps && ( <Hide until="leftCol"> 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<Breakpoint, string> = { 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<LayoutType, LayoutCssMap> = { 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..2955e6918c7 100644 --- a/dotcom-rendering/src/lib/articleMeta.test.ts +++ b/dotcom-rendering/src/lib/articleMeta.test.ts @@ -23,6 +23,7 @@ describe('shouldShowContributor', () => { ...standardFormat, display: ArticleDisplay.NumberedList, }; + const immersive = { ...standardFormat, display: ArticleDisplay.Immersive, @@ -51,4 +52,8 @@ describe('shouldShowContributor', () => { it('should return false if Immersive display', () => { expect(shouldShowContributor(immersive)).toBe(false); }); + + it('should return true if Immersive display uses the new grid', () => { + expect(shouldShowContributor(immersive, true)).toBe(true); + }); }); diff --git a/dotcom-rendering/src/lib/articleMeta.ts b/dotcom-rendering/src/lib/articleMeta.ts index 8b3543071f6..54c8494ffab 100644 --- a/dotcom-rendering/src/lib/articleMeta.ts +++ b/dotcom-rendering/src/lib/articleMeta.ts @@ -5,10 +5,16 @@ import { type ArticleFormat, } from './articleFormat'; -export const shouldShowAvatar = (format: ArticleFormat): boolean => { +export const shouldShowAvatar = ( + format: ArticleFormat, + isImmersiveGrid = false, +): boolean => { + if (format.display === ArticleDisplay.Immersive && !isImmersiveGrid) { + return false; + } + switch (format.display) { case ArticleDisplay.Immersive: - return false; case ArticleDisplay.Showcase: case ArticleDisplay.NumberedList: case ArticleDisplay.Standard: { @@ -27,12 +33,18 @@ export const shouldShowAvatar = (format: ArticleFormat): boolean => { } }; -export const shouldShowContributor = (format: ArticleFormat): boolean => { +export const shouldShowContributor = ( + format: ArticleFormat, + isImmersiveGrid = false, +): boolean => { + if (format.display === ArticleDisplay.Immersive && !isImmersiveGrid) { + return false; + } + switch (format.display) { 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/getZIndex.ts b/dotcom-rendering/src/lib/getZIndex.ts index eed41a02d41..2b92726f8da 100644 --- a/dotcom-rendering/src/lib/getZIndex.ts +++ b/dotcom-rendering/src/lib/getZIndex.ts @@ -93,6 +93,9 @@ const indices = [ // Media overlay 'mediaOverlay', + // Vertical grid rule lines, kept above the main media in Labs immersive articles + 'immersiveGridOuterRules', + // Self-hosted video 'video-progress-bar-foreground', 'video-progress-bar-background', 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} </Figure> diff --git a/dotcom-rendering/src/paletteDeclarations.ts b/dotcom-rendering/src/paletteDeclarations.ts index 3fb76dea59d..c3f708c1ea1 100644 --- a/dotcom-rendering/src/paletteDeclarations.ts +++ b/dotcom-rendering/src/paletteDeclarations.ts @@ -49,6 +49,25 @@ const pillarPalette = ( } }; +/** + * Design groups that keep their pre-existing colours rather than the + * Labs theme-wide overrides used elsewhere in this file. + */ +const labsGalleryDesigns: ArticleDesign[] = [ + ArticleDesign.Gallery, + ArticleDesign.HostedGallery, +]; +const labsMediaDesigns: ArticleDesign[] = [ + ArticleDesign.Video, + ArticleDesign.Audio, + ArticleDesign.Picture, +]; +const labsHostedDesigns: ArticleDesign[] = [ + ArticleDesign.HostedArticle, + ArticleDesign.HostedVideo, + ArticleDesign.HostedGallery, +]; + const textblockBulletLight: PaletteFunction = ({ theme, design }) => { switch (theme) { case Pillar.News: { @@ -88,7 +107,9 @@ const textblockTextDark: PaletteFunction = () => 'inherit'; const headlineTextLight: PaletteFunction = ({ design, display, theme }) => { switch (display) { case ArticleDisplay.Immersive: - return sourcePalette.neutral[97]; + return theme === ArticleSpecial.Labs + ? sourcePalette.neutral[7] + : sourcePalette.neutral[97]; default: { switch (design) { case ArticleDesign.Editorial: @@ -203,6 +224,8 @@ const headlineBackgroundLight: PaletteFunction = ({ switch (display) { case ArticleDisplay.Immersive: switch (theme) { + case ArticleSpecial.Labs: + return 'transparent'; case ArticleSpecial.SpecialReport: return sourcePalette.specialReport[300]; default: @@ -1282,6 +1305,10 @@ const followIconBackgroundLight: PaletteFunction = ({ design, theme }) => { } }; const followIconBackgroundDark: PaletteFunction = ({ theme, design }) => { + if (theme === ArticleSpecial.Labs && design !== ArticleDesign.LiveBlog) { + return sourcePalette.neutral[7]; + } + switch (design) { case ArticleDesign.DeadBlog: return sourcePalette.neutral[7]; @@ -1311,6 +1338,10 @@ const followIconBackgroundDark: PaletteFunction = ({ theme, design }) => { }; const followIconFillLight: PaletteFunction = ({ design, theme }) => { + if (theme === ArticleSpecial.Labs && design !== ArticleDesign.LiveBlog) { + return sourcePalette.labs[200]; + } + switch (design) { case ArticleDesign.Gallery: case ArticleDesign.HostedGallery: @@ -1357,8 +1388,6 @@ const followIconFillLight: PaletteFunction = ({ design, theme }) => { return sourcePalette.culture[400]; case Pillar.Lifestyle: return sourcePalette.lifestyle[400]; - case ArticleSpecial.Labs: - return sourcePalette.labs[300]; case ArticleSpecial.SpecialReport: return sourcePalette.specialReport[300]; case ArticleSpecial.SpecialReportAlt: @@ -1389,8 +1418,6 @@ const followIconFillLight: PaletteFunction = ({ design, theme }) => { return sourcePalette.culture[400]; case Pillar.Lifestyle: return sourcePalette.lifestyle[400]; - case ArticleSpecial.Labs: - return sourcePalette.labs[300]; case ArticleSpecial.SpecialReport: return sourcePalette.specialReport[300]; case ArticleSpecial.SpecialReportAlt: @@ -1420,13 +1447,15 @@ const followIconFillLight: PaletteFunction = ({ design, theme }) => { } }; const followIconFillDark: PaletteFunction = ({ theme, design }) => { + if (theme === ArticleSpecial.Labs && design !== ArticleDesign.LiveBlog) { + return sourcePalette.labs[500]; + } + switch (design) { case ArticleDesign.LiveBlog: return sourcePalette.neutral[93]; case ArticleDesign.Standard: switch (theme) { - case ArticleSpecial.Labs: - return sourcePalette.labs[300]; case Pillar.Opinion: return sourcePalette.opinion[500]; case Pillar.Sport: @@ -1464,8 +1493,6 @@ const followIconFillDark: PaletteFunction = ({ theme, design }) => { return sourcePalette.culture[500]; case Pillar.Lifestyle: return sourcePalette.lifestyle[500]; - case ArticleSpecial.Labs: - return sourcePalette.labs[400]; case ArticleSpecial.SpecialReport: return sourcePalette.specialReport[700]; case ArticleSpecial.SpecialReportAlt: @@ -1484,8 +1511,6 @@ const followIconFillDark: PaletteFunction = ({ theme, design }) => { return sourcePalette.culture[500]; case Pillar.Lifestyle: return sourcePalette.lifestyle[500]; - case ArticleSpecial.Labs: - return sourcePalette.labs[300]; case ArticleSpecial.SpecialReport: return sourcePalette.specialReport[700]; case ArticleSpecial.SpecialReportAlt: @@ -2470,6 +2495,17 @@ const standfirstLinkTextDark: PaletteFunction = ({ design, theme }) => { }; const standfirstTextLight: PaletteFunction = (format) => { + if ( + format.theme === ArticleSpecial.Labs && + ![ + ArticleDesign.LiveBlog, + ...labsMediaDesigns, + ...labsHostedDesigns, + ].includes(format.design) + ) { + return sourcePalette.labs[100]; + } + switch (format.design) { case ArticleDesign.LiveBlog: return sourcePalette.neutral[100]; @@ -2497,6 +2533,13 @@ const standfirstTextLight: PaletteFunction = (format) => { }; const standfirstTextDark: PaletteFunction = ({ design, display, theme }) => { + if ( + theme === ArticleSpecial.Labs && + ![ArticleDesign.LiveBlog, ...labsHostedDesigns].includes(design) + ) { + return sourcePalette.labs[500]; + } + switch (design) { case ArticleDesign.LiveBlog: case ArticleDesign.DeadBlog: @@ -2507,12 +2550,7 @@ const standfirstTextDark: PaletteFunction = ({ design, display, theme }) => { case ArticleDesign.Picture: case ArticleDesign.Video: case ArticleDesign.Audio: - switch (theme) { - case ArticleSpecial.Labs: - return sourcePalette.neutral[7]; - default: - return sourcePalette.neutral[86]; - } + return sourcePalette.neutral[86]; case ArticleDesign.Standard: case ArticleDesign.Review: case ArticleDesign.Explainer: @@ -3923,6 +3961,19 @@ const shareButtonCopiedLight: PaletteFunction = ({ design }) => { const shareButtonCopiedDark: PaletteFunction = () => sourcePalette.neutral[86]; const shareButtonLight: PaletteFunction = ({ design, theme, display }) => { + if ( + theme === ArticleSpecial.Labs && + ![ + ArticleDesign.LiveBlog, + ...labsGalleryDesigns, + ...labsMediaDesigns, + ArticleDesign.HostedArticle, + ArticleDesign.HostedVideo, + ].includes(design) + ) { + return sourcePalette.labs[200]; + } + switch (design) { case ArticleDesign.Gallery: case ArticleDesign.HostedGallery: @@ -3953,12 +4004,12 @@ const shareButtonLight: PaletteFunction = ({ design, theme, display }) => { switch (theme) { case Pillar.News: return sourcePalette.news[300]; - case ArticleSpecial.Labs: - return sourcePalette.neutral[7]; case ArticleSpecial.SpecialReport: return sourcePalette.specialReport[300]; case ArticleSpecial.SpecialReportAlt: return sourcePalette.specialReportAlt[100]; + case ArticleSpecial.Labs: + return sourcePalette.neutral[7]; default: return pillarPalette(theme, 400); } @@ -3990,6 +4041,19 @@ const shareButtonLight: PaletteFunction = ({ design, theme, display }) => { }; const shareButtonDark: PaletteFunction = ({ design, theme }) => { + if ( + theme === ArticleSpecial.Labs && + ![ + ArticleDesign.LiveBlog, + ...labsGalleryDesigns, + ...labsMediaDesigns, + ArticleDesign.HostedArticle, + ArticleDesign.HostedVideo, + ].includes(design) + ) { + return sourcePalette.labs[500]; + } + switch (design) { case ArticleDesign.Gallery: case ArticleDesign.HostedArticle: @@ -4971,6 +5035,16 @@ const seriesTitleBackgroundLight: PaletteFunction = ({ if (theme === ArticleSpecial.SpecialReport) { return sourcePalette.brandAlt[400]; } + if ( + theme === ArticleSpecial.Labs && + ![ + ArticleDesign.LiveBlog, + ...labsGalleryDesigns, + ...labsMediaDesigns, + ].includes(design) + ) { + return sourcePalette.labs[200]; + } switch (display) { case ArticleDisplay.Immersive: switch (theme) { @@ -4981,7 +5055,7 @@ const seriesTitleBackgroundLight: PaletteFunction = ({ case Pillar.Lifestyle: return pillarPalette(theme, 400); case ArticleSpecial.Labs: - return sourcePalette.labs[300]; + return sourcePalette.labs[200]; case ArticleSpecial.SpecialReportAlt: return sourcePalette.specialReportAlt[300]; } @@ -5036,6 +5110,12 @@ const seriesTitleBackgroundDark: PaletteFunction = ({ if (theme === ArticleSpecial.SpecialReport) { return sourcePalette.brandAlt[400]; } + if ( + theme === ArticleSpecial.Labs && + ![ArticleDesign.LiveBlog, ...labsMediaDesigns].includes(design) + ) { + return sourcePalette.labs[500]; + } switch (display) { case ArticleDisplay.Immersive: switch (theme) { @@ -5046,7 +5126,7 @@ const seriesTitleBackgroundDark: PaletteFunction = ({ case Pillar.Lifestyle: return pillarPalette(theme, 400); case ArticleSpecial.Labs: - return sourcePalette.labs[300]; + return sourcePalette.labs[500]; case ArticleSpecial.SpecialReportAlt: return sourcePalette.specialReportAlt[300]; } @@ -5084,10 +5164,11 @@ const sectionTitleBackgroundLight: PaletteFunction = ({ theme, display }) => { return 'transparent'; } }; -const seriesTitleTextLight: PaletteFunction = ({ theme, display, design }) => { - if (theme === ArticleSpecial.Labs && design !== ArticleDesign.LiveBlog) { - return sourcePalette.neutral[7]; - } +const seriesOrSectionTitleTextLight: PaletteFunction = ({ + theme, + display, + design, +}) => { if ( theme === ArticleSpecial.SpecialReportAlt && design !== ArticleDesign.LiveBlog && @@ -5192,7 +5273,32 @@ const seriesTitleTextLight: PaletteFunction = ({ theme, display, design }) => { return sourcePalette.neutral[7]; } }; -const seriesTitleTextDark: PaletteFunction = ({ design, theme, display }) => { + +/** Used by the series tag only; the article section link keeps `articleSectionLinkTextLight` */ +const seriesTitleTextLight: PaletteFunction = (format) => { + if ( + format.theme === ArticleSpecial.Labs && + ![ArticleDesign.LiveBlog, ...labsMediaDesigns].includes(format.design) + ) { + return sourcePalette.neutral[100]; + } + return seriesOrSectionTitleTextLight(format); +}; + +const articleSectionLinkTextLight: PaletteFunction = (format) => { + if ( + format.theme === ArticleSpecial.Labs && + format.design !== ArticleDesign.LiveBlog + ) { + return sourcePalette.neutral[7]; + } + return seriesOrSectionTitleTextLight(format); +}; +const seriesOrSectionTitleTextDark: PaletteFunction = ({ + design, + theme, + display, +}) => { if (display === ArticleDisplay.Immersive) return sourcePalette.neutral[100]; switch (design) { case ArticleDesign.Gallery: @@ -5229,12 +5335,12 @@ const seriesTitleTextDark: PaletteFunction = ({ design, theme, display }) => { case Pillar.Culture: case Pillar.Lifestyle: return pillarPalette(theme, 500); - case ArticleSpecial.Labs: - return sourcePalette.labs[400]; case ArticleSpecial.SpecialReport: return sourcePalette.specialReport[500]; case ArticleSpecial.SpecialReportAlt: return sourcePalette.specialReportAlt[700]; + case ArticleSpecial.Labs: + return sourcePalette.labs[400]; } case ArticleDesign.Comment: case ArticleDesign.Editorial: @@ -5245,12 +5351,12 @@ const seriesTitleTextDark: PaletteFunction = ({ design, theme, display }) => { case Pillar.Culture: case Pillar.Lifestyle: return pillarPalette(theme, 500); - case ArticleSpecial.Labs: - return sourcePalette.labs[400]; case ArticleSpecial.SpecialReport: return sourcePalette.specialReport[500]; case ArticleSpecial.SpecialReportAlt: return sourcePalette.specialReportAlt[300]; + case ArticleSpecial.Labs: + return sourcePalette.labs[400]; } case ArticleDesign.Picture: case ArticleDesign.Video: @@ -5273,6 +5379,25 @@ const seriesTitleTextDark: PaletteFunction = ({ design, theme, display }) => { } } }; + +/** Used by the series tag only; the article section link keeps `articleSectionLinkTextDark` */ +const seriesTitleTextDark: PaletteFunction = (format) => { + if ( + format.theme === ArticleSpecial.Labs && + ![ + ArticleDesign.LiveBlog, + ...labsGalleryDesigns, + ...labsMediaDesigns, + ].includes(format.design) + ) { + return sourcePalette.neutral[7]; + } + return seriesOrSectionTitleTextDark(format); +}; + +const articleSectionLinkTextDark: PaletteFunction = + seriesOrSectionTitleTextDark; + const seriesTitleMatchTextLight: PaletteFunction = (format) => { if ( format.design === ArticleDesign.MatchReport || @@ -6625,6 +6750,10 @@ const paletteColours = { light: articleSectionBorderLight, dark: articleSectionBorderDark, }, + '--article-section-link-text': { + light: articleSectionLinkTextLight, + dark: articleSectionLinkTextDark, + }, '--article-section-secondary-title': { light: articleSectionSecondaryTitleLight, dark: articleSectionSecondaryTitleDark,