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) => ( + + ))} + +
+ {/* 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