diff --git a/dotcom-rendering/fixtures/manual/storylines-section.ts b/dotcom-rendering/fixtures/manual/storylines-section.ts
new file mode 100644
index 00000000000..79dc0ffc216
--- /dev/null
+++ b/dotcom-rendering/fixtures/manual/storylines-section.ts
@@ -0,0 +1,873 @@
+import type { StorylinesContent } from '../../src/types/storylinesContent';
+
+export const mockStorylinesSectionContent: StorylinesContent = {
+ created: '2025-12-09T17:14:47.633244289Z',
+ tag: 'technology/artificialintelligenceai',
+ storylines: [
+ {
+ title: 'AI chatbots and mental health concerns',
+ content: [
+ {
+ category: 'Key Stories',
+ articles: [
+ {
+ url: 'https://www.theguardian.com/commentisfree/2025/dec/09/would-you-entrust-a-childs-life-to-a-chatbot-thats-what-happens-every-day-that-we-fail-to-regulate-ai',
+ headline:
+ "When a chatbot's advice is a matter of life or death, how can we leave AI to the free market wild west?",
+ byline: null,
+ publicationTime: '2025-12-09T06:00:03Z',
+ image: {
+ src: 'https://media.guim.co.uk/0c136b484d3aa3353c75079f0d1283c8bad87b92/886_91_1672_1338/1000.jpg',
+ altText:
+ 'A graphic of a young woman holding her head in apparent distress.',
+ isAvatar: false,
+ mediaData: null,
+ },
+ },
+ {
+ url: 'https://www.theguardian.com/technology/2025/dec/09/teenagers-ai-chatbots-mental-health-support',
+ headline:
+ "'I feel it's a friend': quarter of teenagers turn to AI chatbots for mental health support",
+ byline: null,
+ publicationTime: '2025-12-09T05:00:04Z',
+ image: {
+ src: 'https://media.guim.co.uk/9c56f5fc8042537534d9603f19981242fa85bff4/0_0_4800_3840/1000.jpg',
+ altText:
+ 'A sad girl looks at her phone in the dark.',
+ isAvatar: false,
+ mediaData: null,
+ },
+ },
+ {
+ url: 'https://www.theguardian.com/technology/2025/nov/30/chatgpt-dangerous-advice-mentally-ill-psychologists-openai',
+ headline:
+ 'ChatGPT-5 offers dangerous advice to mentally ill people, psychologists warn',
+ byline: null,
+ publicationTime: '2025-11-30T12:00:06Z',
+ image: {
+ src: 'https://media.guim.co.uk/e224143d1157965611675daf4b49e29484578c45/500_0_2500_2000/1000.jpg',
+ altText: 'ChatGPT logo.',
+ isAvatar: false,
+ mediaData: null,
+ },
+ },
+ {
+ url: 'https://www.theguardian.com/technology/2025/nov/26/chatgpt-openai-blame-technology-misuse-california-boy-suicide',
+ headline:
+ "ChatGPT firm blames boy's suicide on 'misuse' of its technology",
+ byline: null,
+ publicationTime: '2025-11-26T15:31:58Z',
+ image: {
+ src: 'https://media.guim.co.uk/683b7a3e5a4a90c964dfaef8129e9efffd32a631/0_292_1201_960/1000.jpg',
+ altText: 'Adam Raine',
+ isAvatar: false,
+ mediaData: null,
+ },
+ },
+ ],
+ },
+ {
+ category: 'Contrasting opinions',
+ articles: [
+ {
+ url: 'https://www.theguardian.com/commentisfree/2025/dec/09/would-you-entrust-a-childs-life-to-a-chatbot-thats-what-happens-every-day-that-we-fail-to-regulate-ai',
+ headline:
+ "When a chatbot's advice is a matter of life or death, how can we leave AI to the free market wild west?",
+ byline: 'Gaby Hinsliff',
+ publicationTime: '2025-12-09T06:00:03Z',
+ image: {
+ src: 'https://static.guim.co.uk/sys-images/Guardian/Pix/contributor/2014/9/12/1410533773838/Gaby-Hinsliff.jpg',
+ altText: 'Gaby Hinsliff',
+ isAvatar: true,
+ mediaData: null,
+ },
+ },
+ {
+ url: 'https://www.theguardian.com/commentisfree/2025/aug/03/generative-ai-chatbot-therapy-dangers-risks',
+ headline:
+ "Using Generative AI for therapy might feel like a lifeline – but there's danger in seeking certainty in a chatbot",
+ byline: 'Carly Dober',
+ publicationTime: '2025-08-03T15:00:44Z',
+ image: {
+ src: 'https://media.guim.co.uk/96257b31eee33ef29f188d80d2894cff58dd07c7/2075_0_4884_3910/500.jpg',
+ altText:
+ 'A woman sitting on a couch looking sad and holding a phone',
+ isAvatar: false,
+ mediaData: null,
+ },
+ },
+ ],
+ },
+ {
+ category: 'Explainers',
+ articles: [
+ {
+ url: 'https://www.theguardian.com/technology/2025/jan/29/what-international-ai-safety-report-says-jobs-climate-cyberwar-deepfakes-extinction',
+ headline:
+ 'What International AI Safety report says on jobs, climate, cyberwar and more',
+ byline: 'Dan Milmo',
+ publicationTime: '2025-01-29T13:45:56Z',
+ image: {
+ src: 'https://media.guim.co.uk/72a59307aa147e976413a230fc28a51fdab20ab2/0_269_5568_3341/1000.jpg',
+ altText:
+ 'Yoshua Bengio writing on a transparent whiteboard',
+ isAvatar: false,
+ mediaData: null,
+ },
+ },
+ {
+ url: 'https://www.theguardian.com/technology/2023/jan/13/chatgpt-explainer-what-can-artificial-intelligence-chatbot-do-ai',
+ headline:
+ 'ChatGPT: what can the extraordinary artificial intelligence chatbot do?',
+ byline: 'Ian Sample',
+ publicationTime: '2023-01-13T16:23:39Z',
+ image: {
+ src: 'https://media.guim.co.uk/8fe841cd767bea2719c60a084174d64724d47c1f/0_263_5764_3457/500.jpg',
+ altText:
+ 'OpenAI logo seen on screen with ChatGPT website displayed on mobile',
+ isAvatar: false,
+ mediaData: null,
+ },
+ },
+ ],
+ },
+ {
+ category: 'Deep Reads',
+ articles: [
+ {
+ url: 'https://www.theguardian.com/lifeandstyle/2024/mar/02/can-ai-chatbot-therapists-do-better-than-the-real-thing',
+ headline:
+ "'He checks in on me more than my friends and family': can AI therapists do better than the real thing?",
+ byline: 'Alice Robb',
+ publicationTime: '2024-03-02T16:00:34Z',
+ image: {
+ src: 'https://media.guim.co.uk/7b8b39f2b5abb9658a847146874475e5c2df03dd/0_0_5067_3496/1000.jpg',
+ altText:
+ 'Photomontage of a head turned away with a robot next to its ear, all dressed in white like a doctor, stethoscope round its neck, holding a notepad and pen',
+ isAvatar: false,
+ mediaData: null,
+ },
+ },
+ ],
+ },
+ {
+ category: 'Find multimedia',
+ articles: [
+ {
+ url: 'https://www.theguardian.com/science/audio/2025/aug/28/ai-psychosis-could-chatbots-fuel-delusional-thinking-podcast',
+ headline:
+ "'AI psychosis': could chatbots fuel delusional thinking? – podcast",
+ byline: 'Presented by Madeleine Finlay',
+ publicationTime: '2025-08-28T04:00:22Z',
+ image: {
+ src: 'https://media.guim.co.uk/5c7f3e87dd2f78e8b7b20c37b416ca3897104db8/999_264_4435_3548/500.jpg',
+ altText: 'Photograph: Dima Berlin/Getty Images',
+ isAvatar: false,
+ mediaData: {
+ type: 'Audio',
+ duration: '16:34',
+ },
+ },
+ },
+ {
+ url: 'https://www.theguardian.com/science/audio/2024/sep/21/live-episode-will-ai-make-a-good-companion-podcast',
+ headline:
+ 'Live episode: will AI make a good companion? – podcast',
+ byline: 'Presented by Madeleine Finlay with Ian Sample',
+ publicationTime: '2024-09-21T04:00:30Z',
+ image: {
+ src: 'https://media.guim.co.uk/e931bfd23eb75404369a05a5744040c5c7edce08/85_0_5022_3014/500.jpg',
+ altText: "A person shaking a robot's hand",
+ isAvatar: false,
+ mediaData: {
+ type: 'Audio',
+ duration: '36:44',
+ },
+ },
+ },
+ ],
+ },
+ {
+ category: 'Profiles and Interviews',
+ articles: [
+ {
+ url: 'https://www.theguardian.com/technology/2023/jun/07/what-should-the-limits-be-the-father-of-chatgpt-on-whether-ai-will-save-humanity-or-destroy-it',
+ headline:
+ "'What should the limits be?' The father of ChatGPT on whether AI will save humanity – or destroy it",
+ byline: 'Alex Hern',
+ publicationTime: '2023-06-07T05:00:29Z',
+ image: {
+ src: 'https://media.guim.co.uk/d67dc626115d9507a3876fefd572a1fd9bb33481/0_116_6000_3600/1000.jpg',
+ altText:
+ '‘A race towards AGI is a bad thing’ … OpenAI CEO Sam Altman testifying to a senate committee in Washington DC last month. ',
+ isAvatar: false,
+ mediaData: null,
+ },
+ },
+ ],
+ },
+ ],
+ },
+ {
+ title: "AI's environmental impact and data centre energy demands",
+ content: [
+ {
+ category: 'Key Stories',
+ articles: [
+ {
+ url: 'https://www.theguardian.com/us-news/2025/dec/08/us-data-centers',
+ headline:
+ 'More than 200 environmental groups demand halt to new US datacenters',
+ byline: null,
+ publicationTime: '2025-12-08T12:00:40Z',
+ image: {
+ src: 'https://media.guim.co.uk/7487c3a8c1ce3e04080db3f644342c8d03f0a1c9/0_0_7993_5995/1000.jpg',
+ altText: 'an aerial view of a data center',
+ isAvatar: false,
+ mediaData: null,
+ },
+ },
+ {
+ url: 'https://www.theguardian.com/environment/2025/dec/04/thirsty-work-how-the-rise-of-massive-datacentres-strains-australias-drinking-water-supply',
+ headline:
+ "Thirsty work: how the rise of massive datacentres strains Australia's drinking water supply",
+ byline: null,
+ publicationTime: '2025-12-04T14:00:24Z',
+ image: {
+ src: 'https://media.guim.co.uk/7e53329bb97edc9f0c9e83e399811cbde252f28f/0_0_2953_2362/500.jpg',
+ altText:
+ 'An illustration of a sun made of data connections over parched red earth',
+ isAvatar: false,
+ mediaData: null,
+ },
+ },
+ {
+ url: 'https://www.theguardian.com/technology/2025/dec/04/nevada-ai-data-centers',
+ headline:
+ 'The AI boom is heralding a new gold rush in the American west',
+ byline: null,
+ publicationTime: '2025-12-04T12:00:19Z',
+ image: {
+ src: 'https://media.guim.co.uk/98b1dc3ef8182910c8dc25f71f49cdb364619a90/0_0_4500_3000/500.jpg',
+ altText:
+ 'The Tahoe‑Reno Industrial Center covers more than 100,000 acres in Storey county, Nevada.',
+ isAvatar: false,
+ mediaData: null,
+ },
+ },
+ {
+ url: 'https://www.theguardian.com/australia-news/2025/dec/03/datacentres-demand-huge-amounts-of-electricity-could-they-derail-australias-net-zero-ambitions',
+ headline:
+ "Datacentres demand huge amounts of electricity. Could they derail Australia's net zero ambitions?",
+ byline: null,
+ publicationTime: '2025-12-02T14:00:38Z',
+ image: {
+ src: 'https://media.guim.co.uk/ba3cb163cdc43f2586416c50ca4ace59891a198f/0_0_2953_2362/500.jpg',
+ altText:
+ 'Illustration of datacentres on a hill with houses below',
+ isAvatar: false,
+ mediaData: null,
+ },
+ },
+ {
+ url: 'https://www.theguardian.com/australia-news/2025/dec/02/ai-companies-renewable-energy-australia-data-centres',
+ headline:
+ "AI companies could be forced to invest in renewable energy amid warning tech will use 12% of Australia's power",
+ byline: null,
+ publicationTime: '2025-12-02T06:42:53Z',
+ image: {
+ src: 'https://media.guim.co.uk/2a59b4aace070a3f36963c66221efc0678a2f106/343_0_1624_1299/1000.jpg',
+ altText:
+ 'Rows of servers inside an Amazon data centre',
+ isAvatar: false,
+ mediaData: null,
+ },
+ },
+ ],
+ },
+ {
+ category: 'Contrasting opinions',
+ articles: [
+ {
+ url: 'https://www.theguardian.com/business/2025/aug/03/australia-shouldnt-fear-the-ai-revolution-new-skills-can-create-more-and-better-jobs',
+ headline:
+ 'Australia and the AI revolution – turning algorithms into opportunities',
+ byline: 'Jim Chalmers',
+ publicationTime: '2025-08-02T20:00:20Z',
+ image: {
+ src: 'https://static.guim.co.uk/sys-images/Guardian/Pix/pictures/2013/12/9/1386570608680/jimchalmer.jpg',
+ altText: 'Jim Chalmers',
+ isAvatar: true,
+ mediaData: null,
+ },
+ },
+ {
+ url: 'https://www.theguardian.com/commentisfree/article/2024/may/30/ugly-truth-ai-chatgpt-guzzling-resources-environment',
+ headline:
+ 'The ugly truth behind ChatGPT: AI is guzzling resources at planet-eating rates',
+ byline: 'Mariana Mazzucato',
+ publicationTime: '2024-05-30T06:00:21Z',
+ image: {
+ src: 'https://uploads.guim.co.uk/2024/03/06/Mariana_Mazzucato.jpg',
+ altText: 'Mariana Mazzucato',
+ isAvatar: true,
+ mediaData: null,
+ },
+ },
+ ],
+ },
+ {
+ category: 'Explainers',
+ articles: [
+ {
+ url: 'https://www.theguardian.com/technology/2025/jan/14/keir-starmer-ai-labour-green-energy-promise',
+ headline:
+ "Could Keir Starmer's AI dream derail his own green energy promise?",
+ byline: 'Jillian Ambrose',
+ publicationTime: '2025-01-14T18:09:03Z',
+ image: {
+ src: 'https://media.guim.co.uk/8abfc6d733094f9c745a271b041f13c30f8947c0/0_54_1792_1075/500.jpg',
+ altText: 'A wall of wires and shelves',
+ isAvatar: false,
+ mediaData: null,
+ },
+ },
+ {
+ url: 'https://www.theguardian.com/business/article/2024/jul/04/can-the-climate-survive-the-insatiable-energy-demands-of-the-ai-arms-race',
+ headline:
+ 'Can the climate survive the insatiable energy demands of the AI arms race?',
+ byline: 'Dan Milmo',
+ publicationTime: '2024-07-04T05:00:29Z',
+ image: {
+ src: 'https://media.guim.co.uk/aec25eda9acc54b1bb0a014896d86a73ea7dd4b7/0_62_2000_1202/500.jpg',
+ altText: 'Google’s data centre in the US.',
+ isAvatar: false,
+ mediaData: null,
+ },
+ },
+ ],
+ },
+ {
+ category: 'Deep Reads',
+ articles: [
+ {
+ url: 'https://www.theguardian.com/us-news/2025/oct/15/tucson-arizona-ai-data-center-project-blue',
+ headline:
+ "'The city that draws the line': one Arizona community's fight against a huge datacenter",
+ byline: 'Douglas Main',
+ publicationTime: '2025-10-15T14:00:41Z',
+ image: {
+ src: 'https://media.guim.co.uk/3b7f391d3339a8b4cf874a8c4ac9f8f59fe53884/0_0_4032_3024/1000.jpg',
+ altText: 'people holding signs',
+ isAvatar: false,
+ mediaData: null,
+ },
+ },
+ ],
+ },
+ {
+ category: 'Find multimedia',
+ articles: [
+ {
+ url: 'https://www.theguardian.com/news/audio/2025/jan/23/why-is-ai-so-thirsty-podcast',
+ headline: 'Why is AI so thirsty? – podcast',
+ byline: 'Presented by Michael Safi with Helena Horton; produced by Alex Atack, Ruth Abrahams and Joel Cox; executive producers Elizabeth Cassin and Courtney Yusuf',
+ publicationTime: '2025-01-23T03:00:30Z',
+ image: {
+ src: 'https://media.guim.co.uk/6d50387b3e7e866f278c3f63a1c28dee239a8944/42_362_3236_1941/500.jpg',
+ altText:
+ 'Bottles of water under the shade of a hedge',
+ isAvatar: false,
+ mediaData: {
+ type: 'Audio',
+ duration: '21:27',
+ },
+ },
+ },
+ {
+ url: 'https://www.theguardian.com/science/audio/2024/jul/16/can-the-climate-survive-ais-thirst-for-energy-podcast',
+ headline:
+ "Can the climate survive AI's thirst for energy? – podcast",
+ byline: 'Presented by Madeleine Finlay, with Jillian Ambrose and Alex Hern. Produced by Madeleine Finlay, Holly Fisher and Tom Glasser; sound design by Joel Cox. The executive producer was Ellie Bury',
+ publicationTime: '2024-07-16T04:00:07Z',
+ image: {
+ src: 'https://media.guim.co.uk/12576f906a47ff23877c1d6676c8e5890e80df37/0_320_4800_2880/500.jpg',
+ altText:
+ 'A picture of a lab bathed in green light',
+ isAvatar: false,
+ mediaData: {
+ type: 'Audio',
+ duration: '18:08',
+ },
+ },
+ },
+ ],
+ },
+ {
+ category: 'Profiles and Interviews',
+ articles: [
+ {
+ url: 'https://www.theguardian.com/technology/2025/aug/04/demis-hassabis-ai-future-10-times-bigger-than-industrial-revolution-and-10-times-faster',
+ headline:
+ "Demis Hassabis on our AI future: 'It'll be 10 times bigger than the Industrial Revolution – and maybe 10 times faster'",
+ byline: 'Steve Rose',
+ publicationTime: '2025-08-04T04:00:03Z',
+ image: {
+ src: 'https://media.guim.co.uk/d5e91c33050a876de575fe5431c32f4daaec8468/0_0_9504_6336/500.jpg',
+ altText:
+ "Demis Hassabis at Google's offices in London",
+ isAvatar: false,
+ mediaData: null,
+ },
+ },
+ ],
+ },
+ ],
+ },
+ {
+ title: 'AI regulation and government policy responses',
+ content: [
+ {
+ category: 'Key Stories',
+ articles: [
+ {
+ url: 'https://www.theguardian.com/technology/2025/dec/09/eu-investigation-google-ai-models-gemini',
+ headline:
+ "EU opens investigation into Google's use of online content for AI models",
+ byline: null,
+ publicationTime: '2025-12-09T14:38:39Z',
+ image: {
+ src: 'https://media.guim.co.uk/0265df329ab15c539360f63131ed6ab480ca4201/1222_753_5697_4560/1000.jpg',
+ altText:
+ 'Google logo at a convention centre in Paris',
+ isAvatar: false,
+ mediaData: null,
+ },
+ },
+ {
+ url: 'https://www.theguardian.com/technology/2025/dec/08/scores-of-uk-parliamentarians-join-call-to-regulate-most-powerful-ai-systems',
+ headline:
+ 'Scores of UK parliamentarians join call to regulate most powerful AI systems',
+ byline: null,
+ publicationTime: '2025-12-08T05:00:42Z',
+ image: {
+ src: 'https://media.guim.co.uk/ec63cb16f445d0edfab5627224295056e3e39a62/170_0_6501_5203/1000.jpg',
+ altText:
+ 'Liquid-cooled servers at the Global Switch Docklands data centre campus in London.',
+ isAvatar: false,
+ mediaData: null,
+ },
+ },
+ {
+ url: 'https://www.theguardian.com/australia-news/2025/dec/01/labor-rejects-standalone-ai-legislation-with-plan-that-offers-to-help-unlock-public-and-private-data',
+ headline:
+ "'Enable workers' talents': no need for AI legislation in Australia, Labor says",
+ byline: null,
+ publicationTime: '2025-12-01T11:30:33Z',
+ image: {
+ src: 'https://media.guim.co.uk/1c944256f4b09fdd4a75eb808d7f3593ee172cae/0_0_8659_5773/500.jpg',
+ altText:
+ 'A girl holding a mobile phone with an AI chatbot on the screen',
+ isAvatar: false,
+ mediaData: null,
+ },
+ },
+ {
+ url: 'https://www.theguardian.com/commentisfree/2025/nov/23/ai-use-strengthen-democracy',
+ headline:
+ 'Four ways AI is being used to strengthen democracies worldwide',
+ byline: null,
+ publicationTime: '2025-11-23T12:00:09Z',
+ image: {
+ src: 'https://media.guim.co.uk/3216ac5fa283ff9ebce2d8f7b8be5952ed2231a7/417_0_4167_3333/1000.jpg',
+ altText:
+ 'Brazilian flag colors projected on Brazilian national congress',
+ isAvatar: false,
+ mediaData: null,
+ },
+ },
+ {
+ url: 'https://www.theguardian.com/world/2025/nov/19/european-commission-accused-of-massive-rollback-of-digital-protections',
+ headline:
+ "European Commission accused of 'massive rollback' of digital protections",
+ byline: null,
+ publicationTime: '2025-11-19T16:27:36Z',
+ image: {
+ src: 'https://media.guim.co.uk/ee07a32d49c887cd105777a2887d05733b4387ef/1705_1315_1765_1412/1000.jpg',
+ altText:
+ 'Valdis Dombrovskis stands behind a podium at a press conference',
+ isAvatar: false,
+ mediaData: null,
+ },
+ },
+ ],
+ },
+ {
+ category: 'Contrasting opinions',
+ articles: [
+ {
+ url: 'https://www.theguardian.com/commentisfree/2025/dec/02/artificial-intelligence-threats-congress',
+ headline:
+ 'AI poses unprecedented threats. Congress must act now',
+ byline: 'Bernie Sanders',
+ publicationTime: '2025-12-02T12:00:31Z',
+ image: {
+ src: 'https://media.guim.co.uk/d5f050a59b5a3faef404017bd9070cf6c1efdada/612_0_6839_5473/1000.jpg',
+ altText:
+ 'Proliferation of data centers in Northern Virginia. An image made with a drone shows an Amazon Web Services (AWS) data center in Manassas, Virginia, USA, 23 September 2025.',
+ isAvatar: false,
+ mediaData: null,
+ },
+ },
+ {
+ url: 'https://www.theguardian.com/commentisfree/2025/nov/23/ai-use-strengthen-democracy',
+ headline:
+ 'Four ways AI is being used to strengthen democracies worldwide',
+ byline: 'Nathan E Sanders',
+ publicationTime: '2025-11-23T12:00:09Z',
+ image: {
+ src: 'https://static.guim.co.uk/sys-images/Guardian/Pix/pictures/2013/8/6/1375803209806/BruceSchneier.jpg',
+ altText: 'Bruce Schneier',
+ isAvatar: true,
+ mediaData: null,
+ },
+ },
+ ],
+ },
+ {
+ category: 'Explainers',
+ articles: [
+ {
+ url: 'https://www.theguardian.com/technology/2025/feb/25/why-are-creatives-fighting-uk-government-ai-proposals-on-copyright',
+ headline:
+ 'Why are creatives fighting UK government AI proposals on copyright?',
+ byline: 'Dan Milmo',
+ publicationTime: '2025-02-25T00:01:27Z',
+ image: {
+ src: 'https://media.guim.co.uk/59673eed672f6b4f7f0ff0b874e6139f97bf7da8/0_1271_3744_2246/1000.jpg',
+ altText: 'Kate Bush',
+ isAvatar: false,
+ mediaData: null,
+ },
+ },
+ {
+ url: 'https://www.theguardian.com/technology/2024/mar/14/what-will-eu-proposed-regulation-ai-mean-consumers',
+ headline:
+ "What will the EU's proposed act to regulate AI mean for consumers?",
+ byline: 'Dan Milmo',
+ publicationTime: '2024-03-14T14:33:04Z',
+ image: {
+ src: 'https://media.guim.co.uk/8fe841cd767bea2719c60a084174d64724d47c1f/0_377_5764_3458/1000.jpg',
+ altText: 'OpenAI and ChatGPT logos',
+ isAvatar: false,
+ mediaData: null,
+ },
+ },
+ ],
+ },
+ {
+ category: 'Deep Reads',
+ articles: [
+ {
+ url: 'https://www.theguardian.com/news/article/2024/aug/08/no-god-in-the-machine-the-pitfalls-of-ai-worship',
+ headline:
+ 'No god in the machine: the pitfalls of AI worship',
+ byline: 'Navneet Alang',
+ publicationTime: '2024-08-08T04:00:25Z',
+ image: {
+ src: 'https://media.guim.co.uk/1a8a11267fa480588de862c3c702ec549ef06725/0_0_5000_3000/500.jpg',
+ altText: 'phone god big FINAL 3',
+ isAvatar: false,
+ mediaData: null,
+ },
+ },
+ ],
+ },
+ {
+ category: 'Find multimedia',
+ articles: [
+ {
+ url: 'https://www.theguardian.com/australia-news/audio/2025/oct/03/newsroom-edition-the-battle-to-regulate-ai-full-story-podcast',
+ headline:
+ 'Newsroom edition: the battle to regulate AI – Full Story podcast',
+ byline: 'Presented by Bridie Jabour, with Lenore Taylor and Patrick Keneally. Produced by Miles Herbertand Joe Koning, with video production by Bertin Huynh.',
+ publicationTime: '2025-10-02T18:30:12Z',
+ image: {
+ src: 'https://media.guim.co.uk/87b793760ff5e8e990799da13e682e9973e4b4ba/0_0_1875_1500/500.jpg',
+ altText:
+ 'Full Story Newsroom edition artwork featuring (L-R): Patrick Keneally, Lenore Taylor and Bridie Jabour',
+ isAvatar: false,
+ mediaData: {
+ type: 'Audio',
+ duration: '19:59',
+ },
+ },
+ },
+ {
+ url: 'https://www.theguardian.com/australia-news/video/2024/aug/19/chatbot-interrupts-google-executive-during-australian-senate-hearing-on-ai-video',
+ headline:
+ 'Chatbot interrupts Google executive during Australian Senate hearing on AI – video',
+ byline: 'no byline found',
+ publicationTime: '2024-08-19T01:50:25Z',
+ image: {
+ src: 'https://media.guim.co.uk/ac1bab42ff3dbb663e7469026100437ef837ec14/183_179_1501_901/master/1501.jpg',
+ altText:
+ "Lucinda Longcroft says while she has enabled Google's AI assistant, it was not helping her answer questions from the committee",
+ isAvatar: false,
+ mediaData: {
+ type: 'YoutubeVideo',
+ id: '1Qt5CE2Lzgw',
+ videoId:
+ 'c51e5c30-6330-469e-9274-edc9565ae4f7',
+ height: 300,
+ width: 500,
+ origin: 'ParlView',
+ title: 'Chatbot interrupts Google executive during Australian Senate hearing on AI – video',
+ duration: 80,
+ expired: false,
+ image: 'https://media.guim.co.uk/ac1bab42ff3dbb663e7469026100437ef837ec14/183_179_1501_901/master/1501.jpg',
+ },
+ },
+ },
+ ],
+ },
+ {
+ category: 'Profiles and Interviews',
+ articles: [
+ {
+ url: 'https://www.theguardian.com/politics/2025/sep/09/peter-kyle-tech-bro-minister-kickstarting-uk-growth',
+ headline:
+ "Peter Kyle, the 'tech bro' minister charged with kickstarting UK growth",
+ byline: 'Rob Davies',
+ publicationTime: '2025-09-09T04:00:05Z',
+ image: {
+ src: 'https://media.guim.co.uk/f95d2b64aa520b989daf3fa839127b7a9348ae01/728_0_3682_2946/1000.jpg',
+ altText:
+ 'Peter Kyle arrives in Downing Street last Friday',
+ isAvatar: false,
+ mediaData: null,
+ },
+ },
+ ],
+ },
+ ],
+ },
+ {
+ title: 'AI industry competition and market dynamics',
+ content: [
+ {
+ category: 'Key Stories',
+ articles: [
+ {
+ url: 'https://www.theguardian.com/technology/2025/dec/02/sam-altman-issues-code-red-at-openai-as-chatgpt-contends-with-rivals',
+ headline:
+ "Sam Altman issues 'code red' at OpenAI as ChatGPT contends with rivals",
+ byline: null,
+ publicationTime: '2025-12-02T17:11:27Z',
+ image: {
+ src: 'https://media.guim.co.uk/dd3a6a26e9f6070c6a1d8590f6ab80f16084d7fb/415_0_2669_2138/1000.jpg',
+ altText:
+ 'the openAI logo set on a neon purple background',
+ isAvatar: false,
+ mediaData: null,
+ },
+ },
+ {
+ url: 'https://www.theguardian.com/technology/2025/dec/01/ai-bubble-us-economy',
+ headline:
+ "The question isn't whether the AI bubble will burst – but what the fallout will be",
+ byline: null,
+ publicationTime: '2025-12-01T11:00:33Z',
+ image: {
+ src: 'https://media.guim.co.uk/109922e557b8a6cd0ccfc3ec9509e9a48198e34f/412_0_3571_2857/1000.jpg',
+ altText:
+ 'Photo illustrations featuring DeepSeek and Nvidia logos.',
+ isAvatar: false,
+ mediaData: null,
+ },
+ },
+ {
+ url: 'https://www.theguardian.com/technology/ng-interactive/2025/dec/01/its-going-much-too-fast-the-inside-story-of-the-race-to-create-the-ultimate-ai',
+ headline:
+ "'It's going much too fast': the inside story of the race to create the ultimate AI",
+ byline: null,
+ publicationTime: '2025-12-01T10:00:31Z',
+ image: {
+ src: 'https://media.guim.co.uk/e7055f336a19e15d16b131a69237011f879c193e/1561_429_4028_5035/800.jpg',
+ altText: 'A train behind a fence',
+ isAvatar: false,
+ mediaData: null,
+ },
+ },
+ {
+ url: 'https://www.theguardian.com/technology/2025/nov/19/nvidia-earning-report',
+ headline:
+ "'We excel at every phase of AI': Nvidia CEO quells Wall Street fears of AI bubble amid market selloff",
+ byline: null,
+ publicationTime: '2025-11-20T03:36:19Z',
+ image: {
+ src: 'https://media.guim.co.uk/a6ec114b3c4419e562e475ddf46f29b5a76c9b49/0_0_6500_4326/1000.jpg',
+ altText: 'a man in a suit looks ahead',
+ isAvatar: false,
+ mediaData: null,
+ },
+ },
+ {
+ url: 'https://www.theguardian.com/technology/2025/nov/18/crypto-market-tech-bubble-bitcoin-price-ai-boom',
+ headline:
+ 'Crypto market sheds more than $1tn in six weeks amid fears of tech bubble',
+ byline: null,
+ publicationTime: '2025-11-18T12:03:57Z',
+ image: {
+ src: 'https://media.guim.co.uk/03d1f4b2dd4e61bd4684de97f24d2cbcca8d56e6/333_0_3333_2667/500.jpg',
+ altText:
+ 'Bitcoin tokens for sale during the Bitcoin 2025 conference in Las Vegas',
+ isAvatar: false,
+ mediaData: null,
+ },
+ },
+ ],
+ },
+ {
+ category: 'Contrasting opinions',
+ articles: [
+ {
+ url: 'https://www.theguardian.com/commentisfree/2025/sep/24/ai-investors-llms',
+ headline:
+ 'AI investors are in for a rude awakening',
+ byline: 'Roger McNamee',
+ publicationTime: '2025-09-24T14:00:09Z',
+ image: {
+ src: 'https://media.guim.co.uk/ab8bbf2afed9598f1abe7c8e8e58a60a2684f513/0_0_2500_1500/1000.jpg',
+ altText:
+ 'composite of phones showing logos for AI tools',
+ isAvatar: false,
+ mediaData: null,
+ },
+ },
+ {
+ url: 'https://www.theguardian.com/business/2025/aug/03/australia-shouldnt-fear-the-ai-revolution-new-skills-can-create-more-and-better-jobs',
+ headline:
+ 'Australia and the AI revolution – turning algorithms into opportunities',
+ byline: 'Jim Chalmers',
+ publicationTime: '2025-08-02T20:00:20Z',
+ image: {
+ src: 'https://static.guim.co.uk/sys-images/Guardian/Pix/pictures/2013/12/9/1386570608680/jimchalmer.jpg',
+ altText: 'Jim Chalmers',
+ isAvatar: true,
+ mediaData: null,
+ },
+ },
+ ],
+ },
+ {
+ category: 'Explainers',
+ articles: [
+ {
+ url: 'https://www.theguardian.com/business/2025/oct/08/openai-multibillion-dollar-deals-exuberance-circular-nvidia-amd',
+ headline:
+ "Do OpenAI's multibillion-dollar deals mean exuberance has got out of hand?",
+ byline: 'Dan Milmo',
+ publicationTime: '2025-10-08T05:00:16Z',
+ image: {
+ src: 'https://media.guim.co.uk/0e9118440b60cf0be2c6a62a7b2e05f836711a0f/257_0_3207_2138/1000.jpg',
+ altText:
+ 'OpenAI logo on screen above a keyboard',
+ isAvatar: false,
+ mediaData: null,
+ },
+ },
+ {
+ url: 'https://www.theguardian.com/technology/article/2024/aug/03/why-big-seven-tech-companies-hit-ai-boom-doubts-shares',
+ headline:
+ 'Why have the big seven tech companies been hit by AI boom doubts?',
+ byline: 'Dan Milmo',
+ publicationTime: '2024-08-03T08:00:00Z',
+ image: {
+ src: 'https://media.guim.co.uk/893bc20ada45478c7c6c8f37d98d646147d32ac7/0_0_2546_1527/500.jpg',
+ altText:
+ 'Logos of: Nvidia, Microsoft, Tesla, Apple, Amazon, Alphabet and Meta',
+ isAvatar: false,
+ mediaData: null,
+ },
+ },
+ ],
+ },
+ {
+ category: 'Deep Reads',
+ articles: [
+ {
+ url: 'https://www.theguardian.com/technology/ng-interactive/2025/dec/01/its-going-much-too-fast-the-inside-story-of-the-race-to-create-the-ultimate-ai',
+ headline:
+ "'It's going much too fast': the inside story of the race to create the ultimate AI",
+ byline: 'Robert Booth',
+ publicationTime: '2025-12-01T10:00:31Z',
+ image: {
+ src: 'https://media.guim.co.uk/e7055f336a19e15d16b131a69237011f879c193e/1561_429_4028_5035/800.jpg',
+ altText: 'A train behind a fence',
+ isAvatar: false,
+ mediaData: null,
+ },
+ },
+ ],
+ },
+ {
+ category: 'Find multimedia',
+ articles: [
+ {
+ url: 'https://www.theguardian.com/news/audio/2025/dec/08/is-ai-a-bubble-thats-about-to-pop-podcast',
+ headline:
+ "Is AI a bubble that's about to pop? – podcast",
+ byline: 'Presented by Nosheen Iqbal with Blake Montgomery; produced by Eli Block, George McDonagh and Joel Cox; executive producers Homa Khaleeli and Sami Kent',
+ publicationTime: '2025-12-08T03:00:38Z',
+ image: {
+ src: 'https://media.guim.co.uk/a592cb81eac50241f8dc955b0dd411e77f13449c/419_0_6024_4820/500.jpg',
+ altText:
+ 'A stock market trader looks at prices on a screen.',
+ isAvatar: false,
+ mediaData: {
+ type: 'Audio',
+ duration: '23:40',
+ },
+ },
+ },
+ {
+ url: 'https://www.theguardian.com/technology/ng-interactive/2025/dec/01/its-going-much-too-fast-the-inside-story-of-the-race-to-create-the-ultimate-ai',
+ headline:
+ "'It's going much too fast': the inside story of the race to create the ultimate AI",
+ byline: 'Robert Booth',
+ publicationTime: '2025-12-01T10:00:31Z',
+ image: {
+ src: 'https://media.guim.co.uk/e7055f336a19e15d16b131a69237011f879c193e/1561_429_4028_5035/800.jpg',
+ altText: 'A train behind a fence',
+ isAvatar: false,
+ mediaData: null,
+ },
+ },
+ ],
+ },
+ {
+ category: 'Profiles and Interviews',
+ articles: [
+ {
+ url: 'https://www.theguardian.com/technology/2023/jun/07/what-should-the-limits-be-the-father-of-chatgpt-on-whether-ai-will-save-humanity-or-destroy-it',
+ headline:
+ "'What should the limits be?' The father of ChatGPT on whether AI will save humanity – or destroy it",
+ byline: 'Alex Hern',
+ publicationTime: '2023-06-07T05:00:29Z',
+ image: {
+ src: 'https://media.guim.co.uk/d67dc626115d9507a3876fefd572a1fd9bb33481/0_116_6000_3600/1000.jpg',
+ altText:
+ '‘A race towards AGI is a bad thing’ … OpenAI CEO Sam Altman testifying to a senate committee in Washington DC last month. ',
+ isAvatar: false,
+ mediaData: null,
+ },
+ },
+ ],
+ },
+ ],
+ },
+ ],
+ earliestArticleTime: '2025-11-17T00:00:00Z',
+ latestArticleTime: '2025-12-09T00:00:00Z',
+};
diff --git a/dotcom-rendering/package.json b/dotcom-rendering/package.json
index 7b2d1fcc456..9f5598fa8b7 100644
--- a/dotcom-rendering/package.json
+++ b/dotcom-rendering/package.json
@@ -38,7 +38,7 @@
"@guardian/identity-auth": "6.0.1",
"@guardian/identity-auth-frontend": "8.1.0",
"@guardian/libs": "26.1.0",
- "@guardian/ophan-tracker-js": "2.6.3",
+ "@guardian/ophan-tracker-js": "2.8.0",
"@guardian/react-crossword": "11.1.0",
"@guardian/shimport": "1.0.2",
"@guardian/source": "11.3.0",
diff --git a/dotcom-rendering/src/components/Card/Card.tsx b/dotcom-rendering/src/components/Card/Card.tsx
index 5e13c219cc2..ec04721ae82 100644
--- a/dotcom-rendering/src/components/Card/Card.tsx
+++ b/dotcom-rendering/src/components/Card/Card.tsx
@@ -53,6 +53,7 @@ import { StarRating } from '../StarRating/StarRating';
import { StarRatingDeprecated } from '../StarRating/StarRatingDeprecated';
import type { Alignment } from '../SupportingContent';
import { SupportingContent } from '../SupportingContent';
+import { SupportingKeyStoriesContent } from '../SupportingKeyStoriesContent';
import { SvgMediaControlsPlay } from '../SvgMediaControlsPlay';
import { YoutubeBlockComponent } from '../YoutubeBlockComponent.importable';
import { AvatarContainer } from './components/AvatarContainer';
@@ -164,6 +165,7 @@ export type Props = {
/** Determines if the headline should be positioned within the content or outside the content */
headlinePosition?: 'inner' | 'outer';
enableHls?: boolean;
+ isStorylines?: boolean;
isInStarRatingVariant?: boolean;
starRatingSize?: RatingSizeType;
};
@@ -424,6 +426,7 @@ export const Card = ({
headlinePosition = 'inner',
subtitleSize = 'small',
enableHls = false,
+ isStorylines = false,
isInStarRatingVariant,
starRatingSize = 'small',
}: Props) => {
@@ -457,7 +460,10 @@ export const Card = ({
const withinTwelveHours = isWithinTwelveHours(webPublicationDate);
const shouldShowAge =
- isTagPage || !!onwardsSource || (showAge && withinTwelveHours);
+ isStorylines ||
+ isTagPage ||
+ !!onwardsSource ||
+ (showAge && withinTwelveHours);
if (!shouldShowAge) return undefined;
@@ -510,8 +516,30 @@ export const Card = ({
css={css`
margin-top: auto;
display: flex;
+ ${isStorylines &&
+ `
+ flex-direction: column;
+ gap: ${space[1]}px;
+ align-items: flex-start;
+ `}
`}
>
+ {/* Usually, we either display the pill or the footer,
+ but if the card appears in the storylines section on tag pages
+ then we do want to display the date on these cards as well as the media pill.
+ */}
+ {isStorylines && (
+ }
+ cardBranding={
+ isOnwardContent ? : undefined
+ }
+ showLivePlayable={showLivePlayable}
+ />
+ )}
+
{mainMedia?.type === 'YoutubeVideo' && isVideoArticle && (
<>
{mainMedia.duration === 0 ? (
@@ -738,25 +766,39 @@ export const Card = ({
if (!hasSublinks) return null;
if (sublinkPosition === 'none') return null;
- const Sublinks = () => (
-
- );
+ const Sublinks = () => {
+ return isStorylines ? (
+
+ ) : (
+
+ );
+ };
if (sublinkPosition === 'outer') {
return ;
@@ -775,14 +817,25 @@ export const Card = ({
return (
-
+ {isStorylines ? (
+
+ ) : (
+
+ )}
);
};
@@ -1210,120 +1263,127 @@ export const Card = ({
isOnwardContent,
)}
>
- {/* This div is needed to keep the headline and trail text justified at the start */}
-
- {headlinePosition === 'inner' && (
-
-
-
- {!isUndefined(starRating) &&
- (isInStarRatingVariant ? (
-
- ) : (
-
- ))}
-
- )}
-
- {!!trailText && shouldShowTrailText && (
-
- )}
-
- {!isOpinionCardWithAvatar && (
- <>
- {showPill ? (
- <>
- {!!branding && isOnwardContent && (
-
- )}
-
- >
- ) : (
-
+ {headlinePosition === 'inner' && (
+
+ }
- cardBranding={
- isOnwardContent ? (
-
- ) : undefined
+ fontSizes={headlineSizes}
+ showQuotes={showQuotes}
+ kickerText={
+ format.design ===
+ ArticleDesign.LiveBlog &&
+ !kickerText
+ ? 'Live'
+ : kickerText
}
- showLivePlayable={showLivePlayable}
- />
- )}
- >
- )}
- {showLivePlayable &&
- liveUpdatesPosition === 'inner' && (
-
-
-
+ />
+
+ {!isUndefined(starRating) &&
+ (isInStarRatingVariant ? (
+
+ ) : (
+
+ ))}
+
)}
-
+
+ {!!trailText && shouldShowTrailText && (
+
+ )}
+
+ {!isOpinionCardWithAvatar && (
+ <>
+ {showPill ? (
+ <>
+ {!!branding && isOnwardContent && (
+
+ )}
+
+ >
+ ) : (
+ }
+ cardBranding={
+ isOnwardContent ? (
+
+ ) : undefined
+ }
+ showLivePlayable={showLivePlayable}
+ />
+ )}
+ >
+ )}
+ {showLivePlayable &&
+ liveUpdatesPosition === 'inner' && (
+
+
+
+ )}
+
+ )}
{/* This div is needed to push this content to the bottom of the card */}
diff --git a/dotcom-rendering/src/components/Card/components/CardAge.tsx b/dotcom-rendering/src/components/Card/components/CardAge.tsx
index 9456fee03d3..95496cec509 100644
--- a/dotcom-rendering/src/components/Card/components/CardAge.tsx
+++ b/dotcom-rendering/src/components/Card/components/CardAge.tsx
@@ -30,6 +30,7 @@ type Props = {
isTagPage: boolean;
showClock?: boolean;
colour?: string;
+ isStorylines?: boolean;
};
export const CardAge = ({
@@ -38,6 +39,7 @@ export const CardAge = ({
isTagPage,
showClock,
colour = palette('--card-footer-text'),
+ isStorylines,
}: Props) => {
if (timeAgo(new Date(webPublication.date).getTime()) === false) {
return null;
@@ -46,7 +48,7 @@ export const CardAge = ({
return (
{showClock && }
- {isTagPage ? (
+ {isTagPage && !isStorylines ? (
{
// The link is only applied directly to the headline if it is a sublink
const isSublink = !!linkTo;
@@ -233,7 +235,7 @@ export const CardHeadline = ({
isSublink ? 'card-sublink-headline' : 'card-headline'
}`}
css={[
- isSublink
+ isSublink && !isStorylines
? css`
${textSans14}
`
diff --git a/dotcom-rendering/src/components/FeatureCard.tsx b/dotcom-rendering/src/components/FeatureCard.tsx
index f1b024d3ef7..7ddbbe9b002 100644
--- a/dotcom-rendering/src/components/FeatureCard.tsx
+++ b/dotcom-rendering/src/components/FeatureCard.tsx
@@ -361,6 +361,7 @@ export type Props = {
*/
isImmersive?: boolean;
showVideo?: boolean;
+ isStorylines?: boolean;
isInStarRatingVariant?: boolean;
starRatingSize: RatingSizeType;
};
@@ -398,6 +399,7 @@ export const FeatureCard = ({
isNewsletter = false,
isImmersive = false,
showVideo = false,
+ isStorylines = false,
isInStarRatingVariant,
starRatingSize,
}: Props) => {
@@ -680,6 +682,9 @@ export const FeatureCard = ({
}
showClock={!!showClock}
serverTime={serverTime}
+ isStorylines={
+ isStorylines
+ }
/>
) : undefined
}
diff --git a/dotcom-rendering/src/components/FeatureCardCardAge.tsx b/dotcom-rendering/src/components/FeatureCardCardAge.tsx
index e43d7cc34cd..bc50a03275e 100644
--- a/dotcom-rendering/src/components/FeatureCardCardAge.tsx
+++ b/dotcom-rendering/src/components/FeatureCardCardAge.tsx
@@ -6,15 +6,17 @@ type Props = {
showClock: boolean;
serverTime?: number;
webPublicationDate: string;
+ isStorylines?: boolean;
};
export const FeatureCardCardAge = ({
showClock,
serverTime,
webPublicationDate,
+ isStorylines,
}: Props) => {
const withinTwelveHours = isWithinTwelveHours(webPublicationDate);
- if (withinTwelveHours) {
+ if (withinTwelveHours || isStorylines) {
return (
{
const isLoopingVideo =
@@ -143,6 +147,7 @@ const ImmersiveCardLayout = ({
supportingContent={card.supportingContent}
isImmersive={true}
showVideo={card.showVideo}
+ isStorylines={isStorylines}
isInStarRatingVariant={isInStarRatingVariant}
starRatingSize={'medium'}
/>
@@ -173,6 +178,7 @@ const decideSplashCardProperties = (
mediaCard: boolean,
useLargerHeadlineSizeDesktop: boolean,
avatarUrl: boolean,
+ isStorylines?: boolean,
): BoostedSplashProperties => {
switch (boostLevel) {
// The default boost level is equal to no boost. It is the same as the default card layout.
@@ -193,6 +199,11 @@ const decideSplashCardProperties = (
subtitleSize: 'medium',
};
case 'boost':
+ const boostSupportingContentAlignment: Alignment =
+ isStorylines || supportingContentLength < 4
+ ? 'vertical'
+ : 'horizontal';
+
return {
headlineSizes: {
desktop: useLargerHeadlineSizeDesktop ? 'xlarge' : 'large',
@@ -202,8 +213,7 @@ const decideSplashCardProperties = (
mediaPositionOnDesktop: 'right',
mediaPositionOnMobile: mediaCard ? 'top' : 'bottom',
mediaSize: avatarUrl ? 'large' : 'xlarge',
- supportingContentAlignment:
- supportingContentLength >= 4 ? 'horizontal' : 'vertical',
+ supportingContentAlignment: boostSupportingContentAlignment,
liveUpdatesAlignment: 'vertical',
trailTextSize: 'regular',
subtitleSize: 'medium',
@@ -256,6 +266,7 @@ type SplashCardLayoutProps = {
containerLevel: DCRContainerLevel;
collectionId: number;
enableHls?: boolean;
+ isStorylines?: boolean;
isInStarRatingVariant?: boolean;
};
@@ -270,6 +281,7 @@ const SplashCardLayout = ({
containerLevel,
collectionId,
enableHls,
+ isStorylines,
isInStarRatingVariant,
}: SplashCardLayoutProps) => {
const card = cards[0];
@@ -310,6 +322,7 @@ const SplashCardLayout = ({
isMediaCard(card.format),
useLargerHeadlineSizeDesktop,
!!card.avatarUrl,
+ isStorylines,
);
return (
@@ -356,6 +369,7 @@ const SplashCardLayout = ({
subtitleSize={subtitleSize}
headlinePosition={card.showLivePlayable ? 'outer' : 'inner'}
enableHls={enableHls}
+ isStorylines={isStorylines}
isInStarRatingVariant={isInStarRatingVariant}
starRatingSize={'medium'}
/>
@@ -424,6 +438,7 @@ type FullWidthCardLayoutProps = {
containerLevel: DCRContainerLevel;
collectionId: number;
enableHls?: boolean;
+ isStorylines?: boolean;
isInStarRatingVariant?: boolean;
};
@@ -439,6 +454,7 @@ const FullWidthCardLayout = ({
containerLevel,
collectionId,
enableHls,
+ isStorylines,
isInStarRatingVariant,
}: FullWidthCardLayoutProps) => {
const card = cards[0];
@@ -466,6 +482,7 @@ const FullWidthCardLayout = ({
serverTime={serverTime}
imageLoading={imageLoading}
collectionId={collectionId}
+ isStorylines={isStorylines}
isInStarRatingVariant={isInStarRatingVariant}
/>
);
@@ -516,6 +533,7 @@ const FullWidthCardLayout = ({
showKickerImage={card.format.design === ArticleDesign.Audio}
subtitleSize={subtitleSize}
enableHls={enableHls}
+ isStorylines={isStorylines}
isInStarRatingVariant={isInStarRatingVariant}
starRatingSize={'medium'}
/>
@@ -536,6 +554,7 @@ type HalfWidthCardLayoutProps = {
isLastRow: boolean;
containerLevel: DCRContainerLevel;
enableHls?: boolean;
+ isStorylines?: boolean;
isInStarRatingVariant?: boolean;
};
@@ -551,6 +570,7 @@ const HalfWidthCardLayout = ({
isLastRow,
containerLevel,
enableHls,
+ isStorylines,
isInStarRatingVariant,
}: HalfWidthCardLayoutProps) => {
if (cards.length === 0) return null;
@@ -607,6 +627,7 @@ const HalfWidthCardLayout = ({
headlineSizes={undefined}
canPlayInline={false}
enableHls={enableHls}
+ isStorylines={isStorylines}
isInStarRatingVariant={isInStarRatingVariant}
/>
@@ -626,6 +647,7 @@ export const FlexibleGeneral = ({
containerLevel = 'Primary',
collectionId,
enableHls,
+ isStorylines = false,
isInStarRatingVariant,
}: Props) => {
const splash = [...groupedTrails.splash].slice(0, 1).map((snap) => ({
@@ -656,6 +678,7 @@ export const FlexibleGeneral = ({
containerLevel={containerLevel}
collectionId={collectionId}
enableHls={enableHls}
+ isStorylines={isStorylines}
isInStarRatingVariant={isInStarRatingVariant}
/>
)}
@@ -676,6 +699,7 @@ export const FlexibleGeneral = ({
containerLevel={containerLevel}
collectionId={collectionId}
enableHls={enableHls}
+ isStorylines={isStorylines}
isInStarRatingVariant={isInStarRatingVariant}
/>
);
@@ -697,6 +721,7 @@ export const FlexibleGeneral = ({
isLastRow={i === groupedCards.length - 1}
containerLevel={containerLevel}
enableHls={enableHls}
+ isStorylines={isStorylines}
isInStarRatingVariant={isInStarRatingVariant}
/>
);
diff --git a/dotcom-rendering/src/components/StorylinesSection.tsx b/dotcom-rendering/src/components/StorylinesSection.tsx
new file mode 100644
index 00000000000..52139cdf924
--- /dev/null
+++ b/dotcom-rendering/src/components/StorylinesSection.tsx
@@ -0,0 +1,683 @@
+import { css } from '@emotion/react';
+import {
+ between,
+ from,
+ space,
+ textSans14,
+ until,
+} from '@guardian/source/foundations';
+import { Hide } from '@guardian/source/react-components';
+import { submitComponentEvent } from '../client/ophan/ophan';
+import { type EditionId, isNetworkFront } from '../lib/edition';
+import { palette as schemePalette } from '../palette';
+import type { DCRContainerLevel, DCRContainerPalette } from '../types/front';
+import { ContainerOverrides } from './ContainerOverrides';
+import { ContainerTitle } from './ContainerTitle';
+import { Footer } from './ExpandableAtom/Footer';
+import { FrontSectionTitle } from './FrontSectionTitle';
+import { ShowHideButton } from './ShowHideButton';
+
+type Props = {
+ /** This text will be used as the h2 shown in the left column for the section */
+ title?: string;
+ /** This text shows below the title */
+ description?: string;
+ /** The title can be made into a link using this property */
+ url?: string;
+ // collectionId?: string;
+ pageId?: string;
+ /** Defaults to `true`. If we should render the top border */
+ showTopBorder?: boolean;
+ children?: React.ReactNode;
+ /** The string used to set the `data-component` Ophan attribute */
+ ophanComponentName?: string;
+ /** The string used to set the `data-link-name` Ophan attribute */
+ ophanComponentLink?: string;
+ /**
+ * 🛠️ DEBUG ONLY 🛠️
+ * Used to highlight the name of a container when DCR debug mode is enabled
+ *
+ * @see https://github.com/guardian/dotcom-rendering/blob/main/dotcom-rendering/src/client/debug/README.md
+ */
+ containerName?: string;
+ /** Fronts containers can have their styling overridden using a `containerPalette` */
+ containerPalette?: DCRContainerPalette;
+ /** Fronts containers can have their styling overridden using a `containerLevel`.
+ * If used, this can be either "Primary" or "Secondary", both of which have different styles */
+ containerLevel?: DCRContainerLevel;
+ /** Fronts containers spacing rules vary depending on the size of their container spacing which is derived from if the next container is a primary or secondary. */
+ toggleable?: boolean;
+ /** Defaults to `false`. If true and `editionId` is also passed, then a date string is
+ * shown under the title. Typically only used on Headlines containers on fronts
+ */
+ showDateHeader?: boolean;
+ /** Used in partnership with `showDateHeader` to localise the date string */
+ editionId: EditionId;
+ isTagPage?: boolean;
+ hasNavigationButtons?: boolean;
+ likeHandler?: () => void;
+ dislikeHandler?: () => void;
+};
+
+const width = (columns: number, columnWidth: number, columnGap: number) =>
+ `width: ${columns * columnWidth + (columns - 1) * columnGap}px;`;
+
+const borderColourStyles = (
+ title?: string,
+ showSectionColours?: boolean,
+): string => {
+ if (!showSectionColours) {
+ return schemePalette('--section-border-primary');
+ }
+
+ switch (title) {
+ case 'News':
+ return schemePalette('--section-border-news');
+ case 'Opinion':
+ return schemePalette('--section-border-opinion');
+ case 'Sport':
+ case 'Sports':
+ return schemePalette('--section-border-sport');
+ case 'Lifestyle':
+ return schemePalette('--section-border-lifestyle');
+ case 'Culture':
+ return schemePalette('--section-border-culture');
+ default:
+ return schemePalette('--section-border-primary');
+ }
+};
+
+const articleSectionTitleStyles = (
+ title?: string,
+ showSectionColours?: boolean,
+): string => {
+ if (!showSectionColours) {
+ return schemePalette('--article-section-title');
+ }
+
+ switch (title) {
+ case 'News':
+ return schemePalette('--article-section-title-news');
+ case 'Opinion':
+ return schemePalette('--article-section-title-opinion');
+ case 'Sport':
+ case 'Sports':
+ return schemePalette('--article-section-title-sport');
+ case 'Lifestyle':
+ return schemePalette('--article-section-title-lifestyle');
+ case 'Culture':
+ return schemePalette('--article-section-title-culture');
+ default:
+ return schemePalette('--article-section-title');
+ }
+};
+
+/** Not all browsers support CSS grid, so we set explicit width as a fallback */
+const fallbackStyles = css`
+ @supports not (display: grid) {
+ padding: 0 12px;
+ margin: 0 auto;
+
+ ${from.mobileLandscape} {
+ padding: 0 20px;
+ }
+
+ ${from.tablet} {
+ ${width(12, 40, 20)}
+ }
+
+ ${from.desktop} {
+ ${width(12, 60, 20)}
+ }
+
+ ${from.leftCol} {
+ ${width(14, 60, 20)}
+ }
+
+ ${from.wide} {
+ ${width(16, 60, 20)}
+ }
+ }
+`;
+
+const containerStylesUntilLeftCol = css`
+ display: grid;
+
+ grid-template-rows:
+ [headline-start controls-start] auto
+ [controls-end headline-end content-toggleable-start content-start] auto
+ [content-end content-toggleable-end bottom-content-start] auto
+ [bottom-content-end];
+
+ grid-template-columns:
+ [decoration-start]
+ 0px
+ [content-start title-start]
+ repeat(3, minmax(0, 1fr))
+ [hide-start]
+ minmax(0, 1fr)
+ [content-end title-end hide-end]
+ 0px [decoration-end];
+
+ grid-auto-flow: dense;
+ column-gap: 10px;
+
+ ${from.mobileLandscape} {
+ column-gap: 20px;
+ }
+
+ ${from.tablet} {
+ grid-template-columns:
+ minmax(0, 1fr)
+ [decoration-start content-start title-start]
+ repeat(11, 40px)
+ [hide-start]
+ 40px
+ [decoration-end content-end title-end hide-end]
+ minmax(0, 1fr);
+ }
+
+ ${from.desktop} {
+ grid-template-columns:
+ minmax(0, 1fr)
+ [decoration-start content-start title-start]
+ repeat(11, 60px)
+ [hide-start]
+ 60px
+ [decoration-end content-end title-end hide-end]
+ minmax(0, 1fr);
+ }
+`;
+
+const containerScrollableStylesFromLeftCol = css`
+ ${between.leftCol.and.wide} {
+ grid-template-rows:
+ [headline-start controls-start] auto
+ [controls-end content-toggleable-start content-start] auto
+ [headline-end treats-start] auto
+ [content-end content-toggleable-end treats-end bottom-content-start] auto
+ [bottom-content-end];
+ }
+
+ ${from.wide} {
+ grid-template-rows:
+ [headline-start content-start content-toggleable-start controls-start] auto
+ [headline-end treats-start] auto
+ [content-end content-toggleable-end treats-end controls-end bottom-content-start] auto
+ [bottom-content-end];
+ }
+`;
+
+const containerStylesFromLeftCol = css`
+ ${from.leftCol} {
+ grid-template-rows:
+ [headline-start controls-start content-start] auto
+ [controls-end content-toggleable-start] auto
+ [headline-end treats-start] auto
+ [content-end content-toggleable-end treats-end bottom-content-start] auto
+ [bottom-content-end];
+
+ grid-template-columns:
+ minmax(0, 1fr)
+ [decoration-start title-start]
+ repeat(2, 60px)
+ [title-end content-start]
+ repeat(11, 60px)
+ [hide-start]
+ 60px
+ [decoration-end hide-end content-end]
+ minmax(0, 1fr);
+ }
+
+ ${from.wide} {
+ grid-template-rows:
+ [headline-start content-start content-toggleable-start controls-start] auto
+ [controls-end] auto
+ [headline-end treats-start] auto
+ [content-end content-toggleable-end treats-end bottom-content-start] auto
+ [bottom-content-end];
+
+ grid-template-columns:
+ minmax(0, 1fr)
+ [decoration-start title-start]
+ repeat(3, 60px)
+ [title-end content-start]
+ repeat(12, 60px)
+ [content-end hide-start]
+ 60px
+ [decoration-end hide-end]
+ minmax(0, 1fr);
+ }
+`;
+
+const flexRowStyles = css`
+ flex-direction: row;
+ justify-content: space-between;
+`;
+
+const sectionHeadlineUntilLeftCol = (isOpinion: boolean) => css`
+ grid-row: headline;
+ grid-column: title;
+ display: flex;
+ flex-direction: column;
+
+ ${between.tablet.and.leftCol} {
+ ${flexRowStyles}
+ }
+
+ ${isOpinion && until.mobileLandscape} {
+ flex-direction: column;
+ }
+ ${isOpinion && between.mobileLandscape.and.tablet} {
+ ${flexRowStyles}
+ }
+`;
+
+const topPadding = css`
+ padding-top: ${space[2]}px;
+`;
+
+const sectionControls = css`
+ grid-row: controls;
+ grid-column: hide;
+ justify-self: end;
+ display: flex;
+ padding-top: ${space[2]}px;
+ ${from.wide} {
+ flex-direction: column-reverse;
+ justify-content: flex-end;
+ align-items: flex-end;
+ gap: ${space[2]}px;
+ /* we want to add space between the items in the controls section only when there are at least 2 children and neither are hidden */
+ :has(> :not(.hidden):nth-of-type(2)) {
+ justify-content: space-between;
+ }
+ }
+`;
+
+const sectionContent = css`
+ margin: 0;
+
+ .hidden > & {
+ display: none;
+ }
+
+ grid-column: content;
+`;
+
+const sectionContentRow = (toggleable: boolean) => css`
+ grid-row: ${toggleable ? 'content-toggleable' : 'content'};
+`;
+
+const sectionContentHorizontalMargins = css`
+ ${from.tablet} {
+ margin-left: -10px;
+ margin-right: -10px;
+ }
+`;
+
+const sectionContentBorderFromLeftCol = css`
+ position: relative;
+ ${from.leftCol} {
+ ::before {
+ content: '';
+ position: absolute;
+ top: ${space[2]}px;
+ bottom: 0;
+ border-left: 1px solid ${schemePalette('--section-border')};
+ transform: translateX(-50%);
+ /** Keeps the vertical divider ontop of carousel item dividers */
+ z-index: 1;
+ }
+ }
+`;
+
+const sectionFooter = css`
+ /* Mobile: treats appear at the bottom */
+ grid-row: bottom-content;
+ grid-column: content;
+ ${from.leftCol} {
+ padding-top: ${space[2]}px;
+ }
+ padding-bottom: ${space[3]}px;
+
+ ${from.leftCol} {
+ align-self: end;
+ grid-row: treats;
+ grid-column: title;
+ padding-top: 0;
+ }
+
+ .hidden > & {
+ display: none;
+ }
+`;
+
+const decoration = css`
+ /** element which contains border and inner background colour, if set */
+ grid-row: 1 / -1;
+ grid-column: decoration;
+
+ border-width: 1px;
+ border-color: ${schemePalette('--section-border')};
+ border-style: none;
+`;
+
+/** only visible once content stops sticking to left and right edges */
+const sideBorders = css`
+ ${from.tablet} {
+ margin: 0 -20px;
+ border-left-style: solid;
+ border-right-style: solid;
+ }
+`;
+
+const topBorder = css`
+ border-top-style: solid;
+`;
+
+const primaryLevelTopBorder = (
+ title?: string,
+ showSectionColours?: boolean,
+) => css`
+ grid-row: 1;
+ grid-column: 1 / -1;
+ border-top: 2px solid ${borderColourStyles(title, showSectionColours)};
+ /** Ensures the top border sits above the side borders */
+ z-index: 1;
+ height: fit-content;
+`;
+
+const secondaryLevelTopBorder = css`
+ grid-row: 1;
+ grid-column: content;
+ border-top: 1px solid ${schemePalette('--section-border-secondary')};
+ ${from.tablet} {
+ grid-column: decoration;
+ }
+`;
+
+const carouselNavigationPlaceholder = css`
+ .hidden & {
+ display: none;
+ }
+`;
+
+/**
+ * # Front Container
+ *
+ * A container for the storylines content on tag pages,
+ * which contains sets of cards.
+ *
+ * Provides borders, spacing, colours, a title and features specific to fronts
+ * such as show/hide toggle button. Content slotted as `children` is placed
+ * in the centre. Extra elements can be passed to `leftContent`, which will
+ * automatically fall into the left column on larger breakpoints.
+ *
+ * Defaults to an HTML `section`, but the specific tag can be set.
+ *
+ * @example
+ *
+ * from `mobile` (320) to `phablet` (660)
+ * 1 2 3 4
+ * ┌───────┐
+ * │Title │
+ * ├───────┤
+ * │▒▒▒▒▒▒▒│
+ * │▒▒▒▒▒▒▒│
+ * ├───────┤
+ * │AI Note│
+ * │Context│
+ * │Footer │
+ * └───────┘
+ *
+ * from `tablet` (740) to `desktop` (980)
+ *
+ * 1 2 3 4 5 6 7 8 9 a b c (12)
+ * ┌───────────────────────┐
+ * │Title │
+ * ├───────────────────────┤
+ * │▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒│
+ * │▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒│
+ * ├───────────────────────┤
+ * │AI note,context,footer │
+ * └───────────────────────┘
+ *
+ * on `leftCol` (1140) if component is toggleable
+ *
+ * 1 2 3 4 5 6 7 8 9 a b c d e (14)
+ * ┌───┬───────────────────┬─┐
+ * │Tit│ │X│
+ * │AI ├───────────────────┴─┤
+ * ├───┤▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒│
+ * │ │▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒│
+ * │Foo│▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒│
+ * │ter│Content context info │
+ * ├───┼─────────────────────┤
+ *
+ * on `leftCol` (1140) if component is not toggleable
+ *
+ * 1 2 3 4 5 6 7 8 9 a b c d e (14)
+ * ┌───┬──────────────────────┐
+ * │Tit│▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒│
+ * │AI │▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒│
+ * ├───┤▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒│
+ * │ │▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒│
+ * │Foo│▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒│
+ * │ter│Content context info │
+ * ├───┼──────────────────────┤
+ *
+ * on `wide` (1300)
+ *
+ * 1 2 3 4 5 6 7 8 9 a b c d e f g (16)
+ * ┌──────┬───────────────────────┬─┐
+ * │Title │▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒│X│
+ * │AI │▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒└─┤
+ * │Notice│▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒ │
+ * ├─────-┤▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒ │
+ * │ │▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒ │
+ * │ │▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒ │
+ * │Footer│Content context info │
+ * ├────────────────────────────────┤
+ *
+ */
+
+/**
+ * This is based on @see {FrontSection.tsx} but with some modifications:
+ * - Some text has been added regarding the use of AI in this section
+ * - In a frontsection, the background normally takes up the full width of the page, but we want just the section to have the grey background
+ * - Instead of treats, we have a footer with like/dislike buttons
+ * - A portion of the props and logic in frontsection aren't relevant here
+ */
+export const StorylinesSection = ({
+ title,
+ children,
+ containerName,
+ containerPalette,
+ containerLevel,
+ description,
+ editionId,
+ ophanComponentLink,
+ ophanComponentName,
+ pageId,
+ showDateHeader = false,
+ showTopBorder = true,
+ toggleable = false,
+ url,
+ isTagPage = false,
+ hasNavigationButtons = false,
+ dislikeHandler,
+ likeHandler,
+}: Props) => {
+ const sectionId = 'storylines-section';
+ const isToggleable = toggleable && !!sectionId;
+ const isBetaContainer = !!containerLevel;
+
+ const showSectionColours = isNetworkFront(pageId ?? '');
+
+ /**
+ * In a front section, id is being used to set the containerId in @see {ShowMore.importable.tsx}
+ * We don't use show more here, however as noted in the front section component:
+ * this id pre-existed showMore so is probably also being used for something else.
+ */
+ return (
+
+
+ {isBetaContainer && showTopBorder && (
+
+ )}
+
+
+
+
+
+
+
+ Dive deeper into the Guardian's archive.{' '}
+
+
+ This product uses GenAI. Learn more
+ about how it works{' '}
+
+ here.
+
+
+
+
+ >
+ }
+ collectionBranding={undefined}
+ />
+
+
+ {(isToggleable || hasNavigationButtons) && (
+
+ {isToggleable && (
+
+ )}
+ {hasNavigationButtons && (
+
+ )}
+
+ )}
+
+
+ {children}
+
+
+
+
+ submitComponentEvent(
+ {
+ component: {
+ componentType: 'STORYLINES',
+ id: sectionId,
+ products: [],
+ labels: [],
+ },
+ action: 'DISLIKE',
+ },
+ 'Web',
+ ))
+ }
+ likeHandler={
+ likeHandler ??
+ (() =>
+ submitComponentEvent(
+ {
+ component: {
+ componentType: 'STORYLINES',
+ id: sectionId,
+ products: [],
+ labels: [],
+ },
+ action: 'LIKE',
+ },
+ 'Web',
+ ))
+ }
+ >
+
+
+
+ );
+};
diff --git a/dotcom-rendering/src/components/StorylinesSectionContent.importable.tsx b/dotcom-rendering/src/components/StorylinesSectionContent.importable.tsx
new file mode 100644
index 00000000000..b7b164214be
--- /dev/null
+++ b/dotcom-rendering/src/components/StorylinesSectionContent.importable.tsx
@@ -0,0 +1,312 @@
+import { css } from '@emotion/react';
+import {
+ from,
+ headlineLight50,
+ palette as sourcePalette,
+ space,
+ textSans14,
+ textSans17,
+ textSans20,
+ textSansBold34,
+} from '@guardian/source/foundations';
+import { Hide } from '@guardian/source/react-components';
+import { useState } from 'react';
+import type { EditionId } from '../lib/edition';
+import { parseStorylinesContentToStorylines } from '../model/enhanceTagPageStorylinesContent';
+import { palette } from '../palette';
+import type { StorylinesContent } from '../types/storylinesContent';
+import { FlexibleGeneral } from './FlexibleGeneral';
+import { ScrollableCarousel } from './ScrollableCarousel';
+import { StorylinesSection } from './StorylinesSection';
+
+type StorylinesSectionProps = {
+ url?: string;
+ index: number;
+ containerId?: string;
+ editionId: EditionId;
+ storylinesContent?: StorylinesContent;
+ pillar?: string;
+};
+
+// AIStorylines: this would be better handled in paletteDeclarations by creating a new css variable if we keep this feature.
+const setSelectedStorylineColour = (pillar?: string) => {
+ switch (pillar?.toLowerCase()) {
+ case 'news':
+ return sourcePalette.news[400];
+ case 'opinion':
+ return sourcePalette.opinion[400];
+ case 'sport':
+ return sourcePalette.sport[400];
+ case 'culture':
+ return sourcePalette.culture[400];
+ case 'lifestyle':
+ return sourcePalette.lifestyle[400];
+ default:
+ return sourcePalette.news[400];
+ }
+};
+
+const selectedTitleStyles = (selectedStorylineColour: string) => css`
+ ${textSansBold34}
+ color: ${selectedStorylineColour};
+ margin-bottom: ${space[4]}px;
+ margin-top: ${space[2]}px;
+ padding-left: 10px; /* aligns with the headlines of the stories below */
+`;
+
+const setCategoryColour = (pillar?: string) => {
+ switch (pillar?.toLowerCase()) {
+ case 'news':
+ return sourcePalette.news[300];
+ case 'opinion':
+ return sourcePalette.opinion[400];
+ case 'sport':
+ return sourcePalette.sport[300];
+ case 'culture':
+ return sourcePalette.culture[300];
+ case 'lifestyle':
+ return sourcePalette.lifestyle[300];
+ default:
+ return sourcePalette.news[300];
+ }
+};
+
+const categoryTitleCss = (pillarColour: string) => css`
+ ${textSans20};
+ font-weight: 700;
+ color: ${pillarColour};
+ margin: ${space[2]}px 0;
+ padding: ${space[1]}px 0;
+ ${from.tablet} {
+ margin: 10px;
+ }
+
+ border-top: 1px solid ${palette('--section-border-secondary')};
+`;
+
+const contentCss = css`
+ margin-bottom: ${space[4]}px;
+ ${from.leftCol} {
+ border-left: 1px solid ${sourcePalette.neutral[86]};
+ }
+`;
+
+const tabsContainerStyles = css`
+ display: flex;
+ width: 100%;
+ ${from.wide} {
+ width: 110%;
+ } /* bit hacky, but looks a touch better on wide. */
+ align-items: stretch; /* Makes all tabs the same height */
+ margin-bottom: ${space[6]}px;
+ margin-left: -${space[2]}px;
+`;
+
+const tabStyles = (isActive: boolean, isFirst: boolean) => css`
+ ${textSans17};
+ font-weight: 700;
+ text-align: start;
+ padding: ${space[0]}px ${space[0]}px ${space[0]}px ${space[2]}px;
+ cursor: pointer;
+ border: none;
+ ${!isFirst && `border-left: 1px ${sourcePalette.neutral[86]} solid;`}
+ color: ${isActive
+ ? `${sourcePalette.neutral[60]}`
+ : `${sourcePalette.neutral[38]}`};
+ flex: 1;
+ min-width: 0;
+ display: flex;
+ align-items: flex-start; /* Aligns text to the top of each tab */
+`;
+
+const contentStyles = css`
+ padding-top: ${space[0]}px 0;
+`;
+
+const numberStyles = css`
+ ${headlineLight50}
+ line-height: 2rem; /* to align the number with the top of the text */
+ margin-left: -${space[1]}px;
+ margin-right: ${space[2]}px;
+`;
+
+const articleDateRangeStyle = css`
+ ${textSans14}
+ margin-bottom: ${space[4]}px;
+ margin-top: ${space[2]}px;
+ ${from.tablet} {
+ margin-left: ${space[2]}px;
+ }
+`;
+
+function formatDateRangeText(
+ earliestArticleTime?: string | null,
+ latestArticleTime?: string | null,
+): string {
+ const earliest = earliestArticleTime ? new Date(earliestArticleTime) : null;
+ const latest = latestArticleTime ? new Date(latestArticleTime) : null;
+ const format = (d?: Date | null) => {
+ if (!d) return '';
+ const day = d.getDate();
+ const suffix = (dayNum: number) => {
+ if (dayNum > 3 && dayNum < 21) return 'th';
+ switch (dayNum % 10) {
+ case 1:
+ return 'st';
+ case 2:
+ return 'nd';
+ case 3:
+ return 'rd';
+ default:
+ return 'th';
+ }
+ };
+ return `${day}${suffix(day)} ${d.toLocaleDateString('en-GB', {
+ month: 'long',
+ year: 'numeric',
+ })}`;
+ };
+
+ if (earliest) {
+ return `articles published since ${format(earliest)}`;
+ } else if (latest) {
+ return `articles published up to ${format(latest)}`;
+ } else {
+ return 'recent articles in our archives';
+ }
+}
+
+/**
+ * Used to display the content of the storylines section on specific tag pages.
+ *
+ * ## Why does this need to be an Island?
+ *
+ * Selecting a storyline via the tabs (a carousel on mobile) requires javascript.
+ */
+export const StorylinesSectionContent = ({
+ url,
+ index,
+ containerId,
+ storylinesContent,
+ editionId,
+ pillar,
+}: StorylinesSectionProps) => {
+ const parsedStorylines =
+ storylinesContent &&
+ parseStorylinesContentToStorylines(storylinesContent);
+
+ const [activeStorylineId, setActiveStorylineId] = useState(
+ parsedStorylines?.[0]?.id ?? '',
+ );
+
+ if (!parsedStorylines || parsedStorylines.length === 0) {
+ return null;
+ }
+
+ const activeStoryline = parsedStorylines.find(
+ (s) => s.id === activeStorylineId,
+ );
+
+ const selectedStorylineColour = setSelectedStorylineColour(pillar);
+
+ const categoryColour = setCategoryColour(pillar);
+
+ return (
+ <>
+
+ {/* Storylines tab selector. This is a carousel on mobile. */}
+
+
+ {parsedStorylines.map((storyline, i) => (
+
+ setActiveStorylineId(storyline.id)
+ }
+ type="button"
+ >
+ {activeStorylineId === storyline.id ? (
+ <>
+ {i + 1}
+ {storyline.title}
+ >
+ ) : (
+ <>
+ {i + 1}
+ {storyline.title}
+ >
+ )}
+
+ ))}
+
+
+ {/* Active storyline title */}
+ {activeStoryline && (
+
+ {activeStoryline.title}
+
+ )}
+ {/* Content by categories */}
+
+ {activeStoryline?.categories.map((category, idx) => (
+
+ {category.title !== 'Key Stories' && (
+
+ {category.title}
+
+ )}
+
+
+ ))}
+
+ {/* Context on article date range and mobile AI disclaimer */}
+
+
+
+ This product uses GenAI. Learn more about how it
+ works{' '}
+
+ here
+
+ .{' '}
+
+
+ {`These storylines were curated from ${formatDateRangeText(
+ storylinesContent.earliestArticleTime,
+ storylinesContent.latestArticleTime,
+ )}. Some articles may be older to provide further context.`}
+
+
+ >
+ );
+};
diff --git a/dotcom-rendering/src/components/StorylinesSectionContent.stories.tsx b/dotcom-rendering/src/components/StorylinesSectionContent.stories.tsx
new file mode 100644
index 00000000000..739218dd9bb
--- /dev/null
+++ b/dotcom-rendering/src/components/StorylinesSectionContent.stories.tsx
@@ -0,0 +1,22 @@
+import type { Meta, StoryObj } from '@storybook/react-webpack5';
+import { mockStorylinesSectionContent } from '../../fixtures/manual/storylines-section';
+import { StorylinesSectionContent } from './StorylinesSectionContent.importable';
+
+const meta = {
+ component: StorylinesSectionContent,
+ title: 'Components/StorylinesSectionContent',
+ args: {
+ url: 'https://www.theguardian.com/technology/artificialintelligenceai',
+ index: 1,
+ containerId: 'container-1 | storylines-section',
+ editionId: 'UK',
+ storylinesContent: mockStorylinesSectionContent,
+ },
+ render: (args) => ,
+} satisfies Meta;
+
+export default meta;
+
+type Story = StoryObj;
+
+export const Default = {} satisfies Story;
diff --git a/dotcom-rendering/src/components/SupportingKeyStoriesContent.tsx b/dotcom-rendering/src/components/SupportingKeyStoriesContent.tsx
new file mode 100644
index 00000000000..0657adc161d
--- /dev/null
+++ b/dotcom-rendering/src/components/SupportingKeyStoriesContent.tsx
@@ -0,0 +1,215 @@
+import { css } from '@emotion/react';
+import { from, space, until } from '@guardian/source/foundations';
+import { ArticleDesign } from '../lib/articleFormat';
+import { palette } from '../palette';
+import type { DCRContainerPalette, DCRSupportingContent } from '../types/front';
+import { CardAge } from './Card/components/CardAge';
+import { CardHeadline } from './CardHeadline';
+import { ContainerOverrides } from './ContainerOverrides';
+import { FormatBoundary } from './FormatBoundary';
+import type { Alignment } from './SupportingContent';
+
+type Props = {
+ supportingContent: DCRSupportingContent[];
+ /** Determines if the content is arranged vertically or horizontally */
+ alignment: Alignment;
+ containerPalette?: DCRContainerPalette;
+ isMedia?: boolean;
+ /** Allows sublinks container to have a background colour on mobile screen sizes */
+ fillBackgroundOnMobile?: boolean;
+ /** Allows sublinks container to have a background colour on desktop screen sizes */
+ fillBackgroundOnDesktop?: boolean;
+ isStorylines?: boolean;
+};
+
+/**
+ * Returns the column span for the grid layout based on the number of supporting content items.
+ *
+ * @param {number} contentLength - The number of supporting content items.
+ * @returns {number} The column span to use in the grid layout.
+ */
+const getColumnSpan = (contentLength: number): number => {
+ switch (contentLength) {
+ case 1:
+ case 2:
+ return 6;
+ case 3:
+ return 4;
+ case 4:
+ default:
+ return 3; // Default column span for 4 or more items
+ }
+};
+const baseGrid = css`
+ display: grid;
+ grid-template-rows: auto;
+ grid-template-columns: auto;
+ row-gap: ${space[2]}px;
+`;
+
+const horizontalGrid = css`
+ ${from.tablet} {
+ grid-template-columns: repeat(12, 1fr);
+ column-gap: ${space[5]}px;
+ }
+`;
+
+const horizontalLineStyle = css`
+ :not(:first-child) {
+ margin-top: ${space[3]}px;
+ }
+ :not(:first-child)::before {
+ position: absolute;
+ top: -${space[2]}px;
+ left: 0;
+ content: '';
+ border-top: 1px solid ${palette('--card-border-top')};
+ height: 1px;
+ width: 50%;
+ ${from.tablet} {
+ width: 100px;
+ }
+ ${from.desktop} {
+ width: 140px;
+ }
+ }
+`;
+
+const sublinkBaseStyles = css`
+ position: relative;
+ grid-row: span 1;
+`;
+
+const verticalSublinkStyles = css`
+ ${horizontalLineStyle}
+
+ ${from.tablet} {
+ :first-child {
+ ${horizontalLineStyle}
+ }
+ }
+`;
+
+const horizontalSublinkStyles = (totalColumns: number) => css`
+ grid-column: span ${totalColumns};
+ ${until.tablet} {
+ ${horizontalLineStyle}
+ }
+`;
+
+const wrapperStyles = css`
+ position: relative;
+ @media (pointer: coarse) {
+ padding-bottom: 0;
+ }
+ ${until.leftCol} {
+ margin-top: ${space[2]}px;
+ }
+`;
+
+const backgroundFillMobile = (isMedia: boolean) => css`
+ ${until.tablet} {
+ padding: ${space[2]}px;
+ padding-bottom: ${space[3]}px;
+ background-color: ${isMedia
+ ? palette('--card-media-sublinks-background')
+ : palette('--card-sublinks-background')};
+ }
+`;
+
+const backgroundFillDesktop = (isMedia: boolean) => css`
+ ${from.tablet} {
+ padding: ${space[0]}px;
+ padding-bottom: ${space[3]}px;
+ background-color: ${isMedia
+ ? palette('--card-media-sublinks-background')
+ : palette('--card-sublinks-background')};
+ }
+`;
+
+/** In the storylines section on tag pages, the flex splash is used to display key stories.
+ This is shown as a large image taken from the first article in the group, and the headlines of the first four key articles (include that of the first article).
+ Therefore, we don't display an article headline in the conventional sense, these are displayed as "supporting content".
+*/
+export const SupportingKeyStoriesContent = ({
+ supportingContent,
+ alignment,
+ containerPalette,
+ isMedia = false,
+ fillBackgroundOnMobile = false,
+ fillBackgroundOnDesktop = false,
+ isStorylines = false,
+}: Props) => {
+ const columnSpan = getColumnSpan(supportingContent.length);
+ return (
+
+ {supportingContent.map((subLink, index) => {
+ // The model has this property as optional but it is very likely to exist
+ if (!subLink.headline) return null;
+
+ /** Force the format design to be Standard to ensure
+ * it is compatible with transparent backgrounds */
+ const subLinkFormat = {
+ ...subLink.format,
+ design: ArticleDesign.Standard,
+ };
+
+ return (
+
+
+
+
+
+
+
+
+
+
+ );
+ })}
+
+ );
+};
diff --git a/dotcom-rendering/src/frontend/feTagPage.ts b/dotcom-rendering/src/frontend/feTagPage.ts
index 7a43149e410..c6d2bdfd194 100644
--- a/dotcom-rendering/src/frontend/feTagPage.ts
+++ b/dotcom-rendering/src/frontend/feTagPage.ts
@@ -2,6 +2,7 @@ import type { EditionId } from '../lib/edition';
import type { CommercialProperties } from '../types/commercial';
import type { FooterType } from '../types/footer';
import type { FENavType } from '../types/frontend';
+import type { StorylinesContent } from '../types/storylinesContent';
import type { FEPagination, FETagType } from '../types/tag';
import type { FEFrontCard, FEFrontConfig } from './feFront';
@@ -25,4 +26,5 @@ export type FETagPage = {
forceDay: boolean;
canonicalUrl?: string;
contributionsServiceUrl: string;
+ storylinesContent?: StorylinesContent;
};
diff --git a/dotcom-rendering/src/frontend/schemas/feTagPage.json b/dotcom-rendering/src/frontend/schemas/feTagPage.json
index 376422eac0b..5fb3429c72b 100644
--- a/dotcom-rendering/src/frontend/schemas/feTagPage.json
+++ b/dotcom-rendering/src/frontend/schemas/feTagPage.json
@@ -1283,6 +1283,108 @@
},
"contributionsServiceUrl": {
"type": "string"
+ },
+ "storylinesContent": {
+ "type": "object",
+ "properties": {
+ "created": {
+ "type": "string"
+ },
+ "tag": {
+ "type": "string"
+ },
+ "storylines": {
+ "type": "array",
+ "items": {
+ "type": "object",
+ "properties": {
+ "title": {
+ "type": "string"
+ },
+ "content": {
+ "type": "array",
+ "items": {
+ "type": "object",
+ "properties": {
+ "category": {
+ "type": "string"
+ },
+ "articles": {
+ "type": "array",
+ "items": {
+ "type": "object",
+ "properties": {
+ "url": {
+ "type": "string"
+ },
+ "headline": {
+ "type": "string"
+ },
+ "byline": {
+ "type": "string"
+ },
+ "publicationTime": {
+ "type": "string"
+ },
+ "image": {
+ "type": "object",
+ "properties": {
+ "src": {
+ "type": "string"
+ },
+ "altText": {
+ "type": "string"
+ },
+ "isAvatar": {
+ "type": "boolean"
+ },
+ "mediaData": {
+ "$ref": "#/definitions/MainMedia"
+ }
+ },
+ "required": [
+ "altText",
+ "isAvatar",
+ "src"
+ ]
+ }
+ },
+ "required": [
+ "headline",
+ "publicationTime",
+ "url"
+ ]
+ }
+ }
+ },
+ "required": [
+ "articles",
+ "category"
+ ]
+ }
+ }
+ },
+ "required": [
+ "content",
+ "title"
+ ]
+ }
+ },
+ "articleCount": {
+ "type": "number"
+ },
+ "earliestArticleTime": {
+ "type": "string"
+ },
+ "latestArticleTime": {
+ "type": "string"
+ }
+ },
+ "required": [
+ "created",
+ "storylines",
+ "tag"
+ ]
}
},
"required": [
@@ -2011,6 +2113,271 @@
"text",
"url"
]
+ },
+ "MainMedia": {
+ "anyOf": [
+ {
+ "$ref": "#/definitions/YoutubeVideo",
+ "description": "For displaying embedded, playable videos directly in cards"
+ },
+ {
+ "$ref": "#/definitions/SelfHostedVideo"
+ },
+ {
+ "$ref": "#/definitions/Audio"
+ },
+ {
+ "$ref": "#/definitions/Gallery"
+ }
+ ]
+ },
+ "YoutubeVideo": {
+ "description": "For displaying embedded, playable videos directly in cards",
+ "allOf": [
+ {
+ "type": "object",
+ "properties": {
+ "type": {
+ "enum": [
+ "Audio",
+ "Gallery",
+ "SelfHostedVideo",
+ "YoutubeVideo"
+ ],
+ "type": "string"
+ }
+ },
+ "required": [
+ "type"
+ ]
+ },
+ {
+ "type": "object",
+ "properties": {
+ "type": {
+ "type": "string",
+ "const": "YoutubeVideo"
+ },
+ "id": {
+ "type": "string"
+ },
+ "videoId": {
+ "type": "string"
+ },
+ "height": {
+ "type": "number"
+ },
+ "width": {
+ "type": "number"
+ },
+ "origin": {
+ "type": "string"
+ },
+ "title": {
+ "type": "string"
+ },
+ "duration": {
+ "type": "number"
+ },
+ "expired": {
+ "type": "boolean"
+ },
+ "image": {
+ "type": "string"
+ }
+ },
+ "required": [
+ "duration",
+ "expired",
+ "height",
+ "id",
+ "origin",
+ "title",
+ "type",
+ "videoId",
+ "width"
+ ]
+ }
+ ]
+ },
+ "SelfHostedVideo": {
+ "allOf": [
+ {
+ "type": "object",
+ "properties": {
+ "type": {
+ "enum": [
+ "Audio",
+ "Gallery",
+ "SelfHostedVideo",
+ "YoutubeVideo"
+ ],
+ "type": "string"
+ }
+ },
+ "required": [
+ "type"
+ ]
+ },
+ {
+ "type": "object",
+ "properties": {
+ "type": {
+ "type": "string",
+ "const": "SelfHostedVideo"
+ },
+ "videoStyle": {
+ "$ref": "#/definitions/VideoPlayerFormat"
+ },
+ "atomId": {
+ "type": "string"
+ },
+ "sources": {
+ "type": "array",
+ "items": {
+ "type": "object",
+ "properties": {
+ "src": {
+ "type": "string"
+ },
+ "mimeType": {
+ "$ref": "#/definitions/SupportedVideoFileType"
+ }
+ },
+ "required": [
+ "mimeType",
+ "src"
+ ]
+ }
+ },
+ "height": {
+ "type": "number"
+ },
+ "width": {
+ "type": "number"
+ },
+ "duration": {
+ "type": "number"
+ },
+ "subtitleSource": {
+ "type": "string"
+ },
+ "image": {
+ "type": "string"
+ }
+ },
+ "required": [
+ "atomId",
+ "duration",
+ "height",
+ "sources",
+ "type",
+ "videoStyle",
+ "width"
+ ]
+ }
+ ]
+ },
+ "VideoPlayerFormat": {
+ "enum": [
+ "Cinemagraph",
+ "Default",
+ "Loop"
+ ],
+ "type": "string"
+ },
+ "SupportedVideoFileType": {
+ "enum": [
+ "application/vnd.apple.mpegurl",
+ "application/x-mpegURL",
+ "video/mp4"
+ ],
+ "type": "string"
+ },
+ "Audio": {
+ "allOf": [
+ {
+ "type": "object",
+ "properties": {
+ "type": {
+ "enum": [
+ "Audio",
+ "Gallery",
+ "SelfHostedVideo",
+ "YoutubeVideo"
+ ],
+ "type": "string"
+ }
+ },
+ "required": [
+ "type"
+ ]
+ },
+ {
+ "type": "object",
+ "properties": {
+ "type": {
+ "type": "string",
+ "const": "Audio"
+ },
+ "duration": {
+ "type": "string"
+ },
+ "podcastImage": {
+ "type": "object",
+ "properties": {
+ "src": {
+ "type": "string"
+ },
+ "altText": {
+ "type": "string"
+ }
+ }
+ }
+ },
+ "required": [
+ "duration",
+ "type"
+ ]
+ }
+ ]
+ },
+ "Gallery": {
+ "allOf": [
+ {
+ "type": "object",
+ "properties": {
+ "type": {
+ "enum": [
+ "Audio",
+ "Gallery",
+ "SelfHostedVideo",
+ "YoutubeVideo"
+ ],
+ "type": "string"
+ }
+ },
+ "required": [
+ "type"
+ ]
+ },
+ {
+ "type": "object",
+ "properties": {
+ "type": {
+ "type": "string",
+ "const": "Gallery"
+ },
+ "count": {
+ "type": "string"
+ }
+ },
+ "required": [
+ "count",
+ "type"
+ ]
+ }
+ ]
}
},
"$schema": "http://json-schema.org/draft-07/schema#"
diff --git a/dotcom-rendering/src/layouts/TagPageLayout.tsx b/dotcom-rendering/src/layouts/TagPageLayout.tsx
index 0aaa8e2d24c..6398798542f 100644
--- a/dotcom-rendering/src/layouts/TagPageLayout.tsx
+++ b/dotcom-rendering/src/layouts/TagPageLayout.tsx
@@ -15,6 +15,7 @@ import { Island } from '../components/Island';
import { Masthead } from '../components/Masthead/Masthead';
import { Section } from '../components/Section';
import { StickyBottomBanner } from '../components/StickyBottomBanner.importable';
+import { StorylinesSectionContent } from '../components/StorylinesSectionContent.importable';
import { SubNav } from '../components/SubNav.importable';
import { TagPageHeader } from '../components/TagPageHeader';
import { TrendingTopics } from '../components/TrendingTopics';
@@ -134,6 +135,13 @@ export const TagPageLayout = ({ tagPage, NAV }: Props) => {
)
: undefined;
+ // AIStorylines logic to determine where to insert the section
+ const insertStorylinesSection =
+ tagPage.storylinesContent &&
+ (!tagPage.pagination ||
+ tagPage.pagination.currentPage === 1) && // Only on the first page
+ (index === 1 || tagPage.groupedTrails.length === 1); // After the first section or if there's only one section on the page
+
return (
{desktopAdPositions.includes(index) && (
@@ -145,6 +153,19 @@ export const TagPageLayout = ({ tagPage, NAV }: Props) => {
)}
/>
)}
+ {insertStorylinesSection && (
+
+
+
+ )}
{
+ return {
+ headline: article.headline,
+ url: article.url,
+ kickerText: '',
+ format: { design: 0, display: 0, theme: 0 },
+ webPublicationDate: article.publicationTime,
+ };
+ });
+
+ return {
+ format: { design: 0, display: 0, theme: 0 },
+ dataLinkName: '', // the actual links to articles are in supportingContent
+ url: category.articles[0]?.url ?? '',
+ headline: '',
+ trailText: '',
+ webPublicationDate: '',
+ supportingContent,
+ discussionApiUrl: 'https://discussion.theguardian.com/discussion-api',
+ byline: category.articles[0]?.byline ?? '',
+ showByline: false,
+ boostLevel: 'boost',
+ isImmersive: false,
+ showQuotedHeadline: false,
+ showLivePlayable: false,
+ avatarUrl: undefined,
+ mainMedia: undefined,
+ isExternalLink: false,
+ image: category.articles[0]?.image
+ ? {
+ src: category.articles[0]?.image.src,
+ altText: category.articles[0]?.image.altText || '',
+ }
+ : undefined,
+ };
+}
+
+function decideGroupedTrails(
+ category: CategoryContent,
+ categoryIndex: number,
+): DCRGroupedTrails {
+ if (category.category === 'Key Stories') {
+ return {
+ splash: [parseKeyStoriesToFrontCard(category)],
+ huge: [],
+ veryBig: [],
+ big: [],
+ snap: [],
+ standard: [],
+ };
+ } else if (
+ category.category === 'Profiles and Interviews' ||
+ category.category === 'Deep Reads'
+ ) {
+ const frontCards = category.articles
+ .slice(0, 1)
+ .map((article, index) =>
+ parseArticleDataToFrontCard(
+ category,
+ article,
+ categoryIndex,
+ index,
+ ),
+ );
+ return {
+ splash: [],
+ huge: [],
+ veryBig: [],
+ big: [],
+ snap: [],
+ standard: frontCards,
+ };
+ } else {
+ const frontCards = category.articles
+ .slice(0, 2)
+ .map((article, index) =>
+ parseArticleDataToFrontCard(
+ category,
+ article,
+ categoryIndex,
+ index,
+ ),
+ );
+ return {
+ splash: [],
+ huge: [],
+ veryBig: [],
+ big: [],
+ snap: [],
+ standard: frontCards,
+ };
+ }
+}
+
+export function parseStorylinesContentToStorylines(
+ data: StorylinesContent,
+): ParsedStoryline[] {
+ function decideCategoryTitle(category: CategoryContent): string {
+ switch (category.category) {
+ case 'Key Stories':
+ return 'Key Stories';
+ case 'Contrasting opinions':
+ return 'Opinions';
+ case 'Find multimedia':
+ return 'Multimedia';
+ default:
+ return category.category;
+ }
+ }
+ return data.storylines.map((storyline, i) => ({
+ id: `storyline-${i + 1}`,
+ title: storyline.title,
+ categories: storyline.content.map((category, categoryIndex) => ({
+ title: decideCategoryTitle(category),
+ groupedTrails: decideGroupedTrails(category, categoryIndex),
+ })),
+ }));
+}
diff --git a/dotcom-rendering/src/paletteDeclarations.ts b/dotcom-rendering/src/paletteDeclarations.ts
index 9a672e32857..004b53f1018 100644
--- a/dotcom-rendering/src/paletteDeclarations.ts
+++ b/dotcom-rendering/src/paletteDeclarations.ts
@@ -2535,8 +2535,15 @@ const cardMetaTextDark: PaletteFunction = () => sourcePalette.neutral[60];
const cardBackgroundLight: PaletteFunction = () => 'transparent';
const cardBackgroundDark: PaletteFunction = () => 'transparent';
-const cardMediaBackgroundLight: PaletteFunction = () =>
- sourcePalette.neutral[97];
+const cardMediaBackgroundLight: PaletteFunction = (format) => {
+ switch (format.theme) {
+ case ArticleSpecial.SpecialReportAlt:
+ return sourcePalette.neutral[93];
+ default:
+ return sourcePalette.neutral[97];
+ }
+};
+
const cardMediaBackgroundDark: PaletteFunction = () =>
sourcePalette.neutral[20];
diff --git a/dotcom-rendering/src/types/front.ts b/dotcom-rendering/src/types/front.ts
index 3e05d19937d..50269bbc989 100644
--- a/dotcom-rendering/src/types/front.ts
+++ b/dotcom-rendering/src/types/front.ts
@@ -160,6 +160,8 @@ export type DCRSupportingContent = {
url?: string;
kickerText?: string;
format: ArticleFormat;
+ /** // AIStorylines: The date is shown in the supporting content for the key stories container in a tag page */
+ webPublicationDate?: string;
};
export type TreatType = {
diff --git a/dotcom-rendering/src/types/storylinesContent.ts b/dotcom-rendering/src/types/storylinesContent.ts
new file mode 100644
index 00000000000..40d96a7f37e
--- /dev/null
+++ b/dotcom-rendering/src/types/storylinesContent.ts
@@ -0,0 +1,50 @@
+import type { DCRGroupedTrails } from './front';
+import type { MainMedia } from './mainMedia';
+
+/** Result of the enhance tag page storylines logic */
+export type ParsedStoryline = {
+ id: string;
+ title: string;
+ categories: ParsedCategory[];
+};
+
+export type ParsedCategory = {
+ title: string;
+ groupedTrails: DCRGroupedTrails;
+};
+
+// The types below should match up with those defined in the tool:
+// https://github.com/guardian/tag-page-supercharger/blob/main/app/models/FrontendContent.scala#L9
+export type ImageData = {
+ src: string;
+ altText: string;
+ isAvatar: boolean;
+ mediaData?: MainMedia | null;
+};
+
+export type ArticleData = {
+ url: string;
+ headline: string;
+ byline?: string | null;
+ publicationTime: string;
+ image?: ImageData | null;
+};
+
+export type CategoryContent = {
+ category: string;
+ articles: ArticleData[];
+};
+
+export type Storyline = {
+ title: string;
+ content: CategoryContent[];
+};
+
+export type StorylinesContent = {
+ created: string;
+ tag: string;
+ storylines: Storyline[];
+ articleCount?: number | null;
+ earliestArticleTime?: string | null;
+ latestArticleTime?: string | null;
+};
diff --git a/dotcom-rendering/src/types/tagPage.ts b/dotcom-rendering/src/types/tagPage.ts
index a11fa911139..dc90b6d0c80 100644
--- a/dotcom-rendering/src/types/tagPage.ts
+++ b/dotcom-rendering/src/types/tagPage.ts
@@ -6,6 +6,7 @@ import type { CommercialProperties } from './commercial';
import type { FooterType } from './footer';
import type { DCRFrontCard } from './front';
import type { FENavType } from './frontend';
+import type { StorylinesContent } from './storylinesContent';
import type { FETagType } from './tag';
/**
@@ -61,6 +62,7 @@ export interface TagPage {
branding: CollectionBranding | undefined;
canonicalUrl?: string;
contributionsServiceUrl: string;
+ storylinesContent?: StorylinesContent;
}
export type HeaderImage =
diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml
index 0a3f9f725c6..bd30f5084c4 100644
--- a/pnpm-lock.yaml
+++ b/pnpm-lock.yaml
@@ -303,7 +303,7 @@ importers:
version: link:../ab-testing/config
'@guardian/braze-components':
specifier: 22.2.0
- version: 22.2.0(@emotion/react@11.14.0(@types/react@18.3.1)(react@18.3.1))(@guardian/libs@26.1.0(@guardian/ophan-tracker-js@2.6.3)(tslib@2.6.2)(typescript@5.5.3))(@guardian/source@11.3.0(@emotion/react@11.14.0(@types/react@18.3.1)(react@18.3.1))(@types/react@18.3.1)(react@18.3.1)(tslib@2.6.2)(typescript@5.5.3))(react@18.3.1)
+ version: 22.2.0(@emotion/react@11.14.0(@types/react@18.3.1)(react@18.3.1))(@guardian/libs@26.1.0(@guardian/ophan-tracker-js@2.8.0)(tslib@2.6.2)(typescript@5.5.3))(@guardian/source@11.3.0(@emotion/react@11.14.0(@types/react@18.3.1)(react@18.3.1))(@types/react@18.3.1)(react@18.3.1)(tslib@2.6.2)(typescript@5.5.3))(react@18.3.1)
'@guardian/bridget':
specifier: 8.7.0
version: 8.7.0
@@ -315,28 +315,28 @@ importers:
version: 62.0.1(aws-cdk-lib@2.220.0(constructs@10.4.2))(aws-cdk@2.1030.0)(constructs@10.4.2)
'@guardian/commercial-core':
specifier: 29.0.0
- version: 29.0.0(@guardian/ab-core@8.0.0(tslib@2.6.2)(typescript@5.5.3))(@guardian/libs@26.1.0(@guardian/ophan-tracker-js@2.6.3)(tslib@2.6.2)(typescript@5.5.3))
+ version: 29.0.0(@guardian/ab-core@8.0.0(tslib@2.6.2)(typescript@5.5.3))(@guardian/libs@26.1.0(@guardian/ophan-tracker-js@2.8.0)(tslib@2.6.2)(typescript@5.5.3))
'@guardian/core-web-vitals':
specifier: 7.0.0
- version: 7.0.0(@guardian/libs@26.1.0(@guardian/ophan-tracker-js@2.6.3)(tslib@2.6.2)(typescript@5.5.3))(tslib@2.6.2)(typescript@5.5.3)(web-vitals@4.2.3)
+ version: 7.0.0(@guardian/libs@26.1.0(@guardian/ophan-tracker-js@2.8.0)(tslib@2.6.2)(typescript@5.5.3))(tslib@2.6.2)(typescript@5.5.3)(web-vitals@4.2.3)
'@guardian/eslint-config-typescript':
specifier: 12.0.0
version: 12.0.0(eslint@8.57.1)(tslib@2.6.2)(typescript@5.5.3)
'@guardian/identity-auth':
specifier: 6.0.1
- version: 6.0.1(@guardian/libs@26.1.0(@guardian/ophan-tracker-js@2.6.3)(tslib@2.6.2)(typescript@5.5.3))(tslib@2.6.2)(typescript@5.5.3)
+ version: 6.0.1(@guardian/libs@26.1.0(@guardian/ophan-tracker-js@2.8.0)(tslib@2.6.2)(typescript@5.5.3))(tslib@2.6.2)(typescript@5.5.3)
'@guardian/identity-auth-frontend':
specifier: 8.1.0
- version: 8.1.0(@guardian/identity-auth@6.0.1(@guardian/libs@26.1.0(@guardian/ophan-tracker-js@2.6.3)(tslib@2.6.2)(typescript@5.5.3))(tslib@2.6.2)(typescript@5.5.3))(@guardian/libs@26.1.0(@guardian/ophan-tracker-js@2.6.3)(tslib@2.6.2)(typescript@5.5.3))(tslib@2.6.2)(typescript@5.5.3)
+ version: 8.1.0(@guardian/identity-auth@6.0.1(@guardian/libs@26.1.0(@guardian/ophan-tracker-js@2.8.0)(tslib@2.6.2)(typescript@5.5.3))(tslib@2.6.2)(typescript@5.5.3))(@guardian/libs@26.1.0(@guardian/ophan-tracker-js@2.8.0)(tslib@2.6.2)(typescript@5.5.3))(tslib@2.6.2)(typescript@5.5.3)
'@guardian/libs':
specifier: 26.1.0
- version: 26.1.0(@guardian/ophan-tracker-js@2.6.3)(tslib@2.6.2)(typescript@5.5.3)
+ version: 26.1.0(@guardian/ophan-tracker-js@2.8.0)(tslib@2.6.2)(typescript@5.5.3)
'@guardian/ophan-tracker-js':
- specifier: 2.6.3
- version: 2.6.3
+ specifier: 2.8.0
+ version: 2.8.0
'@guardian/react-crossword':
specifier: 11.1.0
- version: 11.1.0(@emotion/react@11.14.0(@types/react@18.3.1)(react@18.3.1))(@guardian/libs@26.1.0(@guardian/ophan-tracker-js@2.6.3)(tslib@2.6.2)(typescript@5.5.3))(@guardian/source@11.3.0(@emotion/react@11.14.0(@types/react@18.3.1)(react@18.3.1))(@types/react@18.3.1)(react@18.3.1)(tslib@2.6.2)(typescript@5.5.3))(@types/react@18.3.1)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)(typescript@5.5.3)
+ version: 11.1.0(@emotion/react@11.14.0(@types/react@18.3.1)(react@18.3.1))(@guardian/libs@26.1.0(@guardian/ophan-tracker-js@2.8.0)(tslib@2.6.2)(typescript@5.5.3))(@guardian/source@11.3.0(@emotion/react@11.14.0(@types/react@18.3.1)(react@18.3.1))(@types/react@18.3.1)(react@18.3.1)(tslib@2.6.2)(typescript@5.5.3))(@types/react@18.3.1)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)(typescript@5.5.3)
'@guardian/shimport':
specifier: 1.0.2
version: 1.0.2
@@ -345,10 +345,10 @@ importers:
version: 11.3.0(@emotion/react@11.14.0(@types/react@18.3.1)(react@18.3.1))(@types/react@18.3.1)(react@18.3.1)(tslib@2.6.2)(typescript@5.5.3)
'@guardian/source-development-kitchen':
specifier: 18.1.1
- version: 18.1.1(@emotion/react@11.14.0(@types/react@18.3.1)(react@18.3.1))(@guardian/libs@26.1.0(@guardian/ophan-tracker-js@2.6.3)(tslib@2.6.2)(typescript@5.5.3))(@guardian/source@11.3.0(@emotion/react@11.14.0(@types/react@18.3.1)(react@18.3.1))(@types/react@18.3.1)(react@18.3.1)(tslib@2.6.2)(typescript@5.5.3))(@types/react@18.3.1)(react@18.3.1)(tslib@2.6.2)(typescript@5.5.3)
+ version: 18.1.1(@emotion/react@11.14.0(@types/react@18.3.1)(react@18.3.1))(@guardian/libs@26.1.0(@guardian/ophan-tracker-js@2.8.0)(tslib@2.6.2)(typescript@5.5.3))(@guardian/source@11.3.0(@emotion/react@11.14.0(@types/react@18.3.1)(react@18.3.1))(@types/react@18.3.1)(react@18.3.1)(tslib@2.6.2)(typescript@5.5.3))(@types/react@18.3.1)(react@18.3.1)(tslib@2.6.2)(typescript@5.5.3)
'@guardian/support-dotcom-components':
specifier: 8.3.1
- version: 8.3.1(@guardian/libs@26.1.0(@guardian/ophan-tracker-js@2.6.3)(tslib@2.6.2)(typescript@5.5.3))(@guardian/ophan-tracker-js@2.6.3)(zod@4.1.12)
+ version: 8.3.1(@guardian/libs@26.1.0(@guardian/ophan-tracker-js@2.8.0)(tslib@2.6.2)(typescript@5.5.3))(@guardian/ophan-tracker-js@2.8.0)(zod@4.1.12)
'@guardian/tsconfig':
specifier: 0.2.0
version: 0.2.0
@@ -2606,8 +2606,8 @@ packages:
typescript:
optional: true
- '@guardian/ophan-tracker-js@2.6.3':
- resolution: {integrity: sha512-TB26hIfejinZkRLO8f4ARATckddDiaRb8wwpgo5VBoYeOauKhsGyUJT7wQ1qC/DjlGSLWdjkD8LRVEtnct4/OQ==}
+ '@guardian/ophan-tracker-js@2.8.0':
+ resolution: {integrity: sha512-RPoyxPPKaT1em1LZiD1LKsTYzoXBG8Zjs4OzyP5dhEmfoXD99qK48JI4oGUPlq3wOOB0ZT8UrbtbWDKzeMSBHA==}
engines: {node: '>=16'}
'@guardian/prettier@5.0.0':
@@ -12868,10 +12868,10 @@ snapshots:
optionalDependencies:
typescript: 5.5.3
- '@guardian/braze-components@22.2.0(@emotion/react@11.14.0(@types/react@18.3.1)(react@18.3.1))(@guardian/libs@26.1.0(@guardian/ophan-tracker-js@2.6.3)(tslib@2.6.2)(typescript@5.5.3))(@guardian/source@11.3.0(@emotion/react@11.14.0(@types/react@18.3.1)(react@18.3.1))(@types/react@18.3.1)(react@18.3.1)(tslib@2.6.2)(typescript@5.5.3))(react@18.3.1)':
+ '@guardian/braze-components@22.2.0(@emotion/react@11.14.0(@types/react@18.3.1)(react@18.3.1))(@guardian/libs@26.1.0(@guardian/ophan-tracker-js@2.8.0)(tslib@2.6.2)(typescript@5.5.3))(@guardian/source@11.3.0(@emotion/react@11.14.0(@types/react@18.3.1)(react@18.3.1))(@types/react@18.3.1)(react@18.3.1)(tslib@2.6.2)(typescript@5.5.3))(react@18.3.1)':
dependencies:
'@emotion/react': 11.14.0(@types/react@18.3.1)(react@18.3.1)
- '@guardian/libs': 26.1.0(@guardian/ophan-tracker-js@2.6.3)(tslib@2.6.2)(typescript@5.5.3)
+ '@guardian/libs': 26.1.0(@guardian/ophan-tracker-js@2.8.0)(tslib@2.6.2)(typescript@5.5.3)
'@guardian/source': 11.3.0(@emotion/react@11.14.0(@types/react@18.3.1)(react@18.3.1))(@types/react@18.3.1)(react@18.3.1)(tslib@2.6.2)(typescript@5.5.3)
react: 18.3.1
@@ -12896,15 +12896,15 @@ snapshots:
read-pkg-up: 7.0.1
yargs: 17.7.2
- '@guardian/commercial-core@29.0.0(@guardian/ab-core@8.0.0(tslib@2.6.2)(typescript@5.5.3))(@guardian/libs@26.1.0(@guardian/ophan-tracker-js@2.6.3)(tslib@2.6.2)(typescript@5.5.3))':
+ '@guardian/commercial-core@29.0.0(@guardian/ab-core@8.0.0(tslib@2.6.2)(typescript@5.5.3))(@guardian/libs@26.1.0(@guardian/ophan-tracker-js@2.8.0)(tslib@2.6.2)(typescript@5.5.3))':
dependencies:
'@guardian/ab-core': 8.0.0(tslib@2.6.2)(typescript@5.5.3)
- '@guardian/libs': 26.1.0(@guardian/ophan-tracker-js@2.6.3)(tslib@2.6.2)(typescript@5.5.3)
+ '@guardian/libs': 26.1.0(@guardian/ophan-tracker-js@2.8.0)(tslib@2.6.2)(typescript@5.5.3)
'@types/googletag': 3.3.0
- '@guardian/core-web-vitals@7.0.0(@guardian/libs@26.1.0(@guardian/ophan-tracker-js@2.6.3)(tslib@2.6.2)(typescript@5.5.3))(tslib@2.6.2)(typescript@5.5.3)(web-vitals@4.2.3)':
+ '@guardian/core-web-vitals@7.0.0(@guardian/libs@26.1.0(@guardian/ophan-tracker-js@2.8.0)(tslib@2.6.2)(typescript@5.5.3))(tslib@2.6.2)(typescript@5.5.3)(web-vitals@4.2.3)':
dependencies:
- '@guardian/libs': 26.1.0(@guardian/ophan-tracker-js@2.6.3)(tslib@2.6.2)(typescript@5.5.3)
+ '@guardian/libs': 26.1.0(@guardian/ophan-tracker-js@2.8.0)(tslib@2.6.2)(typescript@5.5.3)
tslib: 2.6.2
web-vitals: 4.2.3
optionalDependencies:
@@ -12960,29 +12960,29 @@ snapshots:
- eslint-import-resolver-webpack
- supports-color
- '@guardian/identity-auth-frontend@8.1.0(@guardian/identity-auth@6.0.1(@guardian/libs@26.1.0(@guardian/ophan-tracker-js@2.6.3)(tslib@2.6.2)(typescript@5.5.3))(tslib@2.6.2)(typescript@5.5.3))(@guardian/libs@26.1.0(@guardian/ophan-tracker-js@2.6.3)(tslib@2.6.2)(typescript@5.5.3))(tslib@2.6.2)(typescript@5.5.3)':
+ '@guardian/identity-auth-frontend@8.1.0(@guardian/identity-auth@6.0.1(@guardian/libs@26.1.0(@guardian/ophan-tracker-js@2.8.0)(tslib@2.6.2)(typescript@5.5.3))(tslib@2.6.2)(typescript@5.5.3))(@guardian/libs@26.1.0(@guardian/ophan-tracker-js@2.8.0)(tslib@2.6.2)(typescript@5.5.3))(tslib@2.6.2)(typescript@5.5.3)':
dependencies:
- '@guardian/identity-auth': 6.0.1(@guardian/libs@26.1.0(@guardian/ophan-tracker-js@2.6.3)(tslib@2.6.2)(typescript@5.5.3))(tslib@2.6.2)(typescript@5.5.3)
- '@guardian/libs': 26.1.0(@guardian/ophan-tracker-js@2.6.3)(tslib@2.6.2)(typescript@5.5.3)
+ '@guardian/identity-auth': 6.0.1(@guardian/libs@26.1.0(@guardian/ophan-tracker-js@2.8.0)(tslib@2.6.2)(typescript@5.5.3))(tslib@2.6.2)(typescript@5.5.3)
+ '@guardian/libs': 26.1.0(@guardian/ophan-tracker-js@2.8.0)(tslib@2.6.2)(typescript@5.5.3)
tslib: 2.6.2
optionalDependencies:
typescript: 5.5.3
- '@guardian/identity-auth@6.0.1(@guardian/libs@26.1.0(@guardian/ophan-tracker-js@2.6.3)(tslib@2.6.2)(typescript@5.5.3))(tslib@2.6.2)(typescript@5.5.3)':
+ '@guardian/identity-auth@6.0.1(@guardian/libs@26.1.0(@guardian/ophan-tracker-js@2.8.0)(tslib@2.6.2)(typescript@5.5.3))(tslib@2.6.2)(typescript@5.5.3)':
dependencies:
- '@guardian/libs': 26.1.0(@guardian/ophan-tracker-js@2.6.3)(tslib@2.6.2)(typescript@5.5.3)
+ '@guardian/libs': 26.1.0(@guardian/ophan-tracker-js@2.8.0)(tslib@2.6.2)(typescript@5.5.3)
tslib: 2.6.2
optionalDependencies:
typescript: 5.5.3
- '@guardian/libs@26.1.0(@guardian/ophan-tracker-js@2.6.3)(tslib@2.6.2)(typescript@5.5.3)':
+ '@guardian/libs@26.1.0(@guardian/ophan-tracker-js@2.8.0)(tslib@2.6.2)(typescript@5.5.3)':
dependencies:
- '@guardian/ophan-tracker-js': 2.6.3
+ '@guardian/ophan-tracker-js': 2.8.0
tslib: 2.6.2
optionalDependencies:
typescript: 5.5.3
- '@guardian/ophan-tracker-js@2.6.3':
+ '@guardian/ophan-tracker-js@2.8.0':
dependencies:
'@guardian/tsconfig': 1.0.1
@@ -12991,10 +12991,10 @@ snapshots:
prettier: 3.0.3
tslib: 2.6.2
- '@guardian/react-crossword@11.1.0(@emotion/react@11.14.0(@types/react@18.3.1)(react@18.3.1))(@guardian/libs@26.1.0(@guardian/ophan-tracker-js@2.6.3)(tslib@2.6.2)(typescript@5.5.3))(@guardian/source@11.3.0(@emotion/react@11.14.0(@types/react@18.3.1)(react@18.3.1))(@types/react@18.3.1)(react@18.3.1)(tslib@2.6.2)(typescript@5.5.3))(@types/react@18.3.1)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)(typescript@5.5.3)':
+ '@guardian/react-crossword@11.1.0(@emotion/react@11.14.0(@types/react@18.3.1)(react@18.3.1))(@guardian/libs@26.1.0(@guardian/ophan-tracker-js@2.8.0)(tslib@2.6.2)(typescript@5.5.3))(@guardian/source@11.3.0(@emotion/react@11.14.0(@types/react@18.3.1)(react@18.3.1))(@types/react@18.3.1)(react@18.3.1)(tslib@2.6.2)(typescript@5.5.3))(@types/react@18.3.1)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)(typescript@5.5.3)':
dependencies:
'@emotion/react': 11.14.0(@types/react@18.3.1)(react@18.3.1)
- '@guardian/libs': 26.1.0(@guardian/ophan-tracker-js@2.6.3)(tslib@2.6.2)(typescript@5.5.3)
+ '@guardian/libs': 26.1.0(@guardian/ophan-tracker-js@2.8.0)(tslib@2.6.2)(typescript@5.5.3)
'@guardian/source': 11.3.0(@emotion/react@11.14.0(@types/react@18.3.1)(react@18.3.1))(@types/react@18.3.1)(react@18.3.1)(tslib@2.6.2)(typescript@5.5.3)
react: 18.3.1
tslib: 2.6.2
@@ -13009,9 +13009,9 @@ snapshots:
dependencies:
tslib: 2.6.2
- '@guardian/source-development-kitchen@18.1.1(@emotion/react@11.14.0(@types/react@18.3.1)(react@18.3.1))(@guardian/libs@26.1.0(@guardian/ophan-tracker-js@2.6.3)(tslib@2.6.2)(typescript@5.5.3))(@guardian/source@11.3.0(@emotion/react@11.14.0(@types/react@18.3.1)(react@18.3.1))(@types/react@18.3.1)(react@18.3.1)(tslib@2.6.2)(typescript@5.5.3))(@types/react@18.3.1)(react@18.3.1)(tslib@2.6.2)(typescript@5.5.3)':
+ '@guardian/source-development-kitchen@18.1.1(@emotion/react@11.14.0(@types/react@18.3.1)(react@18.3.1))(@guardian/libs@26.1.0(@guardian/ophan-tracker-js@2.8.0)(tslib@2.6.2)(typescript@5.5.3))(@guardian/source@11.3.0(@emotion/react@11.14.0(@types/react@18.3.1)(react@18.3.1))(@types/react@18.3.1)(react@18.3.1)(tslib@2.6.2)(typescript@5.5.3))(@types/react@18.3.1)(react@18.3.1)(tslib@2.6.2)(typescript@5.5.3)':
dependencies:
- '@guardian/libs': 26.1.0(@guardian/ophan-tracker-js@2.6.3)(tslib@2.6.2)(typescript@5.5.3)
+ '@guardian/libs': 26.1.0(@guardian/ophan-tracker-js@2.8.0)(tslib@2.6.2)(typescript@5.5.3)
'@guardian/source': 11.3.0(@emotion/react@11.14.0(@types/react@18.3.1)(react@18.3.1))(@types/react@18.3.1)(react@18.3.1)(tslib@2.6.2)(typescript@5.5.3)
tslib: 2.6.2
optionalDependencies:
@@ -13030,7 +13030,7 @@ snapshots:
react: 18.3.1
typescript: 5.5.3
- '@guardian/support-dotcom-components@8.3.1(@guardian/libs@26.1.0(@guardian/ophan-tracker-js@2.6.3)(tslib@2.6.2)(typescript@5.5.3))(@guardian/ophan-tracker-js@2.6.3)(zod@4.1.12)':
+ '@guardian/support-dotcom-components@8.3.1(@guardian/libs@26.1.0(@guardian/ophan-tracker-js@2.8.0)(tslib@2.6.2)(typescript@5.5.3))(@guardian/ophan-tracker-js@2.8.0)(zod@4.1.12)':
dependencies:
'@aws-sdk/client-cloudwatch': 3.841.0
'@aws-sdk/client-dynamodb': 3.840.0
@@ -13038,8 +13038,8 @@ snapshots:
'@aws-sdk/client-ssm': 3.840.0
'@aws-sdk/credential-providers': 3.840.0
'@aws-sdk/lib-dynamodb': 3.840.0(@aws-sdk/client-dynamodb@3.840.0)
- '@guardian/libs': 26.1.0(@guardian/ophan-tracker-js@2.6.3)(tslib@2.6.2)(typescript@5.5.3)
- '@guardian/ophan-tracker-js': 2.6.3
+ '@guardian/libs': 26.1.0(@guardian/ophan-tracker-js@2.8.0)(tslib@2.6.2)(typescript@5.5.3)
+ '@guardian/ophan-tracker-js': 2.8.0
'@okta/jwt-verifier': 4.0.2
compression: 1.7.4
cors: 2.8.5