diff --git a/dotcom-rendering/src/grid.ts b/dotcom-rendering/src/grid.ts
index 8121c5c5397..a4995a81f42 100644
--- a/dotcom-rendering/src/grid.ts
+++ b/dotcom-rendering/src/grid.ts
@@ -1,9 +1,11 @@
// ----- Imports ----- //
import {
+ between as betweenBreakpoint,
breakpoints,
from as fromBreakpoint,
} from '@guardian/source/foundations';
+import { palette as themePalette } from './palette';
// ----- Columns & Lines ----- //
@@ -83,25 +85,82 @@ const paddedContainer = `
}
`;
-// ----- API ----- //
+// ----- Vertical Rules ----- //
+
+type VerticalRuleOptions = {
+ centre?: boolean;
+};
/**
- * Ask the element to span all grid columns between two grid lines. The lines
- * can be specified either by `Line` name or by number.
- * @param from The grid line to start from, either a `Line` name or a number.
- * @param to The grid line to end at, either a `Line` name or a number.
- * @returns {string} CSS to place the element on the grid.
+ * Render Guardian grid vertical rules.
*
- * @example
Will place the element in the centre column.
- * const styles = css`
- * ${grid.between('centre-column-start', 'centre-column-end')}
- * `;
+ * Left and right rules are always present.
+ * A centre rule can optionally be enabled.
*
- * @example Will place the element between lines 3 and 5.
- * const styles = css`
- * ${grid.between(3, 5)}
- * `;
+ * Usage:
+ * css([grid.container, grid.verticalRules()])
+ * css([grid.container, grid.verticalRules({ centre: true })])
*/
+const verticalRules = (options: VerticalRuleOptions = {}): string => `
+ ${fromBreakpoint.tablet} {
+ position: relative;
+
+ --centre-transform: translateX(-${columnGap});
+
+ ${fromBreakpoint.leftCol} {
+ --centre-transform: translateX(calc(${columnGap} / -2));
+ }
+
+ &::before,
+ &::after
+ ${options.centre ? ', & > *:first-child::before' : ''} {
+ position: absolute;
+ top: 0;
+ bottom: 0;
+ width: 1px;
+ background-color: ${themePalette('--article-border')};
+ pointer-events: none;
+ content: '';
+ }
+
+ /* LEFT OUTER RULE */
+ &::before {
+ grid-column: centre-column-start;
+ justify-self: start;
+ transform: translateX(-${columnGap});
+
+ ${fromBreakpoint.leftCol} {
+ grid-column: left-column-start;
+ }
+ }
+
+ /* RIGHT OUTER RULE */
+ &::after {
+ grid-column: right-column-end;
+ justify-self: start;
+ transform: translateX(-1px);
+
+ ${betweenBreakpoint.tablet.and.desktop} {
+ grid-column: centre-column-end;
+ }
+ }
+
+ ${
+ options.centre
+ ? `
+ /* CENTRE RULE */
+ & > *:first-child::before {
+ grid-column: centre-column-start;
+ justify-self: start;
+ transform: var(--centre-transform);
+ }`
+ : ''
+ }
+ }
+`;
+
+// ----- API ----- //
+
const between = (from: Line | number, to: Line | number): string => `
grid-column: ${from} / ${to};
`;
@@ -182,6 +241,8 @@ const grid = {
* breakpoint.
*/
mobileColumnGap,
+
+ verticalRules,
} as const;
// ----- Exports ----- //
diff --git a/dotcom-rendering/src/layouts/DecideLayout.tsx b/dotcom-rendering/src/layouts/DecideLayout.tsx
index 57edc35d105..6c958ab25e9 100644
--- a/dotcom-rendering/src/layouts/DecideLayout.tsx
+++ b/dotcom-rendering/src/layouts/DecideLayout.tsx
@@ -14,7 +14,6 @@ import { InteractiveLayout } from './InteractiveLayout';
import { LiveLayout } from './LiveLayout';
import { NewsletterSignupLayout } from './NewsletterSignupLayout';
import { PictureLayout } from './PictureLayout';
-import { ShowcaseLayout } from './ShowcaseLayout';
import { StandardLayout } from './StandardLayout';
interface BaseProps {
@@ -102,7 +101,7 @@ const DecideLayoutApps = ({ article, renderingTarget }: AppProps) => {
);
default:
return (
- {
);
default:
return (
- (
-
- {children}
-
-);
-
-const maxWidth = css`
- ${from.desktop} {
- max-width: 620px;
- }
-`;
-
-const fullHeight = css`
- height: 100%;
-`;
-
-const stretchLines = css`
- ${until.phablet} {
- margin-left: -20px;
- margin-right: -20px;
- }
- ${until.mobileLandscape} {
- margin-left: -10px;
- margin-right: -10px;
- }
-`;
-const mainMediaWrapper = css`
- position: relative;
-`;
-
-const PositionHeadline = ({
- design,
- children,
-}: {
- design: ArticleDesign;
- children: React.ReactNode;
-}) => {
- switch (design) {
- case ArticleDesign.Interview:
- return (
-
- );
- default:
- return {children}
;
- }
-};
-
-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 ShowcaseLayout = (props: WebProps | AppsProps) => {
- const { article, format, renderingTarget, serverTime } = props;
- const {
- config: { isPaidContent, host, hasSurveyAd },
- editionId,
- } = article;
- const isWeb = renderingTarget === 'Web';
- const isApps = renderingTarget === 'Apps';
-
- const showBodyEndSlot =
- isWeb &&
- (parse(article.slotMachineFlags ?? '').showBodyEnd ||
- article.config.switches.slotBodyEnd);
-
- // TODO:
- // 1) Read 'forceEpic' value from URL parameter and use it to force the slot to render
- // 2) Otherwise, ensure slot only renders if `article.config.shouldHideReaderRevenue` equals false.
-
- const showComments = article.isCommentable && !isPaidContent;
-
- const { branding } = article.commercialProperties[article.editionId];
-
- const contributionsServiceUrl = getContributionsServiceUrl(article);
-
- const renderAds = canRenderAds(article);
-
- const isLabs = format.theme === ArticleSpecial.Labs;
-
- return (
- <>
- {isWeb && (
- <>
- {!isLabs ? (
-
- {renderAds && (
-
-
-
- )}
-
-
-
- ) : (
- // Else, this is a labs article so just show Nav and the Labs header
- <>
-
- {renderAds && (
-
-
-
- )}
-
-
-
-
-
-
-
- >
- )}
- >
- )}
-
- {isWeb && renderAds && hasSurveyAd && (
-
- )}
-
-
- {isApps && renderAds && (
-
-
-
- )}
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
- {/* Only show Listen to Article button on App landscape views */}
- {isApps && (
-
-
-
-
-
-
-
- )}
-
-
-
-
- {isApps ? (
- <>
-
-
-
-
-
- {!!article.affiliateLinksDisclaimer && (
-
- )}
-
- >
- ) : (
- <>
-
- {!!article.affiliateLinksDisclaimer && (
-
- )}
- >
- )}
-
-
-
-
-
- {showBodyEndSlot && (
-
-
-
- )}
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
- {isWeb && renderAds && !isLabs && (
-
- )}
-
- {article.storyPackage && (
-
- )}
-
-
-
-
-
- {showComments && (
-
- )}
-
- {!isPaidContent && (
-
- )}
-
- {isWeb && renderAds && !isLabs && (
-
- )}
-
- {isWeb && props.NAV.subNavSections && (
-
- )}
- {isWeb && (
- <>
-
-
-
-
-
-
-
-
- >
- )}
- {isApps && (
-
- )}
- >
- );
-};
diff --git a/dotcom-rendering/src/layouts/StandardLayout.tsx b/dotcom-rendering/src/layouts/StandardLayout.tsx
index ebe454b335d..07b1a9a741f 100644
--- a/dotcom-rendering/src/layouts/StandardLayout.tsx
+++ b/dotcom-rendering/src/layouts/StandardLayout.tsx
@@ -1,4 +1,4 @@
-import { css } from '@emotion/react';
+import { css, type SerializedStyles } from '@emotion/react';
import { log } from '@guardian/libs';
import {
from,
@@ -30,7 +30,6 @@ import { Footer } from '../components/Footer';
import { GetMatchNav } from '../components/GetMatchNav.importable';
import { GetMatchStats } from '../components/GetMatchStats.importable';
import { GetMatchTabs } from '../components/GetMatchTabs.importable';
-import { GridItem } from '../components/GridItem';
import { GuardianLabsLines } from '../components/GuardianLabsLines';
import { HeaderAdSlot } from '../components/HeaderAdSlot';
import { Island } from '../components/Island';
@@ -42,15 +41,16 @@ import { MostViewedFooterData } from '../components/MostViewedFooterData.importa
import { MostViewedFooterLayout } from '../components/MostViewedFooterLayout';
import { MostViewedRightWithAd } from '../components/MostViewedRightWithAd.importable';
import { OnwardsUpper } from '../components/OnwardsUpper.importable';
-import { RightColumn } from '../components/RightColumn';
import { Section } from '../components/Section';
import { SlotBodyEnd } from '../components/SlotBodyEnd.importable';
import { Standfirst } from '../components/Standfirst';
import { StickyBottomBanner } from '../components/StickyBottomBanner.importable';
import { SubMeta } from '../components/SubMeta';
import { SubNav } from '../components/SubNav.importable';
+import { grid } from '../grid';
import {
ArticleDesign,
+ ArticleDisplay,
type ArticleFormat,
ArticleSpecial,
} from '../lib/articleFormat';
@@ -65,268 +65,13 @@ import type { NavType } from '../model/extract-nav';
import { palette as themePalette } from '../palette';
import type { ArticleDeprecated } from '../types/article';
import type { RenderingTarget } from '../types/renderingTarget';
+import {
+ type Area,
+ type FurnitureLayoutType,
+ rowCss,
+} from './lib/furnitureLayouts';
import { BannerWrapper, Stuck } from './lib/stickiness';
-const StandardGrid = ({
- children,
- isMatchReport,
- isMedia,
- isInFootballRedesignVariantGroup,
-}: {
- children: React.ReactNode;
- isMatchReport: boolean;
- isMedia: boolean;
- isInFootballRedesignVariantGroup: boolean;
-}) => (
-
- {children}
-
-);
-
const maxWidth = css`
${from.desktop} {
max-width: 620px;
@@ -344,6 +89,72 @@ const stretchLines = css`
}
`;
+const spanCentreToRightColumnFromDesktop = css`
+ ${from.desktop} {
+ ${grid.between('centre-column-start', 'right-column-start')};
+ }
+`;
+
+const rightColumnCss = (isMedia: boolean, isShowcase: boolean) => css`
+ display: none;
+
+ ${from.desktop} {
+ display: block;
+ padding-top: 6px;
+ grid-row: ${isMedia ? 3 : 1} / span 999;
+ }
+
+ ${from.leftCol} {
+ grid-row: ${isShowcase ? 3 : isMedia ? 2 : 1} / span 999;
+ }
+`;
+
+interface GridItemProps {
+ area: Area;
+ layoutType: FurnitureLayoutType;
+ columns?: {
+ tablet?: 'left' | 'centre' | 'right';
+ desktop?: 'left' | 'centre' | 'right';
+ leftCol?: 'left' | 'centre' | 'right';
+ };
+ element?: 'div' | 'aside';
+ customCss?: SerializedStyles;
+ children: React.ReactNode;
+}
+
+const columnCss = (columnsConfig?: GridItemProps['columns']) => [
+ grid.column.centre,
+ Object.entries({
+ tablet: columnsConfig?.tablet,
+ desktop: columnsConfig?.desktop,
+ leftCol: columnsConfig?.leftCol,
+ })
+ .filter(([, value]) => value != null)
+ .map(
+ ([bp, col]) => css`
+ ${from[bp as keyof typeof from]} {
+ ${grid.column[col!]};
+ }
+ `,
+ ),
+];
+
+const GridItem = ({
+ area,
+ layoutType,
+ columns,
+ element: Element = 'div',
+ customCss,
+ children,
+}: GridItemProps) => (
+
+ {children}
+
+);
+
interface Props {
article: ArticleDeprecated;
format: ArticleFormat;
@@ -417,8 +228,18 @@ export const StandardLayout = (props: WebProps | AppProps) => {
const isLabs = format.theme === ArticleSpecial.Labs;
+ const isShowcase = format.display === ArticleDisplay.Showcase;
+
const renderAds = canRenderAds(article);
+ const layoutType: FurnitureLayoutType = isMatchReport
+ ? 'matchReport'
+ : isMedia
+ ? 'media'
+ : isShowcase
+ ? 'showcase'
+ : 'standard';
+
return (
<>
{isWeb && (
@@ -488,66 +309,76 @@ export const StandardLayout = (props: WebProps | AppProps) => {
pageId={article.pageId}
pageTags={article.tags}
/>
-
-
- {!isInFootballRedesignVariantGroup && (
- <>
-
-
- {isMatchReport && (
-
-
-
- )}
-
-
-
-
- {isMatchReport && (
-
-
-
- )}
-
-
- >
+ {isMatchReport && !isInFootballRedesignVariantGroup && (
+
+
+
+
+
+
+
+
+
+
+
+
)}
-
-
+
+
{
{!isInFootballRedesignVariantGroup && (
-
+
{
)}
-
+
{format.theme === ArticleSpecial.Labs ? (
<>>
) : (
)}
-
-
+
+
-
+
-
+
{isWeb &&
@@ -720,7 +574,16 @@ export const StandardLayout = (props: WebProps | AppProps) => {
)}
-
+
{/* Only show Listen to Article button on App landscape views */}
{isApps && (
@@ -859,52 +722,37 @@ export const StandardLayout = (props: WebProps | AppProps) => {
/>
-
-
+
+
-
-
-
-
-
-
+ />
+
-
-
+
+
{isWeb && renderAds && !isLabs && (
+>;
+
+type BreakpointRows = Area[][];
+
+type LayoutDefinition = {
+ mobile?: BreakpointRows;
+ tablet?: BreakpointRows;
+ leftCol?: BreakpointRows;
+};
+
+const furnitureRowLayouts: Record = {
+ standard: {
+ tablet: [
+ ['title'],
+ ['headline'],
+ ['standfirst'],
+ ['main-media'],
+ ['meta'],
+ ],
+
+ leftCol: [
+ ['title', 'headline'],
+ ['standfirst'],
+ ['meta', 'main-media'],
+ ],
+ },
+ showcase: {
+ tablet: [
+ ['title'],
+ ['headline'],
+ ['standfirst'],
+ ['main-media'],
+ ['meta'],
+ ],
+
+ leftCol: [
+ ['title', 'headline'],
+ ['meta', 'main-media'],
+ ['standfirst'],
+ ['right-column'],
+ ],
+ },
+ matchReport: {
+ tablet: [
+ ['match-summary'],
+ ['title'],
+ ['headline'],
+ ['standfirst'],
+ ['main-media'],
+ ['meta'],
+ ],
+ leftCol: [
+ ['title', 'match-summary'],
+ ['headline'],
+ ['meta', 'main-media'],
+ ],
+ },
+ media: {
+ mobile: [
+ ['title'],
+ ['headline'],
+ ['main-media'],
+ ['standfirst'],
+ ['meta'],
+ ],
+ tablet: [
+ ['title'],
+ ['headline'],
+ ['main-media'],
+ ['standfirst'],
+ ['meta'],
+ ],
+ leftCol: [
+ ['title', 'headline'],
+ ['meta', 'main-media'],
+ ['standfirst'],
+ ],
+ },
+};
+
+const buildRowMap = (layout: LayoutDefinition): LayoutRows => {
+ const map: LayoutRows = {} as LayoutRows;
+
+ const apply = (
+ rows: Area[][] | undefined,
+ breakpoint: 'mobile' | 'tablet' | 'leftCol',
+ ) => {
+ if (!rows) return;
+
+ for (const [index, areas] of rows.entries()) {
+ const row = index + 1;
+
+ for (const area of areas) {
+ map[area] ??= {};
+ map[area][breakpoint] = row;
+ }
+ }
+ };
+
+ apply(layout.mobile, 'mobile');
+ apply(layout.tablet, 'tablet');
+ apply(layout.leftCol, 'leftCol');
+
+ return map;
+};
+
+const rowMaps = Object.fromEntries(
+ Object.entries(furnitureRowLayouts).map(([name, layout]) => [
+ name,
+ buildRowMap(layout),
+ ]),
+) as Record;
+
+const breakpointQueries = {
+ mobile: until.tablet,
+ tablet: from.tablet,
+ leftCol: from.leftCol,
+ desktop: from.desktop,
+} as const;
+
+export const rowCss = (
+ area: Area,
+ layoutType: FurnitureLayoutType,
+): SerializedStyles => {
+ const rows = rowMaps[layoutType][area] ?? {};
+
+ return css(
+ Object.entries(rows).map(
+ ([bp, row]) => css`
+ ${breakpointQueries[bp as keyof typeof breakpointQueries]} {
+ grid-row: ${row};
+ }
+ `,
+ ),
+ );
+};