From f073b2ca8e673fa1fdc3720eda984c55c0c6be92 Mon Sep 17 00:00:00 2001 From: andresilva-guardian Date: Mon, 7 Sep 2026 15:45:03 +0100 Subject: [PATCH 01/36] docs: add multi-phase plan for generic Game page Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> --- .../docs/puzzles-game-page-plan.md | 147 ++++++++++++++++++ 1 file changed, 147 insertions(+) create mode 100644 dotcom-rendering/docs/puzzles-game-page-plan.md diff --git a/dotcom-rendering/docs/puzzles-game-page-plan.md b/dotcom-rendering/docs/puzzles-game-page-plan.md new file mode 100644 index 00000000000..494cee884f8 --- /dev/null +++ b/dotcom-rendering/docs/puzzles-game-page-plan.md @@ -0,0 +1,147 @@ +# Generic Game Page — Multi-Phase Plan + +## Objective + +Build a new, generic **Game page** design in dotcom-rendering (DCR) that +unifies rendering for all of the Guardian's puzzle/game types (crosswords, +sudoku, word games, trivia/quizzes, etc.) under one shared layout/design, +based on the existing Crossword article layout (`CrosswordLayout.tsx`), which +already visually matches the target design mockup. + +This must **not** touch or risk the existing `/crosswords` article rendering +path in any way, and must be hidden from the general public in production +behind the existing server-side AB test framework, testable by manually +forcing participation. + +## Context (condensed) + +1. DCR already renders individual crossword articles via the generic Article + pipeline: CAPI content has `format.design: 'CrosswordDesign'` → + `decideDesign()` in `src/lib/articleFormat.ts` maps it to + `ArticleDesign.Crossword` → `src/layouts/DecideLayout.tsx` picks + `src/layouts/CrosswordLayout.tsx` → the interactive grid is rendered via + `src/lib/renderElement.tsx` (case + `'model.dotcomrendering.pageElements.CrosswordElement'`) → + `src/components/CrosswordComponent.island.tsx` (wraps the + `@guardian/react-crossword` package as a hydrating Island). This path is + **not modified** by this work. +2. DCR's `main` branch also has a separate, unrelated "Puzzles Hub" + (listing/collection page) scaffold: `src/types/puzzlesPage.ts` + (`FEPuzzlesPageType`, `PuzzleItem`, `PuzzleContainer`, `PuzzleContent`), + `src/layouts/PuzzlesLayout.tsx`, `src/components/PuzzlesPage.tsx`, + `src/server/handler.puzzlesPage.web.ts`, + `src/server/render.puzzlesPage.web.tsx`, registered as + `app.post('/PuzzlesPage', handlePuzzlesPage)` in `src/server/server.prod.ts`, + gated by `src/lib/puzzlesHubExperiment.ts` (`isPuzzlesHubEnabled(config)` + checks `config.serverSideABTests['puzzles-new-hub'] === 'variant'`, 404 + otherwise). This is the HUB/LISTING page (a directory of all games) — a + **different concept** from what is built here (an individual GAME page, + analogous to today's individual crossword article page, but generalised to + other game types). These files are used purely as a pattern/convention + reference and are **not modified**. +3. Real, currently-live puzzle/game types and their properties, grouped + exactly as the target design mockup groups them ("Crosswords", "Logic + puzzles", "Word games", "Quizzes and Trivia"): + + | slug | group | renderMode | provider/notes | setter | comments | print | archive | + |---|---|---|---|---|---|---|---| + | crossword (mini/quick/cryptic/quick-cryptic/sunday-quick/prize/everyman/azed/special/genius/speedy/weekend) | crosswords | component | existing `@guardian/react-crossword`, reuse `CrosswordComponent.island.tsx` | ✅ | ✅ | ✅ | ✅ | + | sudoku-easy | logic-puzzles | iframe | AmuseLabs: `set=guardian-sudoku-easy` | ❌ | ❌ | ✅ | ✅ | + | sudoku-medium | logic-puzzles | iframe | AmuseLabs: `set=guardian-sudoku-medium` | ❌ | ❌ | ✅ | ✅ | + | sudoku-hard | logic-puzzles | iframe | AmuseLabs: `set=guardian-sudoku-hard` | ❌ | ❌ | ✅ | ✅ | + | sudoku-killer | logic-puzzles | iframe | AmuseLabs: `set=guardian-sudoku-killer` | ❌ | ❌ | ✅ | ✅ | + | futoshiki | logic-puzzles | iframe | AmuseLabs: `set=guardian-futoshiki` | ❌ | ❌ | ✅ | ✅ | + | suguru | logic-puzzles | iframe | AmuseLabs: `set=guardian-suguru` | ❌ | ❌ | ✅ | ✅ | + | word-wheel | word-games | iframe | AmuseLabs: `set=guardian-word-wheel` | ❌ | ❌ | ✅ | ✅ | + | codeword | word-games | iframe | AmuseLabs: `set=guardian-codeword` | ❌ | ❌ | ✅ | ✅ | + | wordiply | word-games | iframe | bespoke: `https://www.wordiply.com/` | ❌ | ❌ | ✅ | ✅ | + | on-the-ball | trivia-and-quizzes | iframe | bespoke: `https://sportsreveal.io/guardian` | ❌ | ❌ | ✅ | ✅ | + | film-reveal | trivia-and-quizzes | iframe | bespoke: `https://moviegrid.io/guardian` | ❌ | ❌ | ✅ | ✅ | + + All AmuseLabs games share the exact same URL template + (`https://tg.amuselabs.com/guardian/date-picker?set=guardian-{slug}&embed=1&idx=1`), + differing only by the `set=guardian-{slug}` query param — modelled as data, + not near-duplicate code paths. + +## Multi-phase plan + +### Phase 1 — DCR generic Game page (this phase) + +Implement, in dotcom-rendering only, a new `POST /GamePage` route + layout + +registry + fixtures + tests, fully independent of the Article/Crossword +pipeline and the Puzzles Hub. See "Phase 1 detail" below. + +### Phase 2 — Frontend (Play/Scala) integration (parallel session, out of scope here) + +A parallel session builds the `frontend` repo route/controller that resolves +real content for each of the 12 game slugs and POSTs a `FEGamePageType` +payload (see "Handoff contract for frontend" below) to DCR's `POST /GamePage`. +That work is not visible to this session and is not attempted here. + +### Phase 3 — Wire the real AB test + production validation (future phase) + +Once phases 1 and 2 are integrated end-to-end in a test environment: create +the real `game-page-experiment` server-side AB test in the AB testing +framework/config used by `frontend`/Fastly, validate the header/participation +flow end-to-end (request header → Play → `config.serverSideABTests` on the +POST body → DCR gate), and validate visually against the target mockup in a +non-prod environment before any public rollout. + +## Phase 1 detail — key design decisions + +- **Layout composition**: `GameLayout.tsx` is a fresh, self-contained layout — + not a fork of `CrosswordLayout.tsx`, and not a consumer of Article-domain + composite components (`ArticleMeta`, `ArticleTitle`, `ArticleBody`), because + those require a full `ArticleFormat` + `TagType[]` + branding/podcast/avatar + logic that doesn't exist for a generic game page; fabricating fake Article + data to satisfy their prop contracts would create more coupling/risk than + benefit, and is exactly the kind of accidental entanglement with the + crossword article path this work must avoid. +- However, this does **not** mean re-inventing everything. `GameLayout.tsx` + directly reuses, unmodified, these existing generic building blocks: + `Masthead`, `Section`, `GridItem`, `AdSlot`/`HeaderAdSlot`/ + `MobileStickyContainer`, `DiscussionLayout`, `Footer`, `Island`, `SubNav`, + `DecideLines`, `StraightLines`, `RightColumn`, `StickyBottomBanner`, + `CommentCount.island` (generic, no `ArticleFormat` dependency), and + `CrosswordSetter` (generic, `{ setter, profileUrl }` only — reused verbatim + for the setter byline of any game with `setterEnabled`, in practice only + crosswords in this dataset). `ShareButton.island` is reused too; it takes an + `ArticleFormat` purely to branch minor styling, so a small, + locally-constructed `ArticleFormat` value is passed (reusing existing enum + values as plain data) — this is read-only reuse, not a modification of + `articleFormat.ts` or the crossword decision logic. +- Genuinely new, small presentational pieces are added only where nothing + reusable already exists: the puzzle type/group label ("red label" in the + mockup), a standalone `PrintButton` (today print is inlined inside + `ArticleMeta.web.tsx`, not a standalone component), and the "More from + Puzzles & games" rail (reusing the existing `PuzzleItem` type from + `src/types/puzzlesPage.ts` for data-shape consistency, without + importing/modifying internal Puzzles Hub layout components). +- `GameConfig` may carry a couple of extra optional fields beyond the minimal + spec if useful during implementation, keeping the registry as the single + source of structural truth rather than hard-coding text in the layout. + +## Progress tracker + +- [x] Create this plan doc, commit as first commit +- [ ] `src/types/gamePage.ts` — `FEGamePageType` +- [ ] `src/model/games/gameConfigs.ts` — `GameConfig` type + registry for all 12 slugs +- [ ] `src/lib/gamePageExperiment.ts` — `isGamePageEnabled` gate (`game-page-experiment`) +- [ ] `src/lib/gameComponents.ts` — componentKey → component registry (`crossword` → `CrosswordComponent.island`) +- [ ] `src/components/GameIframe.island.tsx` — generic sandboxed iframe island +- [ ] `src/layouts/GameLayout.tsx` — new generic layout +- [ ] `src/server/handler.gamePage.web.ts` + `src/server/render.gamePage.web.tsx` +- [ ] Register `POST /GamePage` in `server.prod.ts` and `server.dev.ts` (+ dev `GET` passthrough route) +- [ ] `fixtures/manual/gamePage.ts` — fixtures for all 12 slugs +- [ ] Tests: handler, gate, registry validation (mirroring `handler.puzzlesPage.web.test.ts` / `validate.puzzlesPage.test.ts` patterns) +- [ ] Lint / typecheck / targeted tests green +- [ ] Manual validation steps + frontend handoff contract written up below +- [ ] Mark this phase done in this doc + +## Manual validation steps + +_(to be filled in as the final step of this phase, once the implementation is complete)_ + +## Handoff contract for frontend + +_(to be filled in as the final step of this phase — exact `FEGamePageType` JSON shape to `POST` to `/GamePage`)_ From 0d2eccd60f8384da679d69855bda3fdb8894409e Mon Sep 17 00:00:00 2001 From: andresilva-guardian Date: Mon, 7 Sep 2026 15:56:36 +0100 Subject: [PATCH 02/36] feat(game-page): add GameConfig registry, FEGamePageType, and AB gate - src/types/gamePage.ts: FEGamePageType request payload contract - src/model/games/gameConfigs.ts: data-driven GameConfig registry for all 12 supported game slugs (crossword + 6 AmuseLabs + wordiply + on-the-ball + film-reveal), with load-time validation - src/lib/gamePageExperiment.ts: isGamePageEnabled AB test gate (game-page-experiment), mirroring puzzlesHubExperiment.ts Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> --- .../src/lib/gamePageExperiment.test.ts | 29 +++ .../src/lib/gamePageExperiment.ts | 20 ++ .../src/model/games/gameConfigs.test.ts | 103 ++++++++++ .../src/model/games/gameConfigs.ts | 192 ++++++++++++++++++ dotcom-rendering/src/types/gamePage.ts | 50 +++++ 5 files changed, 394 insertions(+) create mode 100644 dotcom-rendering/src/lib/gamePageExperiment.test.ts create mode 100644 dotcom-rendering/src/lib/gamePageExperiment.ts create mode 100644 dotcom-rendering/src/model/games/gameConfigs.test.ts create mode 100644 dotcom-rendering/src/model/games/gameConfigs.ts create mode 100644 dotcom-rendering/src/types/gamePage.ts diff --git a/dotcom-rendering/src/lib/gamePageExperiment.test.ts b/dotcom-rendering/src/lib/gamePageExperiment.test.ts new file mode 100644 index 00000000000..47496ca9bb7 --- /dev/null +++ b/dotcom-rendering/src/lib/gamePageExperiment.test.ts @@ -0,0 +1,29 @@ +import { + gamePageExperiment, + gamePageParticipation, + isGamePageEnabled, +} from './gamePageExperiment'; + +describe('isGamePageEnabled', () => { + it('enables only the configured variant', () => { + expect( + isGamePageEnabled({ + serverSideABTests: gamePageParticipation( + gamePageExperiment.variant, + ), + }), + ).toBe(true); + }); + + it.each([ + gamePageParticipation(gamePageExperiment.control), + gamePageParticipation('unknown'), + gamePageParticipation('variant:extra'), + {}, + { 'another-test': 'variant' }, + ])('returns false for non-variant participation %#', (participations) => { + expect(isGamePageEnabled({ serverSideABTests: participations })).toBe( + false, + ); + }); +}); diff --git a/dotcom-rendering/src/lib/gamePageExperiment.ts b/dotcom-rendering/src/lib/gamePageExperiment.ts new file mode 100644 index 00000000000..dcb9a508ac3 --- /dev/null +++ b/dotcom-rendering/src/lib/gamePageExperiment.ts @@ -0,0 +1,20 @@ +import type { ConfigType } from '../types/config'; + +export const gamePageExperiment = { + name: 'game-page-experiment', + variant: 'variant', + control: 'control', +} as const; + +type GamePageExperimentConfig = Pick; + +export const isGamePageEnabled = ({ + serverSideABTests, +}: GamePageExperimentConfig): boolean => + serverSideABTests[gamePageExperiment.name] === gamePageExperiment.variant; + +export const gamePageParticipation = ( + group: string, +): Record => ({ + [gamePageExperiment.name]: group, +}); diff --git a/dotcom-rendering/src/model/games/gameConfigs.test.ts b/dotcom-rendering/src/model/games/gameConfigs.test.ts new file mode 100644 index 00000000000..85b83aeec1a --- /dev/null +++ b/dotcom-rendering/src/model/games/gameConfigs.test.ts @@ -0,0 +1,103 @@ +import { + gameConfigs, + getGameConfig, + resolveIframeUrl, + validateGameConfigs, +} from './gameConfigs'; + +describe('gameConfigs registry', () => { + it('has an entry for every documented slug', () => { + expect(Object.keys(gameConfigs).sort()).toEqual( + [ + 'codeword', + 'crossword', + 'film-reveal', + 'futoshiki', + 'on-the-ball', + 'sudoku-easy', + 'sudoku-hard', + 'sudoku-killer', + 'sudoku-medium', + 'suguru', + 'word-wheel', + 'wordiply', + ].sort(), + ); + }); + + it('does not throw for the current registry', () => { + expect(() => validateGameConfigs(gameConfigs)).not.toThrow(); + }); + + it('rejects a registry entry whose slug does not match its key', () => { + expect(() => + validateGameConfigs({ + ...gameConfigs, + crossword: { ...gameConfigs.crossword!, slug: 'not-crossword' }, + }), + ).toThrow(TypeError); + }); + + it('rejects a component entry missing componentKey', () => { + expect(() => + validateGameConfigs({ + ...gameConfigs, + crossword: { + ...gameConfigs.crossword!, + componentKey: undefined, + }, + }), + ).toThrow(TypeError); + }); + + it('rejects an iframe entry missing its iframe config', () => { + expect(() => + validateGameConfigs({ + ...gameConfigs, + wordiply: { ...gameConfigs.wordiply!, iframe: undefined }, + }), + ).toThrow(TypeError); + }); + + it('rejects an entry that mixes componentKey and iframe', () => { + expect(() => + validateGameConfigs({ + ...gameConfigs, + wordiply: { + ...gameConfigs.wordiply!, + componentKey: 'crossword', + }, + }), + ).toThrow(TypeError); + }); + + describe('getGameConfig', () => { + it('returns the config for a known slug', () => { + expect(getGameConfig('crossword')?.gameGroup).toBe('crosswords'); + }); + + it('returns undefined for an unknown slug', () => { + expect(getGameConfig('not-a-real-game')).toBeUndefined(); + }); + }); + + describe('resolveIframeUrl', () => { + it('substitutes the slug into the AmuseLabs URL template', () => { + expect(resolveIframeUrl(gameConfigs['sudoku-easy']!)).toBe( + 'https://tg.amuselabs.com/guardian/date-picker?set=guardian-sudoku-easy&embed=1&idx=1', + ); + }); + + it('returns the bespoke provider URL unchanged when it has no placeholder', () => { + expect(resolveIframeUrl(gameConfigs.wordiply!)).toBe( + 'https://www.wordiply.com/', + ); + }); + + it('throws for a component-rendered game with no iframe config', () => { + expect(() => resolveIframeUrl(gameConfigs.crossword!)).toThrow( + TypeError, + ); + }); + }); +}); diff --git a/dotcom-rendering/src/model/games/gameConfigs.ts b/dotcom-rendering/src/model/games/gameConfigs.ts new file mode 100644 index 00000000000..e374dd9e09d --- /dev/null +++ b/dotcom-rendering/src/model/games/gameConfigs.ts @@ -0,0 +1,192 @@ +/** + * DCR's single source of truth for the structural/rendering behaviour of + * each supported Game page slug. + * + * This registry is deliberately data-driven: all AmuseLabs-hosted games + * (sudoku variants, futoshiki, suguru, word-wheel, codeword) share the exact + * same iframe URL template and differ only by the `{slug}` substitution, so + * they are modelled as data rather than near-duplicate code paths. + */ + +export const gameGroups = [ + 'crosswords', + 'logic-puzzles', + 'word-games', + 'trivia-and-quizzes', +] as const; + +export type GameGroup = (typeof gameGroups)[number]; + +export const gameRenderModes = ['component', 'iframe'] as const; + +export type GameRenderMode = (typeof gameRenderModes)[number]; + +export interface GameIframeConfig { + provider: string; + /** + * The iframe src URL. May contain a `{slug}` placeholder token, which is + * substituted with the game's `slug` at render time. + */ + urlTemplate: string; +} + +export interface GameConfig { + slug: string; + gameGroup: GameGroup; + renderMode: GameRenderMode; + /** + * Registry key resolved via `src/lib/gameComponents.ts`. Required when + * `renderMode` is `'component'`. + */ + componentKey?: string; + /** Required when `renderMode` is `'iframe'`. */ + iframe?: GameIframeConfig; + setterEnabled: boolean; + commentsEnabled: boolean; + shareEnabled: boolean; + printEnabled: boolean; + hasArchive: boolean; +} + +const amuseLabsUrlTemplate = + 'https://tg.amuselabs.com/guardian/date-picker?set=guardian-{slug}&embed=1&idx=1'; + +const amuseLabsGame = (slug: string, gameGroup: GameGroup): GameConfig => ({ + slug, + gameGroup, + renderMode: 'iframe', + iframe: { provider: 'amuselabs', urlTemplate: amuseLabsUrlTemplate }, + setterEnabled: false, + commentsEnabled: false, + shareEnabled: true, + printEnabled: true, + hasArchive: true, +}); + +/** + * The full set of supported Game page slugs. Keys match each entry's `slug` + * field (validated at load time by `validateGameConfigs` below). + */ +export const gameConfigs: Record = { + crossword: { + slug: 'crossword', + gameGroup: 'crosswords', + renderMode: 'component', + componentKey: 'crossword', + setterEnabled: true, + commentsEnabled: true, + shareEnabled: true, + printEnabled: true, + hasArchive: true, + }, + 'sudoku-easy': amuseLabsGame('sudoku-easy', 'logic-puzzles'), + 'sudoku-medium': amuseLabsGame('sudoku-medium', 'logic-puzzles'), + 'sudoku-hard': amuseLabsGame('sudoku-hard', 'logic-puzzles'), + 'sudoku-killer': amuseLabsGame('sudoku-killer', 'logic-puzzles'), + futoshiki: amuseLabsGame('futoshiki', 'logic-puzzles'), + suguru: amuseLabsGame('suguru', 'logic-puzzles'), + 'word-wheel': amuseLabsGame('word-wheel', 'word-games'), + codeword: amuseLabsGame('codeword', 'word-games'), + wordiply: { + slug: 'wordiply', + gameGroup: 'word-games', + renderMode: 'iframe', + iframe: { + provider: 'wordiply', + urlTemplate: 'https://www.wordiply.com/', + }, + setterEnabled: false, + commentsEnabled: false, + shareEnabled: true, + printEnabled: true, + hasArchive: true, + }, + 'on-the-ball': { + slug: 'on-the-ball', + gameGroup: 'trivia-and-quizzes', + renderMode: 'iframe', + iframe: { + provider: 'sportsreveal', + urlTemplate: 'https://sportsreveal.io/guardian', + }, + setterEnabled: false, + commentsEnabled: false, + shareEnabled: true, + printEnabled: true, + hasArchive: true, + }, + 'film-reveal': { + slug: 'film-reveal', + gameGroup: 'trivia-and-quizzes', + renderMode: 'iframe', + iframe: { + provider: 'moviegrid', + urlTemplate: 'https://moviegrid.io/guardian', + }, + setterEnabled: false, + commentsEnabled: false, + shareEnabled: true, + printEnabled: true, + hasArchive: true, + }, +}; + +/** + * Look up a game's structural config by slug. Returns `undefined` for an + * unknown slug so callers (e.g. the `/GamePage` handler) can decide how to + * respond (404). + */ +export const getGameConfig = (slug: string): GameConfig | undefined => + gameConfigs[slug]; + +/** + * Resolve the final iframe src URL for an `iframe`-rendered game, expanding + * the `{slug}` placeholder token in `GameIframeConfig.urlTemplate`. + */ +export const resolveIframeUrl = (config: GameConfig): string => { + if (!config.iframe) { + throw new TypeError( + `GameConfig for slug "${config.slug}" has no iframe config.`, + ); + } + return config.iframe.urlTemplate.replaceAll('{slug}', config.slug); +}; + +const isValidGameConfig = (key: string, config: GameConfig): boolean => { + if (config.slug !== key) return false; + if (!gameGroups.includes(config.gameGroup)) return false; + if (!gameRenderModes.includes(config.renderMode)) return false; + if (config.renderMode === 'component' && !config.componentKey) { + return false; + } + if (config.renderMode === 'iframe' && !config.iframe) { + return false; + } + if (config.renderMode === 'iframe' && config.componentKey) { + return false; + } + if (config.renderMode === 'component' && config.iframe) { + return false; + } + return true; +}; + +/** + * Fail fast if the registry itself is malformed (e.g. a mismatched slug key, + * or a `component` entry missing its `componentKey`). Run once at module + * load so a bad registry entry surfaces immediately rather than at request + * time. + */ +export const validateGameConfigs = ( + configs: Record, +): void => { + for (const [key, config] of Object.entries(configs)) { + if (!isValidGameConfig(key, config)) { + throw new TypeError( + `Invalid GameConfig registry entry for slug "${key}".`, + ); + } + } +}; + +validateGameConfigs(gameConfigs); diff --git a/dotcom-rendering/src/types/gamePage.ts b/dotcom-rendering/src/types/gamePage.ts new file mode 100644 index 00000000000..9dc1b0485cf --- /dev/null +++ b/dotcom-rendering/src/types/gamePage.ts @@ -0,0 +1,50 @@ +import type { EditionId } from '../lib/edition'; +import type { ConfigType } from './config'; +import type { FooterType } from './footer'; +import type { FENavType } from './frontend'; +import type { PuzzleItem } from './puzzlesPage'; + +/** + * The instance-specific data for a single Game page: the concrete content + * (title, setter, date, etc.) resolved by frontend for a given game `slug`. + * + * `crosswordData` carries the raw crossword element JSON (the same shape as + * today's `model.dotcomrendering.pageElements.CrosswordElement`) and is only + * present when the resolved `GameConfig` for `slug` has + * `renderMode: 'component'` with `componentKey: 'crossword'`. + */ +export interface GamePageInstance { + title: string; + /** e.g. "Quick crossword" — the red label shown in the mockup. */ + puzzleType?: string; + setterName?: string; + date?: string; + specialInstructions?: string; + /** Only relevant when the resolved `GameConfig.commentsEnabled` is true. */ + discussionId?: string; + /** + * Raw crossword element JSON. Only present when `slug` resolves to the + * crossword game. Left as `unknown` here as DCR's `GameConfig` registry + * (not this payload type) is the source of truth for how to interpret it. + */ + crosswordData?: unknown; + moreFromPuzzlesAndGames?: PuzzleItem[]; +} + +/** + * The request payload contract for `POST /GamePage`, modeled closely on + * `FEPuzzlesPageType` (see `src/types/puzzlesPage.ts`) for consistency of + * conventions between the two, unrelated, puzzles-related page types. + */ +export interface FEGamePageType { + id: string; + /** Looked up in DCR's `GameConfig` registry (`src/model/games/gameConfigs.ts`). */ + slug: string; + webTitle: string; + config: ConfigType; + nav: FENavType; + pageFooter: FooterType; + canonicalUrl: string; + editionId: EditionId; + instance: GamePageInstance; +} From e64e1fe99c9b99b68d354b55dc5ed1e0e09b3619 Mon Sep 17 00:00:00 2001 From: andresilva-guardian Date: Mon, 7 Sep 2026 15:56:52 +0100 Subject: [PATCH 03/36] feat(game-page): add GameLayout, GamePage, and iframe/component registries - src/lib/gameComponents.ts: componentKey -> component registry (only 'crossword' mapped, reusing CrosswordComponent.island.tsx as-is) - src/components/GameIframe.island.tsx: generic sandboxed iframe island for renderMode 'iframe' games - src/layouts/GameLayout.tsx: fresh, self-contained layout mirroring the target mockup structure (masthead, type/group label, title, conditional setter/share/print/comments, ad slots, related rail, footer). Reuses existing generic building blocks (Masthead, Section, DiscussionLayout, Footer, AdSlot, CommentCount.island, CrosswordSetter, ShareButton.island) rather than duplicating them or forcing reuse of Article-domain composite components (ArticleMeta/ArticleTitle), which require a full ArticleFormat + TagType[] + branding/podcast machinery unrelated to generic game pages. - src/components/GamePage.tsx: top-level page component wiring islands (Metrics, SetABTests, AlreadyVisited, etc.) around GameLayout, mirroring PuzzlesPage.tsx conventions. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> --- .../src/components/GameIframe.island.tsx | 28 ++ dotcom-rendering/src/components/GamePage.tsx | 58 +++ dotcom-rendering/src/layouts/GameLayout.tsx | 412 ++++++++++++++++++ dotcom-rendering/src/lib/gameComponents.ts | 22 + 4 files changed, 520 insertions(+) create mode 100644 dotcom-rendering/src/components/GameIframe.island.tsx create mode 100644 dotcom-rendering/src/components/GamePage.tsx create mode 100644 dotcom-rendering/src/layouts/GameLayout.tsx create mode 100644 dotcom-rendering/src/lib/gameComponents.ts diff --git a/dotcom-rendering/src/components/GameIframe.island.tsx b/dotcom-rendering/src/components/GameIframe.island.tsx new file mode 100644 index 00000000000..bd143a5fd9c --- /dev/null +++ b/dotcom-rendering/src/components/GameIframe.island.tsx @@ -0,0 +1,28 @@ +import { css } from '@emotion/react'; + +interface Props { + /** The already-resolved iframe src URL (with `{slug}` substituted). */ + src: string; + title: string; +} + +const frameStyles = css` + width: 100%; + min-height: 500px; + border: none; +`; + +/** + * Generic sandboxed iframe wrapper for third-party (or in-house, non-React) + * puzzle/game providers, such as AmuseLabs-hosted games or bespoke providers + * like wordiply.com. Used for any `GameConfig` with `renderMode: 'iframe'`. + */ +export const GameIframe = ({ src, title }: Props) => ( +