diff --git a/dotcom-rendering/src/components/ArticleBody.tsx b/dotcom-rendering/src/components/ArticleBody.tsx
index 380c74b692a..7b4af5ed594 100644
--- a/dotcom-rendering/src/components/ArticleBody.tsx
+++ b/dotcom-rendering/src/components/ArticleBody.tsx
@@ -58,6 +58,7 @@ type Props = {
serverTime?: number;
idApiUrl?: string;
accentColor?: string;
+ isOldInteractive?: boolean;
};
const globalOlStyles = () => css`
@@ -165,6 +166,7 @@ export const ArticleBody = ({
serverTime,
idApiUrl,
accentColor,
+ isOldInteractive = false,
}: Props) => {
const isInteractiveContent =
format.design === ArticleDesign.Interactive ||
@@ -292,6 +294,7 @@ export const ArticleBody = ({
contributionsServiceUrl={contributionsServiceUrl}
shouldHideAds={shouldHideAds}
idApiUrl={idApiUrl}
+ isOldInteractive={isOldInteractive}
/>
{hasObserverPublicationTag && }
diff --git a/dotcom-rendering/src/components/Figure.tsx b/dotcom-rendering/src/components/Figure.tsx
index ccb113fb4b1..d69c3414b1b 100644
--- a/dotcom-rendering/src/components/Figure.tsx
+++ b/dotcom-rendering/src/components/Figure.tsx
@@ -1,5 +1,5 @@
import { css } from '@emotion/react';
-import { breakpoints, from, space, until } from '@guardian/source/foundations';
+import { from, space, until } from '@guardian/source/foundations';
import { ArticleDesign, type ArticleFormat } from '../lib/articleFormat';
import type { FEElement, RoleType } from '../types/content';
@@ -74,47 +74,7 @@ const roleCss = {
margin-top: ${space[3]}px;
margin-bottom: ${space[3]}px;
- ${until.tablet} {
- margin-left: -20px;
- margin-right: -20px;
- }
- ${until.mobileLandscape} {
- margin-left: -10px;
- margin-right: -10px;
- }
- ${from.tablet} {
- --scrollbar-width-fallback: 15px;
- --half-scrollbar-width-fallback: 7.5px;
-
- width: calc(
- 100vw - var(--scrollbar-width, var(--scrollbar-width-fallback))
- );
- max-width: calc(
- 100vw - var(--scrollbar-width, var(--scrollbar-width-fallback))
- );
-
- --grid-container-max-width: 740px;
- --grid-container-left-margin: calc(
- ((-100vw + (var(--grid-container-max-width) - 42px)) / 2) +
- var(
- --half-scrollbar-width,
- var(--half-scrollbar-width-fallback)
- )
- );
-
- margin-left: var(--grid-container-left-margin);
- }
- ${from.desktop} {
- --grid-container-max-width: ${breakpoints.desktop}px;
- }
- ${from.leftCol} {
- --grid-container-max-width: ${breakpoints.leftCol}px;
- --grid-left-col-width: 140px;
- }
- ${from.wide} {
- --grid-container-max-width: ${breakpoints.wide}px;
- --grid-left-col-width: 219px;
- }
+ grid-column: 1 / -1;
`,
showcase: css`
diff --git a/dotcom-rendering/src/components/SubMeta.tsx b/dotcom-rendering/src/components/SubMeta.tsx
index 24d1b24196a..8f594eab135 100644
--- a/dotcom-rendering/src/components/SubMeta.tsx
+++ b/dotcom-rendering/src/components/SubMeta.tsx
@@ -129,6 +129,7 @@ type Props = {
webUrl: string;
webTitle: string;
showBottomSocialButtons: boolean;
+ isDeprecatedInteractiveLayout?: boolean;
};
const syndicationButtonOverrides = css`
@@ -204,6 +205,7 @@ export const SubMeta = ({
webUrl,
webTitle,
showBottomSocialButtons,
+ isDeprecatedInteractiveLayout = false,
}: Props) => {
const createLinks = () => {
const links: BaseLinkType[] = [];
@@ -228,9 +230,7 @@ export const SubMeta = ({
{
const notSupported =
Not supported
;
const format = {
@@ -43,6 +46,7 @@ const DecideLayoutApps = ({ article, renderingTarget }: AppProps) => {
};
const serverTime = article.serverTime;
+ const publicationDate = new Date(article.frontendData.webPublicationDate);
switch (article.display) {
case ArticleDisplay.Immersive: {
@@ -116,15 +120,24 @@ const DecideLayoutApps = ({ article, renderingTarget }: AppProps) => {
default: {
switch (article.design) {
case ArticleDesign.Interactive:
- return (
-
- );
-
+ if (publicationDate < interactiveLayoutSwitchoverDate) {
+ return (
+
+ );
+ } else {
+ return (
+
+ );
+ }
case ArticleDesign.FullPageInteractive: {
return (
{
};
const serverTime = article.serverTime;
+ const publicationDate = new Date(article.frontendData.webPublicationDate);
switch (article.display) {
case ArticleDisplay.Immersive: {
@@ -293,15 +307,26 @@ const DecideLayoutWeb = ({ article, NAV, renderingTarget }: WebProps) => {
default: {
switch (article.design) {
case ArticleDesign.Interactive:
- return (
-
- );
+ if (publicationDate < interactiveLayoutSwitchoverDate) {
+ return (
+
+ );
+ } else {
+ return (
+
+ );
+ }
case ArticleDesign.FullPageInteractive: {
return (
(
-
- {children}
-
-);
-
-const maxWidth = css`
- ${from.desktop} {
- max-width: 620px;
- }
-`;
-
const stretchLines = css`
${until.phablet} {
margin-left: -20px;
@@ -181,38 +73,63 @@ const stretchLines = css`
}
`;
-export const temporaryBodyCopyColourOverride = css`
- .content__main-column--interactive p {
- /* stylelint-disable-next-line declaration-no-important */
- color: ${themePalette('--article-text')} !important;
- }
-`;
+interface GridItemProps {
+ area: Area;
+ layoutType: LayoutType;
+ element?: 'div' | 'aside';
+ customCss?: SerializedStyles;
+ children: React.ReactNode;
+}
-interface CommonProps {
+const GridItem = ({
+ area,
+ layoutType,
+ element: Element = 'div',
+ customCss,
+ children,
+}: GridItemProps) => (
+
+ {children}
+
+);
+
+interface Props {
article: ArticleDeprecated;
format: ArticleFormat;
renderingTarget: RenderingTarget;
serverTime?: number;
}
-interface WebProps extends CommonProps {
+interface WebProps extends Props {
NAV: NavType;
renderingTarget: 'Web';
}
-interface AppsProps extends CommonProps {
+interface AppProps extends Props {
renderingTarget: 'Apps';
}
-export const InteractiveLayout = (props: WebProps | AppsProps) => {
+export const InteractiveLayout = (props: WebProps | AppProps) => {
const { article, format, renderingTarget, serverTime } = props;
const {
config: { isPaidContent, host, hasSurveyAd },
editionId,
} = article;
- const isApps = renderingTarget === 'Apps';
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;
@@ -222,23 +139,8 @@ export const InteractiveLayout = (props: WebProps | AppsProps) => {
const renderAds = canRenderAds(article);
- const includesFullWidthElement = article.blocks.some((block) =>
- block.elements.some((element) => {
- const role =
- 'role' in element
- ? (element.role as RoleType | 'fullWidth' | undefined)
- : undefined;
- return role === 'fullWidth';
- }),
- );
-
return (
<>
- {includesFullWidthElement && (
-
-
-
- )}
{isApps && (
<>
@@ -247,367 +149,401 @@ export const InteractiveLayout = (props: WebProps | AppsProps) => {
-
>
)}
- {article.isLegacyInteractive && (
-
- )}
{isWeb && (
- <>
-
- {renderAds && (
-
-
-
-
-
- )}
-
-
tag.id)}
- sectionId={article.config.section}
- contentType={article.contentType}
- />
-
-
- {format.theme === ArticleSpecial.Labs && (
-
+
+ {renderAds && (
+
)}
+ tag.id)}
+ sectionId={article.config.section}
+ contentType={article.contentType}
+ />
+
+ )}
- {renderAds && hasSurveyAd && (
-
- )}
- >
+ {format.theme === ArticleSpecial.Labs && (
+
+
+
+ )}
+
+ {isWeb && renderAds && hasSurveyAd && (
+
)}
+
+ {isApps && renderAds && (
+
+
+
+ )}
-
-
+
+
+
-
-
-
-
-
-
-
-
-
+
+
+
+
+
+
+
+
+
+ {isWeb &&
+ format.theme === ArticleSpecial.Labs &&
+ format.design !== ArticleDesign.Video ? (
+
+ ) : (
+
+ )}
+
+ {isApps ? (
+ <>
+
+
-
-
-
- {format.theme === ArticleSpecial.Labs ? (
- <>>
- ) : (
-
- )}
-
-
-
-
-
-
-
-
-
-
-
-
- {isApps ? (
- <>
-
-
-
-
-
-
- >
- ) : (
-
- )}
-
-
-
-
-
+
+
+
-
-
-
-
-
-
-
-
-
-
+ )}
+
+ >
+ ) : (
+
+ )}
+
+
+ {/* Only show Listen to Article button on App landscape views */}
+ {isApps && (
+
+
+
+
+
+
+
+ )}
+
+
-
-
-
-
+ )}
+
+ {showBodyEndSlot && (
+
+
+
+ )}
+
+
+
+
-
-
-
+ >
+
+
+
+
+
+
+
{isWeb && renderAds && (
{
{article.storyPackage && (
{
webURL={article.webURL}
/>
-
{showComments && (
{
'--article-section-background',
)}
borderColour={themePalette('--article-border')}
- fontColour={themePalette('--article-section-title')}
>
{
)}
-
- {isWeb && props.NAV.subNavSections && (
-
- )}
-
{isWeb && (
<>
+ {props.NAV.subNavSections && (
+
+ )}
{
editionId={article.editionId}
/>
-
{
!!article.config.switches.remoteBanner
}
tags={article.tags}
+ host={host}
/>
@@ -817,19 +755,22 @@ export const InteractiveLayout = (props: WebProps | AppsProps) => {
/>
>
)}
+
{isApps && (
-
+ <>
+
+ >
)}
>
);
diff --git a/dotcom-rendering/src/layouts/InteractiveLayoutDeprecated.tsx b/dotcom-rendering/src/layouts/InteractiveLayoutDeprecated.tsx
new file mode 100644
index 00000000000..6141e64a78a
--- /dev/null
+++ b/dotcom-rendering/src/layouts/InteractiveLayoutDeprecated.tsx
@@ -0,0 +1,838 @@
+import { css, Global } from '@emotion/react';
+import {
+ from,
+ palette as sourcePalette,
+ until,
+} from '@guardian/source/foundations';
+import { Hide } from '@guardian/source/react-components';
+import { StraightLines } from '@guardian/source-development-kitchen/react-components';
+import type React from 'react';
+import { AdSlot, MobileStickyContainer } from '../components/AdSlot.web';
+import { AppsFooter } from '../components/AppsFooter.island';
+import { ArticleBody } from '../components/ArticleBody';
+import { ArticleContainer } from '../components/ArticleContainer';
+import { ArticleHeadline } from '../components/ArticleHeadline';
+import { ArticleMetaApps } from '../components/ArticleMeta.apps';
+import { ArticleMeta } from '../components/ArticleMeta.web';
+import { ArticleTitle } from '../components/ArticleTitle';
+import { Border } from '../components/Border';
+import { Carousel } from '../components/Carousel.island';
+import { DecideLines } from '../components/DecideLines';
+import { DirectoryPageNav } from '../components/DirectoryPageNav';
+import { DiscussionLayout } from '../components/DiscussionLayout';
+import { Footer } from '../components/Footer';
+import { GridItem } from '../components/GridItem';
+import { HeaderAdSlot } from '../components/HeaderAdSlot';
+import { InteractivesDisableArticleSwipe } from '../components/InteractivesDisableArticleSwipe.island';
+import { InteractivesNativePlatformWrapper } from '../components/InteractivesNativePlatformWrapper.island';
+import { InteractivesScrollbarWidth } from '../components/InteractivesScrollbarWidth.island';
+import { Island } from '../components/Island';
+import { LabsHeader } from '../components/LabsHeader';
+import { MainMedia } from '../components/MainMedia';
+import { Masthead } from '../components/Masthead/Masthead';
+import { MostViewedFooterData } from '../components/MostViewedFooterData.island';
+import { MostViewedFooterLayout } from '../components/MostViewedFooterLayout';
+import { OnwardsUpper } from '../components/OnwardsUpper.island';
+import { Section } from '../components/Section';
+import { SlotBodyEnd } from '../components/SlotBodyEnd.island';
+import { Standfirst } from '../components/Standfirst';
+import { StickyBottomBanner } from '../components/StickyBottomBanner.island';
+import { SubMeta } from '../components/SubMeta';
+import { SubNav } from '../components/SubNav.island';
+import { type ArticleFormat, ArticleSpecial } from '../lib/articleFormat';
+import { canRenderAds } from '../lib/canRenderAds';
+import { getContributionsServiceUrl } from '../lib/contributions';
+import { decideStoryPackageTrails } from '../lib/decideTrail';
+import type { NavType } from '../model/extract-nav';
+import { palette as themePalette } from '../palette';
+import type { ArticleDeprecated } from '../types/article';
+import type { RoleType } from '../types/content';
+import type { RenderingTarget } from '../types/renderingTarget';
+import {
+ interactiveGlobalStyles,
+ interactiveLegacyClasses,
+} from './lib/interactiveLegacyStyling';
+import { BannerWrapper, Stuck } from './lib/stickiness';
+
+const InteractiveGrid = ({ children }: { children: React.ReactNode }) => (
+
+ {children}
+
+);
+
+const maxWidth = css`
+ ${from.desktop} {
+ max-width: 620px;
+ }
+`;
+
+const stretchLines = css`
+ ${until.phablet} {
+ margin-left: -20px;
+ margin-right: -20px;
+ }
+ ${until.mobileLandscape} {
+ margin-left: -10px;
+ margin-right: -10px;
+ }
+`;
+
+export const temporaryBodyCopyColourOverride = css`
+ .content__main-column--interactive p {
+ /* stylelint-disable-next-line declaration-no-important */
+ color: ${themePalette('--article-text')} !important;
+ }
+`;
+
+interface CommonProps {
+ article: ArticleDeprecated;
+ format: ArticleFormat;
+ renderingTarget: RenderingTarget;
+ serverTime?: number;
+}
+
+interface WebProps extends CommonProps {
+ NAV: NavType;
+ renderingTarget: 'Web';
+}
+
+interface AppsProps extends CommonProps {
+ renderingTarget: 'Apps';
+}
+
+export const InteractiveLayoutDeprecated = (props: WebProps | AppsProps) => {
+ const { article, format, renderingTarget, serverTime } = props;
+ const {
+ config: { isPaidContent, host, hasSurveyAd },
+ editionId,
+ } = article;
+
+ const isApps = renderingTarget === 'Apps';
+ const isWeb = renderingTarget === 'Web';
+
+ const showComments = article.isCommentable && !isPaidContent;
+
+ const { branding } = article.commercialProperties[article.editionId];
+
+ const contributionsServiceUrl = getContributionsServiceUrl(article);
+
+ const renderAds = canRenderAds(article);
+
+ const includesFullWidthElement = article.blocks.some((block) =>
+ block.elements.some((element) => {
+ const role =
+ 'role' in element
+ ? (element.role as RoleType | 'fullWidth' | undefined)
+ : undefined;
+ return role === 'fullWidth';
+ }),
+ );
+
+ return (
+ <>
+ {includesFullWidthElement && (
+
+
+
+ )}
+ {isApps && (
+ <>
+
+
+
+
+
+
+
+ >
+ )}
+ {article.isLegacyInteractive && (
+
+ )}
+ {isWeb && (
+ <>
+
+ {renderAds && (
+
+
+
+
+
+ )}
+
+
tag.id)}
+ sectionId={article.config.section}
+ contentType={article.contentType}
+ />
+
+
+ {format.theme === ArticleSpecial.Labs && (
+
+
+
+ )}
+
+ {renderAds && hasSurveyAd && (
+
+ )}
+ >
+ )}
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ {format.theme === ArticleSpecial.Labs ? (
+ <>>
+ ) : (
+
+ )}
+
+
+
+
+
+
+
+
+
+
+
+
+ {isApps ? (
+ <>
+
+
+
+
+
+
+ >
+ ) : (
+
+ )}
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ {isWeb && renderAds && (
+
+ )}
+
+ {article.storyPackage && (
+
+ )}
+
+
+
+
+
+ {showComments && (
+
+ )}
+
+ {!isPaidContent && (
+
+ )}
+
+ {isWeb && renderAds && (
+
+ )}
+
+
+ {isWeb && props.NAV.subNavSections && (
+
+ )}
+
+ {isWeb && (
+ <>
+
+
+
+
+
+
+
+
+ >
+ )}
+ {isApps && (
+
+ )}
+ >
+ );
+};
diff --git a/dotcom-rendering/src/layouts/lib/furnitureArrangements.ts b/dotcom-rendering/src/layouts/lib/furnitureArrangements.ts
new file mode 100644
index 00000000000..229d620e6f7
--- /dev/null
+++ b/dotcom-rendering/src/layouts/lib/furnitureArrangements.ts
@@ -0,0 +1,108 @@
+import { css, type SerializedStyles } from '@emotion/react';
+import { from, until } from '@guardian/source/foundations';
+import { grid } from '../../grid';
+
+export type LayoutType = 'standard';
+
+export type Area =
+ | 'title'
+ | 'headline'
+ | 'standfirst'
+ | 'media'
+ | 'meta'
+ | 'body'
+ | 'right-column';
+
+type Breakpoint = 'mobile' | 'tablet' | 'desktop' | 'leftCol';
+
+const breakpointQueries: Record = {
+ mobile: until.tablet,
+ tablet: from.tablet,
+ desktop: from.desktop,
+ leftCol: from.leftCol,
+};
+
+// Raw CSS overrides per area per breakpoint. Entries are only needed when an area
+// deviates from the default: centre column, single-column mobile layout with areas
+// in DOM order (media → title → headline → standfirst → meta → body → right-column).
+
+type AreaCss = Partial>;
+type LayoutCssMap = Partial>;
+
+const standardCss: LayoutCssMap = {
+ title: {
+ tablet: 'grid-row: 1;',
+ leftCol:
+ 'grid-row: 1; grid-column: left-column-start / left-column-end;',
+ },
+ headline: {
+ tablet: 'grid-row: 2;',
+ leftCol: 'grid-row: 1;',
+ },
+ standfirst: {
+ tablet: 'grid-row: 3;',
+ leftCol: 'grid-row: 2;',
+ },
+ media: {
+ tablet: 'grid-row: 4;',
+ leftCol: 'grid-row: 3;',
+ },
+ meta: {
+ tablet: 'grid-row: 5;',
+ leftCol:
+ 'grid-row: 3 / span 2; grid-column: left-column-start / left-column-end;',
+ },
+ body: {
+ mobile: grid.column.all,
+ tablet: `grid-row: 6; ${grid.column.all}`,
+ leftCol: 'grid-row: 4;',
+ },
+ 'right-column': {
+ desktop:
+ 'grid-row: 1 / span 6; grid-column: right-column-start / right-column-end;',
+ leftCol:
+ 'grid-row: 1 / span 4; grid-column: right-column-start / right-column-end;',
+ },
+};
+
+const layoutCssMaps: Record = {
+ standard: standardCss,
+};
+
+/**
+ * Returns the Emotion CSS needed to position a single grid item — its
+ * default column, its row at each breakpoint, and any column overrides.
+ * The grid item _must_ be inside a {@link grid} module container.
+ *
+ * All items default to the centre column. Per-breakpoint overrides for
+ * `grid-row` and `grid-column` are applied on top via media queries,
+ * looked up from the plain CSS maps defined in this file.
+ *
+ * @param area - The named piece of article furniture to position (e.g. `'headline'`, `'body'`).
+ * @param layoutType - See {@link LayoutType}. Determines which CSS map to use for lookups.
+ *
+ * @example
+ * // In a React component:
+ *
+ */
+export const gridItemCss = (
+ area: Area,
+ layoutType: LayoutType,
+): SerializedStyles => {
+ const areaOverrides = layoutCssMaps[layoutType][area] ?? {};
+
+ const breakpointCss = Object.entries(areaOverrides).map(
+ ([bp, styles]) => css`
+ ${breakpointQueries[bp as Breakpoint]} {
+ ${styles}
+ }
+ `,
+ );
+
+ // All items default to the centre column; breakpoint entries above
+ // override grid-row and grid-column as needed.
+ return css`
+ grid-column: centre-column-start / centre-column-end;
+ ${breakpointCss}
+ `;
+};
diff --git a/dotcom-rendering/src/lib/ArticleRenderer.tsx b/dotcom-rendering/src/lib/ArticleRenderer.tsx
index 20cc9d78058..b6f36284f39 100644
--- a/dotcom-rendering/src/lib/ArticleRenderer.tsx
+++ b/dotcom-rendering/src/lib/ArticleRenderer.tsx
@@ -1,5 +1,6 @@
import { css } from '@emotion/react';
import { useConfig } from '../components/ConfigContext';
+import { grid } from '../grid';
import { interactiveLegacyClasses } from '../layouts/lib/interactiveLegacyStyling';
import type { ServerSideTests, Switches } from '../types/config';
import type { FEElement } from '../types/content';
@@ -37,6 +38,7 @@ type Props = {
contributionsServiceUrl: string;
shouldHideAds: boolean;
idApiUrl?: string;
+ isOldInteractive?: boolean;
};
export const ArticleRenderer = ({
@@ -61,6 +63,7 @@ export const ArticleRenderer = ({
contributionsServiceUrl,
shouldHideAds,
idApiUrl,
+ isOldInteractive = false,
}: Props) => {
const isSectionedMiniProfilesArticle =
elements.filter(
@@ -106,6 +109,13 @@ export const ArticleRenderer = ({
// ^^ Until we decide where to do the "isomorphism split" in this this code is not safe here.
// But should be soon.
+ const interactiveLayoutCSS = css`
+ ${grid.container}
+ > * {
+ ${grid.column.centre}
+ }
+ `;
+
return (
{renderingTarget === 'Apps'
? renderedElements