diff --git a/dotcom-rendering/src/components/Masthead/Titlepiece/CustomSubNav.stories.tsx b/dotcom-rendering/src/components/Masthead/Titlepiece/CustomSubNav.stories.tsx
index 8e8800f4f12..570fdb1bae0 100644
--- a/dotcom-rendering/src/components/Masthead/Titlepiece/CustomSubNav.stories.tsx
+++ b/dotcom-rendering/src/components/Masthead/Titlepiece/CustomSubNav.stories.tsx
@@ -33,20 +33,51 @@ const customSubNav: CustomSubnav = {
pages: [],
};
+/** Distinct images per breakpoint so it's obvious which one is being served. */
+const customSubNavWithImages: CustomSubnav = {
+ ...customSubNav,
+ images: [
+ {
+ breakpoint: 'mobile',
+ platforms: ['web'],
+ imageSrc:
+ 'https://media.guim.co.uk/6537e163c9164d25ec6102641f6a04fa5ba76560/0_210_5472_3283/master/5472.jpg?width=740&height=140&quality=85&fit=crop&s=none',
+ },
+ {
+ breakpoint: 'tablet',
+ platforms: ['web'],
+ imageSrc:
+ 'https://media.guim.co.uk/56b42eef576bc04c820da710459acd91082bb37b/0_0_6720_4480/6720.jpg?width=980&height=140&quality=85&fit=crop&s=none',
+ },
+ {
+ breakpoint: 'desktop',
+ platforms: ['web'],
+ imageSrc:
+ 'https://media.guim.co.uk/c981848745e482e03e23b2ec9402e1f5c5bee6a6/102_73_3282_1848/2000.jpg?width=1300&height=140&quality=85&fit=crop&s=none',
+ },
+ ],
+};
+
const meta = {
component: CustomSubNav,
title: 'Components/Masthead/Titlepiece/CustomSubNav',
decorators: [
- (Story) => (
-
-
-
- ),
+ (Story, context) => {
+ const hasImage =
+ (context.args.customSubNav.images?.length ?? 0) > 0;
+ return (
+
+
+
+ );
+ },
],
render: (args) => ,
args: {
@@ -64,6 +95,14 @@ export const Front = {
args: { renderingPage: 'front' },
} satisfies Story;
+/** On fronts, a web image is shown per breakpoint; resize the viewport to switch between mobile/tablet/desktop. */
+export const FrontWithImage = {
+ args: {
+ renderingPage: 'front',
+ customSubNav: customSubNavWithImages,
+ },
+} satisfies Story;
+
export const Article = {
args: { renderingPage: 'article' },
parameters: {
diff --git a/dotcom-rendering/src/components/Masthead/Titlepiece/CustomSubNav.tsx b/dotcom-rendering/src/components/Masthead/Titlepiece/CustomSubNav.tsx
index fd6f63a1b27..4b3f54cd1ae 100644
--- a/dotcom-rendering/src/components/Masthead/Titlepiece/CustomSubNav.tsx
+++ b/dotcom-rendering/src/components/Masthead/Titlepiece/CustomSubNav.tsx
@@ -4,15 +4,22 @@
*/
import { css } from '@emotion/react';
import {
+ breakpoints,
from,
headlineBold28,
space,
textSans14,
textSansBold14,
} from '@guardian/source/foundations';
+import { grid } from '../../../grid';
+import { generateImageURL } from '../../../lib/image';
import { nestedOphanComponents } from '../../../lib/ophan-helpers';
import { palette as themePalette } from '../../../palette';
-import type { CustomSubnav, RenderingPage } from '../../../types/customSubnav';
+import type {
+ CustomSubnav,
+ CustomSubnavImage,
+ RenderingPage,
+} from '../../../types/customSubnav';
type Props = {
customSubNav: CustomSubnav;
@@ -88,6 +95,122 @@ const articleHeaderStyles = css`
border-right: 1px solid ${themePalette('--masthead-nav-lines')};
`;
+/**
+ * On fronts, when an image is present the subnav mirrors DirectoryPageNav: a
+ * fixed-width, centred grid (blue) with the image spanning the full width on the
+ * top row and the links beneath it. The surrounding row is white (set by the
+ * Titlepiece wrapper), so only this padded container shows blue.
+ */
+const imageNavStyles = css`
+ ${grid.paddedContainer}
+ position: relative;
+ background-color: ${themePalette('--masthead-nav-background')};
+`;
+
+const imageWrapperStyles = css`
+ ${grid.column.all}
+ grid-row: 1;
+ position: relative;
+ display: block;
+ border-bottom: 1px solid ${themePalette('--masthead-nav-lines')};
+`;
+
+const headerImageStyles = css`
+ display: block;
+ width: 100%;
+ height: 210px;
+ object-fit: cover;
+
+ ${from.tablet} {
+ height: 140px;
+ }
+`;
+
+const imageHeaderTextStyles = css`
+ ${headlineBold28}
+ position: absolute;
+ left: ${space[3]}px;
+ bottom: ${space[1]}px;
+ color: ${themePalette('--masthead-nav-link-text')};
+
+ ${from.mobileLandscape} {
+ left: ${space[5]}px;
+ }
+`;
+
+const imageListStyles = css`
+ ${grid.column.all}
+ grid-row: 2;
+ ${textSans14}
+ display: flex;
+ align-items: center;
+ column-gap: ${space[2]}px;
+ min-height: 28px;
+ padding: 0 ${space[3]}px;
+
+ ${from.mobileLandscape} {
+ padding: 0 ${space[5]}px;
+ }
+ ${from.tablet} {
+ min-height: 30px;
+ }
+`;
+
+/** Largest breakpoints first so the media queries cascade correctly. */
+const byBreakpointWidthDesc = (a: CustomSubnavImage, b: CustomSubnavImage) =>
+ breakpoints[b.breakpoint] - breakpoints[a.breakpoint];
+
+/**
+ * Builds a `1x, 2x` srcSet via the Fastly Image Optimiser so each breakpoint's
+ * image is served at the right width and pixel density (mirrors DirectoryPageNav).
+ */
+const buildSrcSet = ({ imageSrc, breakpoint }: CustomSubnavImage) =>
+ `${generateImageURL({
+ mainImage: imageSrc,
+ imageWidth: breakpoints[breakpoint],
+ resolution: 'low',
+ })}, ${generateImageURL({
+ mainImage: imageSrc,
+ imageWidth: breakpoints[breakpoint],
+ resolution: 'high',
+ })} 2x`;
+
+const HeaderImage = ({
+ images,
+ headerText,
+}: {
+ images: CustomSubnavImage[];
+ headerText: string;
+}) => {
+ const sorted = [...images].sort(byBreakpointWidthDesc);
+ /** Smallest breakpoint is the
fallback; the rest become s. */
+ const fallback = sorted.at(-1);
+ if (!fallback) {
+ return null;
+ }
+ return (
+
+ {sorted.slice(0, -1).map((image) => (
+
+ ))}
+
+
+ );
+};
+
/** Sets horizontal scrolling behaviour and removes the scrollbar */
const scrollableSubNavStyles = css`
overflow-x: scroll;
@@ -136,6 +259,63 @@ export const CustomSubNav = ({
hasPageSkin,
}: Props) => {
const isArticle = renderingPage === 'article';
+ /** DCR receives images for all platforms; only web images are rendered here. */
+ const webImages = (customSubNav.images ?? []).filter((image) =>
+ image.platforms.includes('web'),
+ );
+ const hasHeaderImage = !isArticle && webImages.length > 0;
+
+ const linkItems = customSubNav.links.map(({ linkText, dotcomPath }) => (
+
+
+ {linkText === currentNavLink ? (
+ {linkText}
+ ) : (
+ linkText
+ )}
+
+
+ ));
+
+ if (hasHeaderImage) {
+ return (
+
+
+
+
+ {customSubNav.header.headerText}
+
+
+
+
+ );
+ }
+
return (
);
diff --git a/dotcom-rendering/src/components/Titlepiece.island.tsx b/dotcom-rendering/src/components/Titlepiece.island.tsx
index 3a49e2971ce..cba079f21f3 100644
--- a/dotcom-rendering/src/components/Titlepiece.island.tsx
+++ b/dotcom-rendering/src/components/Titlepiece.island.tsx
@@ -2,6 +2,7 @@ import { css, Global } from '@emotion/react';
import {
from,
headlineBold14,
+ palette as sourcePalette,
space,
until,
visuallyHidden,
@@ -316,6 +317,14 @@ const fadeStyles = css`
${themePalette('--masthead-nav-background')} 100%
);
`;
+
+/** For the image custom subnav the row is white and full-bleed; CustomSubNav
+ * centres its own blue padded container on top (mirrors DirectoryPageNav). */
+const customSubnavImageWrapper = css`
+ grid-column: viewport-start / viewport-end;
+ grid-row: 3;
+ background-color: ${sourcePalette.neutral[100]};
+`;
export const Titlepiece = ({
nav,
editionId,
@@ -327,6 +336,13 @@ export const Titlepiece = ({
}: Props) => {
const { showBanner } = useEditionSwitcherBanner(pageId, editionId);
+ const hasCustomSubnavImage =
+ customSubnav?.renderingPage === 'front' &&
+ (customSubnav.data.images?.some((image) =>
+ image.platforms.includes('web'),
+ ) ??
+ false);
+
return (
{
}
};
-const isCodeGridUrl = (url: URL) => url.hostname === 'media.guimcode.co.uk';
+const isCodeGridUrl = (url: URL) =>
+ url.hostname === 'media.guimcode.co.uk' ||
+ url.hostname === 'uploads.guimcode.co.uk';
/**
* Generates a URL for calling the Fastly Image Optimiser.
diff --git a/dotcom-rendering/src/model/newsletter-page-schema.json b/dotcom-rendering/src/model/newsletter-page-schema.json
index a01a2a4d791..0ad257140fc 100644
--- a/dotcom-rendering/src/model/newsletter-page-schema.json
+++ b/dotcom-rendering/src/model/newsletter-page-schema.json
@@ -497,19 +497,42 @@
"type": "string"
},
"breakpoint": {
- "enum": [
- "mobile",
- "tablet",
- "web"
- ],
- "type": "string"
+ "$ref": "#/definitions/CustomSubnavImageBreakpoint"
+ },
+ "platforms": {
+ "type": "array",
+ "items": {
+ "$ref": "#/definitions/CustomSubnavImagePlatform"
+ }
}
},
"required": [
"breakpoint",
- "imageSrc"
+ "imageSrc",
+ "platforms"
]
},
+ "CustomSubnavImageBreakpoint": {
+ "enum": [
+ "desktop",
+ "leftCol",
+ "mobile",
+ "mobileLandscape",
+ "mobileMedium",
+ "phablet",
+ "tablet",
+ "wide"
+ ],
+ "type": "string"
+ },
+ "CustomSubnavImagePlatform": {
+ "enum": [
+ "android",
+ "ios",
+ "web"
+ ],
+ "type": "string"
+ },
"ReaderRevenuePositions": {
"type": "object",
"properties": {
diff --git a/dotcom-rendering/src/types/customSubnav.ts b/dotcom-rendering/src/types/customSubnav.ts
index d811bce2233..8ce07f183b8 100644
--- a/dotcom-rendering/src/types/customSubnav.ts
+++ b/dotcom-rendering/src/types/customSubnav.ts
@@ -15,9 +15,22 @@ export interface CustomSubnavHeader {
dotcomPath?: string;
copy: string;
}
+export type CustomSubnavImageBreakpoint =
+ | 'mobile'
+ | 'mobileMedium'
+ | 'mobileLandscape'
+ | 'phablet'
+ | 'tablet'
+ | 'desktop'
+ | 'leftCol'
+ | 'wide';
+
+export type CustomSubnavImagePlatform = 'ios' | 'web' | 'android';
+
export interface CustomSubnavImage {
imageSrc: string;
- breakpoint: 'mobile' | 'tablet' | 'web';
+ breakpoint: CustomSubnavImageBreakpoint;
+ platforms: CustomSubnavImagePlatform[];
}
export interface CustomSubnavTargetedPage {