From 6c0dd8af50647aca31a3c1d60070aa0ec3e42e7a Mon Sep 17 00:00:00 2001 From: Pete F <37048459+bryophyta@users.noreply.github.com> Date: Wed, 7 Oct 2026 15:38:52 +0100 Subject: [PATCH 01/19] WIP: Reinstate the standalone files for storylines --- .../fixtures/manual/storylines-section.ts | 874 ++++++++++++++++++ .../src/components/StorylinesSection.tsx | 522 +++++++++++ .../StorylinesSectionContent.island.tsx | 333 +++++++ .../StorylinesSectionContent.stories.tsx | 22 + .../SupportingKeyStoriesContent.tsx | 217 +++++ .../src/components/SvgBetaLabel.tsx | 106 +++ .../model/enhanceTagPageStorylinesContent.ts | 232 +++++ .../src/types/storylinesContent.ts | 50 + 8 files changed, 2356 insertions(+) create mode 100644 dotcom-rendering/fixtures/manual/storylines-section.ts create mode 100644 dotcom-rendering/src/components/StorylinesSection.tsx create mode 100644 dotcom-rendering/src/components/StorylinesSectionContent.island.tsx create mode 100644 dotcom-rendering/src/components/StorylinesSectionContent.stories.tsx create mode 100644 dotcom-rendering/src/components/SupportingKeyStoriesContent.tsx create mode 100644 dotcom-rendering/src/components/SvgBetaLabel.tsx create mode 100644 dotcom-rendering/src/model/enhanceTagPageStorylinesContent.ts create mode 100644 dotcom-rendering/src/types/storylinesContent.ts diff --git a/dotcom-rendering/fixtures/manual/storylines-section.ts b/dotcom-rendering/fixtures/manual/storylines-section.ts new file mode 100644 index 00000000000..25dec967e20 --- /dev/null +++ b/dotcom-rendering/fixtures/manual/storylines-section.ts @@ -0,0 +1,874 @@ +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', + isLive: false, + }, + }, + }, + ], + }, + { + 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/src/components/StorylinesSection.tsx b/dotcom-rendering/src/components/StorylinesSection.tsx new file mode 100644 index 00000000000..2df816147ff --- /dev/null +++ b/dotcom-rendering/src/components/StorylinesSection.tsx @@ -0,0 +1,522 @@ +import { css } from '@emotion/react'; +import { between, from, space, textSans14 } from '@guardian/source/foundations'; +import { Hide } from '@guardian/source/react-components'; +import { submitComponentEvent } from '../client/ophan/ophan'; +import { type EditionId } from '../lib/edition'; +import { palette as schemePalette } from '../palette'; +import type { DCRContainerPalette } from '../types/front'; +import type { TagPagePagination } from '../types/tagPage'; +import { ContainerOverrides } from './ContainerOverrides'; +import { ContainerTitle } from './ContainerTitle'; +import { Footer } from './ExpandableAtom/Footer'; +import { FrontPagination } from './FrontPagination'; +import { FrontSectionTitle } from './FrontSectionTitle'; +import { SvgBetaLabel } from './SvgBetaLabel'; + +type Props = { + /** This text will be used as the h2 shown in the left column for the section */ + title?: string; + /** The title can be made into a link using this property */ + url?: string; + 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; + /** Fronts containers can have their styling overridden using a `containerPalette` */ + containerPalette?: DCRContainerPalette; + /** Used in partnership with `showDateHeader` to localise the date string */ + editionId: EditionId; + isTagPage?: boolean; + /** Puts pagination at the bottom of the container allowing the user to navigate to other pages, + * usually used on the last container on a page */ + pagination?: TagPagePagination; +}; + +const width = (columns: number, columnWidth: number, columnGap: number) => + `width: ${columns * columnWidth + (columns - 1) * columnGap}px;`; + +const articleSectionTitleStyles = 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 pagination-start] auto + [pagination-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 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 pagination-start] auto + [pagination-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 pagination-start] auto + [pagination-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 = css` + grid-row: headline; + grid-column: title; + display: flex; + flex-direction: row; + justify-content: space-between; + + ${between.tablet.and.leftCol} { + ${flexRowStyles} + } +`; + +const topPadding = css` + padding-top: ${space[2]}px; +`; + +const sectionContent = css` + margin: 0; + + .hidden > & { + display: none; + } + + grid-column: content; +`; + +const sectionContentRow = css` + grid-row: content; +`; + +const sectionContentHorizontalMargins = css` + ${from.tablet} { + margin-left: -10px; + margin-right: -10px; + } +`; + +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 sectionPaginationBackground = css` + grid-row: pagination; + grid-column: decoration; + background-color: white; + ${from.tablet} { + margin: 0 -20px; + border-left-style: solid; + border-right-style: solid; + border-width: 1px; + border-color: ${schemePalette('--section-border')}; + } + .hidden > & { + display: none; + } +`; + +const sectionPagination = css` + grid-row: pagination; + grid-column: content; + .hidden > & { + display: none; + } +`; + +const bottomPadding = css` + padding-bottom: ${space[10]}px; +`; + +/** + * # 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, + containerPalette, + editionId, + ophanComponentLink, + ophanComponentName, + url, + isTagPage = false, + pagination, +}: Props) => { + const sectionId = 'storylines-section'; + + return ( + +
+
+ +
+ + +
+ + + Storylines is an experimental + feature we are showing to a limited + audience as a Beta test. +
It uses generative AI to + identify three key storylines within + this topic and show valuable + articles from our archive. The aim + is to give readers a better + understanding of a topic and access + to a wider variety of our + journalism. The only text + automatically generated is the short + description of each storyline. It + has been created in line with the + Guardian’s{' '} + + generative AI principles + + .{' '} +
+ +
+
{ + void submitComponentEvent( + { + component: { + componentType: + 'STORYLINES', + id: sectionId, + products: [], + labels: [], + }, + action: 'DISLIKE', + }, + 'Web', + ); + }} + likeHandler={() => { + void submitComponentEvent( + { + component: { + componentType: + 'STORYLINES', + id: sectionId, + products: [], + labels: [], + }, + action: 'LIKE', + }, + 'Web', + ); + }} + isStorylines={true} + >
+
+ +
+
+
+
+ + } + sectionId={sectionId} + collectionBranding={undefined} + /> + +
+ +
+
+
+ +
+ {children} +
+ + {pagination && ( + <> +
+ + + )} +
+
+ ); +}; diff --git a/dotcom-rendering/src/components/StorylinesSectionContent.island.tsx b/dotcom-rendering/src/components/StorylinesSectionContent.island.tsx new file mode 100644 index 00000000000..ef2b1c280df --- /dev/null +++ b/dotcom-rendering/src/components/StorylinesSectionContent.island.tsx @@ -0,0 +1,333 @@ +import { css } from '@emotion/react'; +import { + from, + headlineLight50, + space, + textSans14, + textSans17, + textSans20, + textSansBold34, + until, +} from '@guardian/source/foundations'; +import { Hide } from '@guardian/source/react-components'; +import { useState } from 'react'; +import { submitComponentEvent } from '../client/ophan/ophan'; +import type { EditionId } from '../lib/edition'; +import { parseStorylinesContentToStorylines } from '../model/enhanceTagPageStorylinesContent'; +import { palette } from '../palette'; +import type { StorylinesContent } from '../types/storylinesContent'; +import type { TagPagePagination } from '../types/tagPage'; +import { Footer } from './ExpandableAtom/Footer'; +import { FlexibleGeneral } from './FlexibleGeneral'; +import { ScrollableCarousel } from './ScrollableCarousel'; +import { StorylinesSection } from './StorylinesSection'; + +type StorylinesSectionProps = { + url?: string; + index: number; + containerId?: string; + editionId: EditionId; + storylinesContent: StorylinesContent; + pagination?: TagPagePagination; +}; + +const selectedTitleStyles = css` + ${textSansBold34} + color: ${palette('--storylines-titles')}; + margin-bottom: ${space[2]}px; + ${until.tablet} { + line-height: 1.2; + padding-left: 0px; + } + ${from.tablet} { + line-height: 1.3; + padding-left: 10px; /* aligns with the headlines of the stories below */ + } +`; + +const categoryTitleCss = css` + ${textSans20}; + font-weight: 700; + color: ${palette('--storylines-titles')}; + 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 ${palette('--storylines-border')}; + } +`; + +const tabsContainerStyles = css` + display: flex; + width: 100%; + ${from.wide} { + width: 110%; /* bit hacky, but looks a touch better on wide. */ + margin-bottom: ${space[2]}px; + } + align-items: stretch; /* Makes all tabs the same height */ + 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 ${palette('--storylines-border')} solid;`} + background-color: ${palette('--storylines-background')}; + color: ${isActive + ? `${palette('--storylines-active-tab')}` + : `${palette('--storylines-inactive-tab')}`}; + 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; + } +`; + +const footerStyle = css` + margin-top: ${space[1]}px; + padding-bottom: ${space[4]}px; + display: flex; + justify-content: end; +`; + +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(); + return `${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, + pagination, +}: StorylinesSectionProps) => { + const parsedStorylines = + parseStorylinesContentToStorylines(storylinesContent); + + const [activeStorylineId, setActiveStorylineId] = useState( + parsedStorylines[0]?.id ?? '', + ); + + if (!parsedStorylines[0]) { + return null; + } + + const activeStoryline = parsedStorylines.find( + (s) => s.id === activeStorylineId, + ); + + function handleStorylineChange(newStorylineId: string) { + const currentId = activeStorylineId; + setActiveStorylineId(newStorylineId); + void submitComponentEvent( + { + component: { + componentType: 'STORYLINES', + id: `${storylinesContent.tag} storylines`, + }, + action: 'CLICK', + value: `${currentId} -> ${newStorylineId}`, + }, + 'Web', + ); + } + + return ( + + {/* Storylines tab selector. This is a carousel on mobile. */} +
+ + {parsedStorylines.map((storyline, i) => ( + + ))} + +
+ {/* 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 */} +
+ +
+ Storylines is an experimental feature we are showing to + a limited audience as a Beta test. It uses generative AI + to identify three key storylines within this topic and + show valuable articles from our archive. The aim is to + give readers a better understanding of a topic and + access to a wider variety of our journalism. The only + text automatically generated is the short description of + each storyline. It has been created in line with the + Guardian’s{' '} + + generative AI principles + + .{' '} +
+
+
+ {`These storylines were curated from ${formatDateRangeText( + storylinesContent.earliestArticleTime, + storylinesContent.latestArticleTime, + )}. Some articles may be older to provide further context.`} +
+
+ + +
+
+ void submitComponentEvent( + { + component: { + componentType: 'STORYLINES', + id: 'storylines-section', + products: [], + labels: [], + }, + action: 'DISLIKE', + }, + 'Web', + ) + } + likeHandler={() => { + void submitComponentEvent( + { + component: { + componentType: 'STORYLINES', + id: 'storylines-section', + products: [], + labels: [], + }, + action: 'LIKE', + }, + 'Web', + ); + }} + >
+
+
+
+ ); +}; diff --git a/dotcom-rendering/src/components/StorylinesSectionContent.stories.tsx b/dotcom-rendering/src/components/StorylinesSectionContent.stories.tsx new file mode 100644 index 00000000000..ffcfed7549b --- /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.island'; + +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..2b841120082 --- /dev/null +++ b/dotcom-rendering/src/components/SupportingKeyStoriesContent.tsx @@ -0,0 +1,217 @@ +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; + dataLinkName?: string; +}; + +/** + * 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, + dataLinkName, +}: 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/components/SvgBetaLabel.tsx b/dotcom-rendering/src/components/SvgBetaLabel.tsx new file mode 100644 index 00000000000..6c03c0d0965 --- /dev/null +++ b/dotcom-rendering/src/components/SvgBetaLabel.tsx @@ -0,0 +1,106 @@ +/** + * Used to indicate a beta feature. + * At the moment this is just used for the AI storylines section on tag pages. + * If commonly reused in the future, might benefit from being uploaded to guim.co.uk or moved to source. + */ +export const SvgBetaLabel = () => ( + + Guardian Beta Label + + + + + + + + + + + + + + + + + + + + + + + + + + + + +); diff --git a/dotcom-rendering/src/model/enhanceTagPageStorylinesContent.ts b/dotcom-rendering/src/model/enhanceTagPageStorylinesContent.ts new file mode 100644 index 00000000000..92af5ebcb3f --- /dev/null +++ b/dotcom-rendering/src/model/enhanceTagPageStorylinesContent.ts @@ -0,0 +1,232 @@ +import type { Group } from '../lib/getDataLinkName'; +import { getDataLinkNameCard } from '../lib/getDataLinkName'; +import type { DCRFrontCard, DCRGroupedTrails } from '../types/front'; +import type { + ArticleData, + CategoryContent, + ParsedStoryline, + StorylinesContent, +} from '../types/storylinesContent'; +import { getMediaMetadata } from './enhanceCards'; + +function decideFormatForArticle( + category: CategoryContent, + article: ArticleData, +): { + design: number; + display: number; + theme: number; +} { + switch (category.category) { + case 'Contrasting opinions': + return { design: 8, display: 0, theme: 1 }; + case 'Deep Reads': + return { design: 10, display: 0, theme: 0 }; + case 'Explainers': + return { design: 0, display: 0, theme: 2 }; + case 'Find multimedia': + const mediaType = article.image?.mediaData?.type; + if ( + mediaType === 'YoutubeVideo' || + mediaType === 'SelfHostedVideo' + ) { + return { design: 4, display: 0, theme: 7 }; + } else if (mediaType === 'Audio') { + return { design: 3, display: 0, theme: 7 }; + } else if (mediaType === 'Gallery') { + return { design: 2, display: 0, theme: 7 }; + } else { + return { design: 0, display: 0, theme: 7 }; + } + case 'Profiles and Interviews': + return { design: 18, display: 1, theme: 0 }; + default: + return { design: 0, display: 0, theme: 0 }; + } +} + +function parseArticleDataToFrontCard( + category: CategoryContent, + article: ArticleData, + categoryIndex: number, + index: number, + storylineId: string, +): DCRFrontCard { + const format = decideFormatForArticle(category, article); + const group: Group = `${categoryIndex}`; + const normalisedCategory = category.category + .toLowerCase() + .replace(/\s+/g, '-'); + const dataLinkName = `${storylineId} | ${normalisedCategory} | ${getDataLinkNameCard( + format, + group, + index, + )}`; + const mainMedia = + category.category === 'Find multimedia' && article.image?.mediaData + ? article.image.mediaData + : undefined; + const articleMedia = mainMedia && getMediaMetadata(mainMedia); + return { + format, + dataLinkName, + url: article.url, + headline: article.headline, + trailText: undefined, + webPublicationDate: article.publicationTime, + supportingContent: [], + discussionApiUrl: 'https://discussion.theguardian.com/discussion-api', + byline: article.byline ?? '', + showByline: category.category === 'Contrasting opinions' ? true : false, + boostLevel: + category.category === 'Profiles and Interviews' || + category.category === 'Deep Reads' + ? 'megaboost' + : 'default', + isImmersive: + category.category === 'Profiles and Interviews' || + category.category === 'Deep Reads' + ? true + : false, + showQuotedHeadline: false, + showLivePlayable: false, + avatarUrl: + category.category === 'Contrasting opinions' && + article.image?.isAvatar + ? article.image.src + : undefined, + mainMedia, + articleMedia, + isExternalLink: false, + image: article.image + ? { + src: article.image.src, + altText: article.image.altText || '', + } + : undefined, + }; +} + +function parseKeyStoriesToFrontCard( + category: CategoryContent, + storylineId: string, +): DCRFrontCard { + const supportingContent = category.articles.slice(0, 4).map((article) => { + 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: `${storylineId} | key-stories`, + 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, + storylineId: string, +): DCRGroupedTrails { + if (category.category === 'Key Stories') { + return { + splash: [parseKeyStoriesToFrontCard(category, storylineId)], + 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, + storylineId, + ), + ); + return { + splash: [], + snap: [], + standard: frontCards, + }; + } else { + const frontCards = category.articles + .slice(0, 2) + .map((article, index) => + parseArticleDataToFrontCard( + category, + article, + categoryIndex, + index, + storylineId, + ), + ); + return { + splash: [], + snap: [], + standard: frontCards, + }; + } +} + +export function parseStorylinesContentToStorylines( + storylinesContent: 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 storylinesContent.storylines.map((storyline, i) => { + const storylineId = `storyline-${i + 1}`; + return { + id: storylineId, + title: storyline.title, + categories: storyline.content.map((category, categoryIndex) => ({ + title: decideCategoryTitle(category), + groupedTrails: decideGroupedTrails( + category, + categoryIndex, + storylineId, + ), + })), + }; + }); +} 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; +}; From 1d743456dcaeb7b4510fdae432c06debf0be1392 Mon Sep 17 00:00:00 2001 From: Pete F <37048459+bryophyta@users.noreply.github.com> Date: Wed, 7 Oct 2026 17:00:55 +0100 Subject: [PATCH 02/19] Reinstate the edited files for storylines --- ab-testing/config/abTests.ts | 13 + ab-testing/config/types.ts | 3 +- .../fixtures/manual/hostedGallery.ts | 1 + .../fixtures/manual/hostedVideo.ts | 1 + dotcom-rendering/src/components/Card/Card.tsx | 239 +++++----- .../components/Card/components/CardFooter.tsx | 9 + .../src/components/CardHeadline.tsx | 5 +- .../src/components/DecideContainer.tsx | 1 + .../src/components/ExpandableAtom/Footer.tsx | 9 +- .../src/components/FeatureCard.tsx | 5 + .../src/components/FeatureCardCardAge.tsx | 4 +- .../components/FlexibleGeneral.stories.tsx | 1 + .../src/components/FlexibleGeneral.tsx | 26 +- .../YoutubeAtomFeatureCardOverlay.tsx | 1 + dotcom-rendering/src/frontend/feTagPage.ts | 4 + .../src/frontend/schemas/feTagPage.json | 422 ++++++++++++++++++ .../src/layouts/TagPageLayout.tsx | 32 +- .../model/enhanceTagPageStorylinesContent.ts | 4 +- dotcom-rendering/src/paletteDeclarations.ts | 26 ++ .../src/server/handler.front.web.ts | 1 + dotcom-rendering/src/types/front.ts | 2 + dotcom-rendering/src/types/tagPage.ts | 2 + 22 files changed, 698 insertions(+), 113 deletions(-) diff --git a/ab-testing/config/abTests.ts b/ab-testing/config/abTests.ts index 2b63148e8e0..3212cf8884c 100644 --- a/ab-testing/config/abTests.ts +++ b/ab-testing/config/abTests.ts @@ -359,6 +359,19 @@ const ABTests: ABTest[] = [ audienceSize: 0, groups: ["enable"], }, + { + name: "newsroom-ai-tag-page-storylines", + description: + "Testing the AI generated storylines component on tag pages", + owners: ["ai.dev@guardian.co.uk"], + expirationDate: `2026-11-30`, + type: "server", + status: "ON", + audienceSize: 0 / 100, + audienceSpace: "B", + groups: ["control", "variant"], + shouldForceMetricsCollection: false, + }, ]; const activeABtests = ABTests.filter((test) => test.status === "ON"); diff --git a/ab-testing/config/types.ts b/ab-testing/config/types.ts index f64107a4156..5961220a79e 100644 --- a/ab-testing/config/types.ts +++ b/ab-testing/config/types.ts @@ -16,7 +16,8 @@ type Team = | "martech" | "identity-and-trust" | "puzzles" - | "articles-and-publishing"; + | "articles-and-publishing" + | "newsroom-ai"; type TestName = `${Team}-${string}`; diff --git a/dotcom-rendering/fixtures/manual/hostedGallery.ts b/dotcom-rendering/fixtures/manual/hostedGallery.ts index 188f9472958..3f136798f68 100644 --- a/dotcom-rendering/fixtures/manual/hostedGallery.ts +++ b/dotcom-rendering/fixtures/manual/hostedGallery.ts @@ -2850,6 +2850,7 @@ export const hostedGallery: FEArticle = { filterAtAGlance: true, ampArticleSwitch: false, remarketing: true, + tagPageStorylines: false, articleEndSlot: true, keyEventsCarousel: true, registerWithPhone: false, diff --git a/dotcom-rendering/fixtures/manual/hostedVideo.ts b/dotcom-rendering/fixtures/manual/hostedVideo.ts index 4c61eb6e395..85f69c6e7bc 100644 --- a/dotcom-rendering/fixtures/manual/hostedVideo.ts +++ b/dotcom-rendering/fixtures/manual/hostedVideo.ts @@ -244,6 +244,7 @@ export const hostedVideo: FEArticle = { filterAtAGlance: true, ampArticleSwitch: false, remarketing: true, + tagPageStorylines: false, articleEndSlot: true, keyEventsCarousel: false, registerWithPhone: true, diff --git a/dotcom-rendering/src/components/Card/Card.tsx b/dotcom-rendering/src/components/Card/Card.tsx index 0284c8c0356..d53fec1924a 100644 --- a/dotcom-rendering/src/components/Card/Card.tsx +++ b/dotcom-rendering/src/components/Card/Card.tsx @@ -46,6 +46,7 @@ import { SnapCssSandbox } from '../SnapCssSandbox'; import { StarRating } from '../StarRating/StarRating'; import type { Alignment } from '../SupportingContent'; import { SupportingContent } from '../SupportingContent'; +import { SupportingKeyStoriesContent } from '../SupportingKeyStoriesContent'; import { SvgMediaControlsPlay } from '../SvgMediaControlsPlay'; import { YoutubeBlockComponent } from '../YoutubeBlockComponent.island'; import { AvatarContainer } from './components/AvatarContainer'; @@ -165,6 +166,7 @@ export type Props = { starRatingSize?: RatingSizeType; contentSpacing?: 'small' | 'large'; headlineTestUuid?: string; + isStorylines?: boolean; }; const waveformWrapper = ( @@ -412,6 +414,7 @@ export const Card = ({ articleMedia, contentSpacing, headlineTestUuid, + isStorylines = false, }: Props) => { const ab = useAB(); const isInLoopClickTestControl = Boolean( @@ -675,7 +678,20 @@ export const Card = ({ } const Sublinks = () => { - return ( + return isStorylines ? ( + + ) : ( ( - + {isStorylines ? ( + + ) : ( + + )} ); @@ -1124,107 +1152,114 @@ export const Card = ({ } padContent={contentSpacing} > + {/* In the storylines section on tag pages, the flex splash is used to display key stories. + We don't display an article headline in the conventional sense, the key stories are instead displayed as "supporting content". + However, simply passing an empty string as the article headline still reserves space. + The storylines check enables us to avoid rendering that space at all. + */} {/* the div is needed to keep the headline and trail text justified at the start */} -
- {headlinePosition === 'inner' && ( - - + {headlinePosition === 'inner' && ( + + + + {!isUndefined(starRating) && ( + + )} + + )} + + {!!trailText && shouldShowTrailText && ( + + )} + + {!isOpinionCardWithAvatar && !showLivePlayable && ( + } + cardBranding={ + isOnwardContent ? ( + + ) : undefined } - showPulsingDot={ - format.design === - ArticleDesign.LiveBlog || - showPulsingDot - } - byline={byline} - showByline={showByline} - isExternalLink={isExternalLink} - kickerImage={ - showKickerImage && - media?.type === 'podcast' - ? media.podcastImage + media={ + !isGallerySecondaryOnward + ? articleMedia : undefined } + isNewsletter={isNewsletter} /> - - {!isUndefined(starRating) && ( - - )} - - )} - - {!!trailText && shouldShowTrailText && ( - - )} - - {!isOpinionCardWithAvatar && !showLivePlayable && ( - } - cardBranding={ - isOnwardContent ? ( - - ) : undefined - } - media={ - !isGallerySecondaryOnward - ? articleMedia - : undefined - } - isNewsletter={isNewsletter} - /> - )} - {showLivePlayable && - liveUpdatesPosition === 'inner' && ( - - - )} -
+ {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/CardFooter.tsx b/dotcom-rendering/src/components/Card/components/CardFooter.tsx index 825137a8287..61a957d1e12 100644 --- a/dotcom-rendering/src/components/Card/components/CardFooter.tsx +++ b/dotcom-rendering/src/components/Card/components/CardFooter.tsx @@ -63,6 +63,7 @@ type Props = { media?: ArticleMedia; isNewsletter?: boolean; shouldReserveSpace?: { mobile: boolean; desktop: boolean }; + isStorylines?: boolean; }; export const CardFooter = ({ @@ -73,6 +74,7 @@ export const CardFooter = ({ media, isNewsletter, shouldReserveSpace, + isStorylines, }: Props) => { const shouldShowBranding = format.theme === ArticleSpecial.Labs && !!cardBranding; @@ -89,6 +91,13 @@ export const CardFooter = ({
{shouldShowBranding && cardBranding} + {/** + * 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 && age} + { // The link is only applied directly to the headline if it is a sublink const isSublink = !!linkTo; + const isStorylinesCard = isStorylines ?? false; const fontStyles = getFonts(format, fontSizes); @@ -247,7 +250,7 @@ export const CardHeadline = ({ isSublink ? 'card-sublink-headline' : 'card-headline' }`} css={[ - isSublink + isSublink && !isStorylinesCard ? css` ${textSans14} ` diff --git a/dotcom-rendering/src/components/DecideContainer.tsx b/dotcom-rendering/src/components/DecideContainer.tsx index 6af9a3d8dae..ed689260b1b 100644 --- a/dotcom-rendering/src/components/DecideContainer.tsx +++ b/dotcom-rendering/src/components/DecideContainer.tsx @@ -120,6 +120,7 @@ export const DecideContainer = ({ aspectRatio={aspectRatio} containerLevel={containerLevel} collectionId={collectionId} + isStorylines={false} /> ); case 'scrollable/small': diff --git a/dotcom-rendering/src/components/ExpandableAtom/Footer.tsx b/dotcom-rendering/src/components/ExpandableAtom/Footer.tsx index f84e4cb5071..c4cc8df7802 100644 --- a/dotcom-rendering/src/components/ExpandableAtom/Footer.tsx +++ b/dotcom-rendering/src/components/ExpandableAtom/Footer.tsx @@ -4,11 +4,12 @@ import { useState } from 'react'; import { palette as themePalette } from '../../palette'; /// LIKE/DISLIKE FEEDBACK FOOTER -const footerStyling = css` +// The isStorylines variable enables left alignment in the tag page storylines section +const footerStyling = (isStorylines?: boolean) => css` font-size: 13px; line-height: 16px; display: flex; - justify-content: flex-end; + ${!isStorylines && 'justify-content: flex-end;'} `; // Currently no thumb icon in src-icons so a path is needed @@ -29,9 +30,11 @@ const ThumbImage = () => { export const Footer = ({ likeHandler, dislikeHandler, + isStorylines = false, }: { likeHandler: () => void; dislikeHandler: () => void; + isStorylines?: boolean; }): JSX.Element => { // This is defined here because adding the hover styling using cx breaks the text styling const buttonStyling = css` @@ -58,7 +61,7 @@ export const Footer = ({ `; const [showThankYou, setShowThankYou] = useState(false); return ( -
+
diff --git a/dotcom-rendering/src/components/ScrollableFeature.island.tsx b/dotcom-rendering/src/components/ScrollableFeature.island.tsx index 9df79e8452c..2197d382e3d 100644 --- a/dotcom-rendering/src/components/ScrollableFeature.island.tsx +++ b/dotcom-rendering/src/components/ScrollableFeature.island.tsx @@ -92,6 +92,7 @@ export const ScrollableFeature = ({ starRatingSize={'small'} articleMedia={card.articleMedia} headlineTestUuid={card.headlineTestUuid} + isStorylines={false} /> ))} diff --git a/dotcom-rendering/src/components/StaticFeatureTwo.tsx b/dotcom-rendering/src/components/StaticFeatureTwo.tsx index dd8e149d611..4f3b147e3f4 100644 --- a/dotcom-rendering/src/components/StaticFeatureTwo.tsx +++ b/dotcom-rendering/src/components/StaticFeatureTwo.tsx @@ -81,6 +81,7 @@ export const StaticFeatureTwo = ({ starRatingSize={'medium'} articleMedia={card.articleMedia} headlineTestUuid={card.headlineTestUuid} + isStorylines={false} /> ); diff --git a/dotcom-rendering/src/components/StorylinesSectionContent.stories.tsx b/dotcom-rendering/src/components/StorylinesSectionContent.stories.tsx index ffcfed7549b..7b2b12a7102 100644 --- a/dotcom-rendering/src/components/StorylinesSectionContent.stories.tsx +++ b/dotcom-rendering/src/components/StorylinesSectionContent.stories.tsx @@ -1,4 +1,5 @@ import type { Meta, StoryObj } from '@storybook/react-webpack5'; +import MockDate from 'mockdate'; import { mockStorylinesSectionContent } from '../../fixtures/manual/storylines-section'; import { StorylinesSectionContent } from './StorylinesSectionContent.island'; @@ -12,6 +13,15 @@ const meta = { editionId: 'UK', storylinesContent: mockStorylinesSectionContent, }, + beforeEach: () => { + const globalMockDate = new Date(); + /** + * Fixture articles are from Dec 2025; the global mock date would put them in the future, + * but we want to make sure that they're in the past so that the publication date shows + * in the component correctly.*/ + MockDate.set('2025-12-10T12:00:00Z'); + return () => MockDate.set(globalMockDate); + }, render: (args) => , } satisfies Meta; diff --git a/dotcom-rendering/src/components/YoutubeAtom/YoutubeAtomFeatureCardOverlay.tsx b/dotcom-rendering/src/components/YoutubeAtom/YoutubeAtomFeatureCardOverlay.tsx index 78613be5b41..21ff032aedb 100644 --- a/dotcom-rendering/src/components/YoutubeAtom/YoutubeAtomFeatureCardOverlay.tsx +++ b/dotcom-rendering/src/components/YoutubeAtom/YoutubeAtomFeatureCardOverlay.tsx @@ -285,6 +285,7 @@ export const YoutubeAtomFeatureCardOverlay = ({ ) : undefined } media={articleMedia} + isStorylines={false} /> diff --git a/dotcom-rendering/src/layouts/TagPageLayout.tsx b/dotcom-rendering/src/layouts/TagPageLayout.tsx index e0688866fdf..13c2dd95c58 100644 --- a/dotcom-rendering/src/layouts/TagPageLayout.tsx +++ b/dotcom-rendering/src/layouts/TagPageLayout.tsx @@ -150,12 +150,6 @@ export const TagPageLayout = ({ tagPage, NAV }: Props) => { tagPage.pagination.currentPage === 1) && // Only on the first page index === 0; // Only after the first section - /** - * The pagination should appear at the bottom of the page; usually this is done by passing to FrontSection. - * If the storylines section is being inserted when there's only one other container on the page, - * we want to attach the pagination to it instead of the last trails section. - */ - const isLastGroup = index === tagPage.groupedTrails.length - 1; const hasPagination = !!tagPage.pagination; @@ -191,6 +185,11 @@ export const TagPageLayout = ({ tagPage, NAV }: Props) => { toggleable={false} pageId={tagPage.pageId} editionId={tagPage.editionId} + /** + * The pagination should appear at the bottom of the page; usually this is done by passing to FrontSection. + * If the storylines section is being inserted when there's only one other container on the page, + * we want to attach the pagination to it instead of the last trails section. + */ pagination={ insertStorylinesSection ? undefined From e1b5dc56a0acaf399b3d0aee292a7865759dd45a Mon Sep 17 00:00:00 2001 From: Pete F <37048459+bryophyta@users.noreply.github.com> Date: Wed, 14 Oct 2026 10:20:17 +0100 Subject: [PATCH 04/19] Refactor: more explicit intention for logic --- dotcom-rendering/src/components/Card/Card.tsx | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/dotcom-rendering/src/components/Card/Card.tsx b/dotcom-rendering/src/components/Card/Card.tsx index 939ab07292f..f58139ce246 100644 --- a/dotcom-rendering/src/components/Card/Card.tsx +++ b/dotcom-rendering/src/components/Card/Card.tsx @@ -456,7 +456,9 @@ export const Card = ({ const isLabs = format.theme === ArticleSpecial.Labs; const decideAge = () => { - if (!webPublicationDate || (!showAge && !isStorylines)) { + const shouldShowAgeIfKnown = showAge || isStorylines; + + if (!webPublicationDate || !shouldShowAgeIfKnown) { return undefined; } From 9dd3bfff4865f96e912ebf6b2fc0515f7926de91 Mon Sep 17 00:00:00 2001 From: Pete F <37048459+bryophyta@users.noreply.github.com> Date: Wed, 14 Oct 2026 10:20:45 +0100 Subject: [PATCH 05/19] Only show Storylines container for tablet size and above --- .../src/layouts/TagPageLayout.tsx | 31 ++++++++++--------- 1 file changed, 17 insertions(+), 14 deletions(-) diff --git a/dotcom-rendering/src/layouts/TagPageLayout.tsx b/dotcom-rendering/src/layouts/TagPageLayout.tsx index 13c2dd95c58..f751e314fdf 100644 --- a/dotcom-rendering/src/layouts/TagPageLayout.tsx +++ b/dotcom-rendering/src/layouts/TagPageLayout.tsx @@ -1,5 +1,6 @@ import { isUndefined, joinUrl } from '@guardian/libs'; import { palette } from '@guardian/source/foundations'; +import { Hide } from '@guardian/source/react-components'; import { Fragment } from 'react'; import { Accessibility } from '../components/Accessibility.island'; import { DecideContainerByTrails } from '../components/DecideContainerByTrails'; @@ -203,20 +204,22 @@ export const TagPageLayout = ({ tagPage, NAV }: Props) => { aspectRatio="5:4" /> - {insertStorylinesSection && - tagPage.storylinesContent && ( - - - - )} + + {insertStorylinesSection && + tagPage.storylinesContent && ( + + + + )} + {mobileAdPositions.includes(index) && ( Date: Wed, 14 Oct 2026 14:19:29 +0100 Subject: [PATCH 06/19] Don't show pub date on multimedia storylines cards --- dotcom-rendering/src/components/Card/Card.tsx | 2 -- .../components/Card/components/CardFooter.stories.tsx | 7 ------- .../src/components/Card/components/CardFooter.tsx | 9 --------- dotcom-rendering/src/components/FeatureCard.tsx | 1 - .../YoutubeAtom/YoutubeAtomFeatureCardOverlay.tsx | 1 - 5 files changed, 20 deletions(-) diff --git a/dotcom-rendering/src/components/Card/Card.tsx b/dotcom-rendering/src/components/Card/Card.tsx index f58139ce246..6783a1cac62 100644 --- a/dotcom-rendering/src/components/Card/Card.tsx +++ b/dotcom-rendering/src/components/Card/Card.tsx @@ -1234,7 +1234,6 @@ export const Card = ({ : undefined } isNewsletter={isNewsletter} - isStorylines={isStorylines} /> )} {showLivePlayable && @@ -1327,7 +1326,6 @@ export const Card = ({ mobile: avatarPosition.mobile === 'bottom', desktop: avatarPosition.desktop === 'bottom', }} - isStorylines={isStorylines} /> )} diff --git a/dotcom-rendering/src/components/Card/components/CardFooter.stories.tsx b/dotcom-rendering/src/components/Card/components/CardFooter.stories.tsx index f08e99ab171..9a96eb09ea3 100644 --- a/dotcom-rendering/src/components/Card/components/CardFooter.stories.tsx +++ b/dotcom-rendering/src/components/Card/components/CardFooter.stories.tsx @@ -22,7 +22,6 @@ export const WithAge = meta.story({ args: { format: defaultFormat, age:

19h ago

, - isStorylines: false, }, }); @@ -33,7 +32,6 @@ export const WithGallery = meta.story({ type: 'Gallery', count: '14', }, - isStorylines: false, }, }); @@ -44,7 +42,6 @@ export const WithAudio = meta.story({ type: 'Audio', duration: '12:34', }, - isStorylines: false, }, }); @@ -56,7 +53,6 @@ export const WithYoutubeVideo = meta.story({ duration: 972, isLive: false, }, - isStorylines: false, }, }); @@ -67,7 +63,6 @@ export const WithSelfHostedVideo = meta.story({ type: 'SelfHostedVideo', duration: 254, }, - isStorylines: false, }, }); @@ -75,7 +70,6 @@ export const WithNewsletter = meta.story({ args: { format: defaultFormat, isNewsletter: true, - isStorylines: false, }, }); @@ -86,6 +80,5 @@ export const WithBranding = meta.story({ theme: ArticleSpecial.Labs, }, cardBranding:

Card branding

, - isStorylines: false, }, }); diff --git a/dotcom-rendering/src/components/Card/components/CardFooter.tsx b/dotcom-rendering/src/components/Card/components/CardFooter.tsx index d6dc8d3edb3..825137a8287 100644 --- a/dotcom-rendering/src/components/Card/components/CardFooter.tsx +++ b/dotcom-rendering/src/components/Card/components/CardFooter.tsx @@ -63,7 +63,6 @@ type Props = { media?: ArticleMedia; isNewsletter?: boolean; shouldReserveSpace?: { mobile: boolean; desktop: boolean }; - isStorylines: boolean; }; export const CardFooter = ({ @@ -74,7 +73,6 @@ export const CardFooter = ({ media, isNewsletter, shouldReserveSpace, - isStorylines, }: Props) => { const shouldShowBranding = format.theme === ArticleSpecial.Labs && !!cardBranding; @@ -91,13 +89,6 @@ export const CardFooter = ({