Skip to content
Merged
Show file tree
Hide file tree
Changes from 11 commits
Commits
Show all changes
56 commits
Select commit Hold shift + click to select a range
f073b2c
docs: add multi-phase plan for generic Game page
andresilva-guardian Sep 7, 2026
0d2eccd
feat(game-page): add GameConfig registry, FEGamePageType, and AB gate
andresilva-guardian Sep 7, 2026
e64e1fe
feat(game-page): add GameLayout, GamePage, and iframe/component regis…
andresilva-guardian Sep 7, 2026
8f67895
feat(game-page): add handler, render, and POST /GamePage route
andresilva-guardian Sep 7, 2026
4de9def
test(game-page): add fixtures and tests for handler/gate/registry
andresilva-guardian Sep 7, 2026
47b4e51
docs: finalize Game page plan with manual validation steps and fronte…
andresilva-guardian Sep 7, 2026
f4c21ef
Remove game-page-experiment AB gate (routes will be mapped in a separ…
andresilva-guardian Sep 7, 2026
bd8560e
Fix GameLayout visual parity: PDF link and section label link
andresilva-guardian Sep 7, 2026
cc86529
docs: add docs/game-page.md as the real reference doc for Game Page
andresilva-guardian Sep 8, 2026
c7110cd
docs(game-page): list local access command for each of the 12 slugs
andresilva-guardian Sep 8, 2026
ab6cb4d
fix: dev server root sendFile 404s when repo path contains a dot-segm…
andresilva-guardian Sep 8, 2026
3fc8b78
Remove crossword from Game Page: simplify GameConfig registry + FEGam…
andresilva-guardian Sep 10, 2026
5692b3f
Remove crossword rendering code from GameLayout
andresilva-guardian Sep 10, 2026
354e3e1
Remove crossword fixtures/test cases for Game Page
andresilva-guardian Sep 10, 2026
bb0f5ba
docs(game-page): update for the iframe-only scope reduction
andresilva-guardian Sep 10, 2026
3c35ae2
Rename Game Page -> Puzzle Page throughout (PR review: terminology)
andresilva-guardian Sep 10, 2026
80d6981
Remove non-V0 puzzles from the registry (PR review feedback)
andresilva-guardian Sep 10, 2026
be85602
Move Puzzle Page validation into its own file (PR review feedback)
andresilva-guardian Sep 10, 2026
8cd7f6d
Pass user ID to puzzle iframe + postMessage + reload on login (PR rev…
andresilva-guardian Sep 10, 2026
3bd0204
docs: consolidate into a single docs/puzzle-page.md reference
andresilva-guardian Sep 10, 2026
9b4404e
Add optional puzzleDate field to FEPuzzlePageType (PR review)
andresilva-guardian Sep 11, 2026
1ba5a53
docs(puzzle-page): reflect puzzleDate, frontend gating, and new publi…
andresilva-guardian Sep 11, 2026
3a800b6
docs+fixtures: reflect frontend's new top-level puzzle-page URL shape
andresilva-guardian Sep 11, 2026
b60a73d
Replace userId-only iframe context with combined guardian-puzzle-context
andresilva-guardian Sep 14, 2026
975c1e6
docs(puzzle-page): reflect combined guardian-puzzle-context mechanism
andresilva-guardian Sep 14, 2026
cfaa3b3
Add curated per-puzzle SEO description to PuzzleConfig registry
andresilva-guardian Sep 14, 2026
872b415
Wire PuzzleConfig.description into render.puzzlePage.web.tsx + OG/Twi…
andresilva-guardian Sep 14, 2026
067215b
docs(puzzle-page): document SEO description/OG/Twitter wiring + futur…
andresilva-guardian Sep 14, 2026
4af65b6
Add optional image field to PuzzleConfig for share/preview images
andresilva-guardian Sep 14, 2026
4797dd7
Wire optional PuzzleConfig.image into og:image/twitter:image + test c…
andresilva-guardian Sep 14, 2026
72ddf81
docs(puzzle-page): document optional PuzzleConfig.image field
andresilva-guardian Sep 14, 2026
1e07c76
Add cumulative v1/v2 AB test entries for Puzzles & Games rollout
andresilva-guardian Sep 14, 2026
5130cc5
Add cumulative isPuzzlesHubV1Enabled/isPuzzlesHubV2Enabled gate helpers
andresilva-guardian Sep 14, 2026
ebc184d
Gate the "More from Puzzles & Games" rail behind isPuzzlesHubV1Enabled
andresilva-guardian Sep 14, 2026
5c59c20
docs(puzzle-page): document 3-tier v0/v1/v2 rollout gating structure
andresilva-guardian Sep 14, 2026
53c842c
Remove em dashes across branch per new house style rule
andresilva-guardian Sep 14, 2026
56348c6
Display instance.puzzleDate next to the Puzzle Page title
andresilva-guardian Sep 15, 2026
47ea8be
Include puzzleDate in the guardian-puzzle-context sent to the iframe
andresilva-guardian Sep 15, 2026
3399817
docs: update puzzle-page.md for the now-active puzzleDate
andresilva-guardian Sep 15, 2026
7fab67a
Add formatPuzzleDateShort for SEO title/description templating
andresilva-guardian Sep 15, 2026
5d7ff1e
Add exact SEO title/description copy with {date} templating
andresilva-guardian Sep 15, 2026
4a20458
Wire the resolved SEO title into <title>/og:title/twitter:title
andresilva-guardian Sep 15, 2026
2b3aa30
docs: document SEO title/description templating and its scope
andresilva-guardian Sep 15, 2026
15f5eb8
Fix killer sudoku's wrong AmuseLabs URL, remove slug-derived template
andresilva-guardian Sep 15, 2026
d5ff801
Add confirmed uid query param alongside guardian-puzzle-context
andresilva-guardian Sep 15, 2026
6f4ebbb
docs: document the killer-sudoku URL fix and the confirmed uid param
andresilva-guardian Sep 15, 2026
38d818d
docs: document idx=1/archive-URL and future Puzzles Server architectu…
andresilva-guardian Sep 15, 2026
2f43e22
Change PuzzleIframeConfig to a discriminated union by provider
andresilva-guardian Sep 15, 2026
732d64e
Add per-provider iframe URL builder strategy (src/lib/puzzleIframeUrl…
andresilva-guardian Sep 15, 2026
c3a4c99
Wire PuzzleIframe to the new per-provider URL builder strategy
andresilva-guardian Sep 15, 2026
d744cea
Update PuzzleIframe.island.test.tsx for the refactored responsibility…
andresilva-guardian Sep 15, 2026
726d040
docs: document the per-provider iframe URL strategy abstraction
andresilva-guardian Sep 15, 2026
3bf3dd5
Increase puzzle iframe min-height below tablet for AmuseLabs menu reflow
andresilva-guardian Sep 15, 2026
f848d20
Scope the print button to Sudoku only, per explicit product guidance
andresilva-guardian Sep 15, 2026
9a8535e
docs: document responsive iframe height estimate and Sudoku-only print
andresilva-guardian Sep 15, 2026
93e55ce
Merge remote-tracking branch 'origin/main' into afs/puzzles-game-page
andresilva-guardian Sep 15, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
335 changes: 335 additions & 0 deletions dotcom-rendering/docs/game-page.md

Large diffs are not rendered by default.

410 changes: 410 additions & 0 deletions dotcom-rendering/docs/puzzles-game-page-plan.md

Large diffs are not rendered by default.

106 changes: 106 additions & 0 deletions dotcom-rendering/fixtures/manual/gamePage.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,106 @@
import type { CrosswordProps } from '@guardian/react-crossword';
import { gameConfigs } from '../../src/model/games/gameConfigs';
import type { FEGamePageType } from '../../src/types/gamePage';
import type { PuzzleItem } from '../../src/types/puzzlesPage';
import { Standard } from '../generated/fe-articles/Standard';

const sampleMoreFromPuzzlesAndGames: PuzzleItem[] = [
{
id: 'sudoku-easy-daily',
title: 'Sudoku easy',
type: 'sudoku-easy',
set: 'all',
cardVariant: 'compact',
cadence: 'Daily',
slug: 'sudoku-easy',
},
{
id: 'word-wheel-daily',
title: 'Word wheel',
type: 'word-wheel',
set: 'all',
cardVariant: 'compact',
cadence: 'Daily',
slug: 'word-wheel',
},
];

const sampleCrosswordData: CrosswordProps['data'] = {
crosswordType: 'quick',
date: Date.now(),
dimensions: { cols: 3, rows: 1 },
id: 'quick/1',
name: 'Quick crossword No 1',
number: 1,
solutionAvailable: false,
entries: [
{
id: '1-across',
number: 1,
humanNumber: '1',
clue: 'Sample clue (3)',
direction: 'across',
position: { x: 0, y: 0 },
separatorLocations: {},
length: 3,
group: ['1-across'],
},
],
};

/**
* Builds a `FEGamePageType` fixture for the given `slug`, defaulting to a
* generic instance for that slug's `GameConfig`. Pass `overrides` to
* customise individual fields (deep-merged only one level for `instance`).
*/
export const createGamePage = (
slug: string,
overrides: Partial<FEGamePageType> = {},
): FEGamePageType => {
const gameConfig = gameConfigs[slug];

if (!gameConfig) {
throw new Error(`Unknown game slug in fixture: ${slug}`);
}

const isCrossword = gameConfig.componentKey === 'crossword';

return {
id: `game-page-${slug}`,
slug,
webTitle: `${slug} | The Guardian`,
config: {
...Standard.config,
contentType: 'Game',
// DCR no longer gates /GamePage on any AB test participation (the
// former 'game-page-experiment' gate was removed; routes will be
// mapped/exposed via a different project instead), so this is left
// empty rather than implying any particular value is required.
serverSideABTests: {},
},
nav: Standard.nav,
pageFooter: Standard.pageFooter,
canonicalUrl: `https://www.theguardian.com/games/${slug}`,
editionId: Standard.editionId,
instance: {
title: isCrossword ? 'Quick crossword No 1' : `${slug} puzzle`,
puzzleType: isCrossword ? 'Quick crossword' : undefined,
setterName: gameConfig.setterEnabled ? 'Sample Setter' : undefined,
date: '1 January 2025',
discussionId: gameConfig.commentsEnabled
? `game/${slug}`
: undefined,
crosswordData: isCrossword ? sampleCrosswordData : undefined,
moreFromPuzzlesAndGames: sampleMoreFromPuzzlesAndGames,
},
...overrides,
};
};

/** One fixture per supported slug, for local dev preview and tests. */
export const gamePageFixtures: Record<string, FEGamePageType> = Object.keys(
gameConfigs,
).reduce<Record<string, FEGamePageType>>((acc, slug) => {
acc[slug] = createGamePage(slug);
return acc;
}, {});
28 changes: 28 additions & 0 deletions dotcom-rendering/src/components/GameIframe.island.tsx
Original file line number Diff line number Diff line change
@@ -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) => (
Comment thread
andresilva-guardian marked this conversation as resolved.
Outdated
<iframe
css={frameStyles}
src={src}
title={title}
loading="lazy"
sandbox="allow-scripts allow-same-origin allow-popups allow-forms"
/>
);
58 changes: 58 additions & 0 deletions dotcom-rendering/src/components/GamePage.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,58 @@
import { Global } from '@emotion/react';
import { StrictMode } from 'react';
import { GameLayout, type ResolvedGamePage } from '../layouts/GameLayout';
import { ArticleDesign, ArticleDisplay, Pillar } from '../lib/articleFormat';
import { rootStyles } from '../lib/rootStyles';
import type { NavType } from '../model/extract-nav';
import { AdmiralScript } from './AdmiralScript.island';
import { AlreadyVisited } from './AlreadyVisited.island';
import { useConfig } from './ConfigContext';
import { FocusStyles } from './FocusStyles.island';
import { Island } from './Island';
import { Metrics } from './Metrics.island';
import { SetABTests } from './SetABTests.island';
import { SkipTo } from './SkipTo';

type Props = {
gamePage: ResolvedGamePage;
NAV: NavType;
};

export const GamePage = ({ gamePage, NAV }: Props) => {
const format = {
display: ArticleDisplay.Standard,
design: ArticleDesign.Standard,
theme: Pillar.News,
};
const { darkModeAvailable } = useConfig();

return (
<StrictMode>
<Global styles={rootStyles(format, darkModeAvailable)} />
<SkipTo id="maincontent" label="Skip to main content" />
<SkipTo id="navigation" label="Skip to navigation" />
<Island priority="feature" defer={{ until: 'idle' }}>
<AlreadyVisited />
</Island>
<Island priority="feature" defer={{ until: 'idle' }}>
<AdmiralScript />
</Island>
<Island priority="feature" defer={{ until: 'idle' }}>
<FocusStyles />
</Island>
<Island priority="critical">
<Metrics
commercialMetricsEnabled={
!!gamePage.config.switches.commercialMetrics
}
/>
</Island>
<Island priority="critical">
<SetABTests
serverSideABTests={gamePage.config.serverSideABTests}
/>
</Island>
<GameLayout gamePage={gamePage} NAV={NAV} />
</StrictMode>
);
};
90 changes: 90 additions & 0 deletions dotcom-rendering/src/layouts/GameLayout.test.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,90 @@
import { render, screen } from '@testing-library/react';
import { createGamePage } from '../../fixtures/manual/gamePage';
import { ConfigProvider } from '../components/ConfigContext';
import { extractNAV } from '../model/extract-nav';
import { getGameConfig } from '../model/games/gameConfigs';
import { GameLayout } from './GameLayout';

jest.mock('../lib/bridgetApi', () => jest.fn());
jest.mock('../lib/useMatchMedia', () => ({
...jest.requireActual('../lib/useMatchMedia'),
useMatchMedia: jest.fn(() => true),
}));

const renderGameLayout = (
slug: string,
overrides: Parameters<typeof createGamePage>[1] = {},
) => {
const gamePage = createGamePage(slug, overrides);
const gameConfig = getGameConfig(slug);
if (!gameConfig) throw new Error(`missing config for ${slug}`);

return render(
<ConfigProvider
value={{
renderingTarget: 'Web',
darkModeAvailable: false,
assetOrigin: '/',
editionId: 'UK',
}}
>
<GameLayout
gamePage={{ ...gamePage, gameConfig }}
NAV={extractNAV(gamePage.nav)}
/>
</ConfigProvider>,
);
};

describe('GameLayout', () => {
it('renders a working PDF version link when crosswordData.pdf is present', () => {
renderGameLayout('crossword', {
instance: {
...createGamePage('crossword').instance,
crosswordData: {
...(createGamePage('crossword').instance
.crosswordData as Record<string, unknown>),
pdf: 'https://example.com/crossword.pdf',
},
},
});

const pdfLink = screen.getByRole('link', { name: 'PDF version' });
expect(pdfLink).toHaveAttribute(
'href',
'https://example.com/crossword.pdf',
);
});

it('does not render a PDF version link when crosswordData.pdf is absent', () => {
renderGameLayout('crossword');

expect(
screen.queryByRole('link', { name: 'PDF version' }),
).not.toBeInTheDocument();
});

it('does not render CrosswordLinks for a non-crossword slug', () => {
renderGameLayout('sudoku-easy');

expect(
screen.queryByRole('link', { name: 'PDF version' }),
).not.toBeInTheDocument();
});

it('renders the crosswords group label as a styled link to /crosswords', () => {
renderGameLayout('crossword');

const label = screen.getByRole('link', { name: 'Quick crossword' });
expect(label).toHaveAttribute('href', '/crosswords');
});

it('renders a non-crosswords group label as plain, non-linked text', () => {
renderGameLayout('sudoku-easy');

expect(
screen.queryByRole('link', { name: 'Logic puzzles' }),
).not.toBeInTheDocument();
expect(screen.getByText('Logic puzzles')).toBeInTheDocument();
});
});
Loading
Loading