Add Puzzle Page: generic template for iframe-based puzzles (Sudoku, Word wheel, Wordiply) - #16700
Add Puzzle Page: generic template for iframe-based puzzles (Sudoku, Word wheel, Wordiply)#16700andresilva-guardian wants to merge 36 commits into
Conversation
Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com>
- 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>
…tries - 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/model/validate.ts: add validateAsGamePageType (purely additive, reuses existing private helpers isRecord/isNonEmptyString/isPuzzleItem already defined in this module; no existing validators changed) - src/server/handler.gamePage.web.ts: validate body -> isGamePageEnabled gate (404) -> GameConfig lookup by slug (404 if unknown) -> render - src/server/render.gamePage.web.tsx: renders GamePage to HTML, mirroring render.puzzlesPage.web.tsx conventions - Register POST /GamePage in server.prod.ts, and GET (prod URL passthrough) + POST in server.dev.ts, mirroring the PuzzlesPage route wiring exactly Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com>
- fixtures/manual/gamePage.ts: createGamePage(slug, overrides) factory + concrete fixtures for all 12 slugs, modeled on fixtures/manual/puzzlesPage.ts - src/model/games/gameConfigs.test.ts: registry completeness + validation - src/model/validate.gamePage.test.ts: FEGamePageType validation, mirroring validate.puzzlesPage.test.ts patterns - src/server/handler.gamePage.web.test.ts: 200/404 variants for AB gate, unknown slug, and invalid payload, mirroring handler.puzzlesPage.web.test.ts Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com>
…nd handoff contract Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com>
…ate project instead)
The /GamePage handler no longer checks isGamePageEnabled(config). Routes
will be mapped/exposed to real traffic via a separate project later, so
gating this in DCR was only adding friction to local testing with no
protective benefit at this stage.
- src/server/handler.gamePage.web.ts: remove the isGamePageEnabled check
and its 404 branch; the unknown-slug 404 check is unaffected
- src/lib/gamePageExperiment.ts (+ .test.ts): deleted, now unused
- src/server/handler.gamePage.web.test.ts: remove AB-gate 404 test cases,
keep happy-path and unknown-slug tests, add cases proving the page
renders regardless of serverSideABTests content
- fixtures/manual/gamePage.ts: default fixture serverSideABTests to {}
rather than implying a required gate value
- docs/puzzles-game-page-plan.md: add a Changelog entry, update the
progress tracker, manual validation steps, and frontend handoff contract
to reflect that DCR no longer checks serverSideABTests for /GamePage
Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com>
Found via side-by-side comparison against the real DCR-rendered crossword
page (http://localhost:9000/crosswords/quick/17578?dcr=true).
- src/layouts/GameLayout.tsx:
- Render CrosswordLinks (existing, unmodified component) next to the
title/meta header block whenever the resolved GameConfig's
componentKey is 'crossword' and instance.crosswordData is present,
so the 'PDF version' link is no longer silently missing.
- Render the puzzle type/group label as a real anchor to /crosswords
for the crosswords group, styled to match SeriesSectionLink's
'no series tag' fallback kicker link (same font presets and the
--article-section-link-text colour token) without reusing that
component directly. Other groups have no hub page yet, so their
label remains plain, non-linked text.
- src/layouts/GameLayout.test.tsx: new RTL test suite covering both fixes
(PDF link present/absent, crossword vs non-crossword slugs, label
link vs plain text) — no such test previously existed for GameLayout.
- docs/puzzles-game-page-plan.md: changelog entry documenting both fixes
and the ?dcr=true comparison caveat for future manual validation.
Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com>
Companion to the frontend repo's docs/game-page.md (same branch, afs/puzzles-game-page). Unlike docs/puzzles-game-page-plan.md (a phase-tracking planning doc, left untouched), this is an ongoing reference doc grounded in the current code: - What it is / ownership split with frontend - Current status: rendering is wired up for all 12 GameConfig registry slugs (crossword via CrosswordComponent, 11 iframe slugs via GameIframe) - the gap is on the frontend content-sourcing side, not here - Hitting it locally: make dev, fixture generation via tsx, curl examples, confirms handler.gamePage.web.ts has no AB gate - How to configure/add a new game type, including an honest callout that src/lib/gameComponents.ts is currently unused dead code and GameLayout's GameContent hardcodes the crossword case rather than looking up component-rendered games generically - a second component-rendered game type would need a GameLayout.tsx change today - Full GameConfig and FEGamePageType/instance field reference, explaining what each field actually controls in the rendered output - Known limitations: no access control, hasArchive unused, crosswordData not structurally validated, date is unformatted, moreFromPuzzlesAndGames rail is minimal, visual parity fixes done so far Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com>
🚀 Image pushed to AWS ECRImage digest: 🐛 Run the image locallyThe following can be used to run the image locally: # Refer to image using the immutable digest. Find alternatives below.
IMAGE_IDENTIFIER="@sha256:493c1fdb5e278375392ac6a81ec555516a365258f0280f8e5292a8d0e3be55d9"
# Refer to image using branch tag
# IMAGE_IDENTIFIER=":branch-afs-puzzles-game-page"
# Refer to image using build tag
# IMAGE_IDENTIFIER=":build-31099"
# Refer to image via the GitHub commit SHA tag
# IMAGE_IDENTIFIER=":sha-53c842c7d9d2343fd1332b687f7629f6911febfa"
# Set environment variables for the AWS CLI
AWS_PROFILE="<A_PROFILE_FROM_JANUS>"
AWS_DEFAULT_REGION="eu-west-1"
IMAGE_ACCOUNT_ID=$(aws ssm get-parameter --name /organisation/accounts/deployTools --query "Parameter.Value" --output text)
REGISTRY="${IMAGE_ACCOUNT_ID}.dkr.ecr.${AWS_DEFAULT_REGION}.amazonaws.com"
IMAGE="${REGISTRY}/guardian/dotcom-rendering${IMAGE_IDENTIFIER}"
# Login to AWS ECR https://docs.aws.amazon.com/AmazonECR/latest/userguide/registry_auth.html
aws ecr get-login-password | docker login --username AWS --password-stdin $REGISTRY
# Pull the image
docker pull $IMAGE
# Run the image. You'll likely need to set additional flags. See https://docs.docker.com/reference/cli/docker/container/run.
docker run $IMAGE |
Adds an 'All 12 slugs, one by one' subsection under 'Hitting it locally': a table (slug | gameGroup | renderMode | local command) giving the exact curl -X POST http://localhost:3030/GamePage --data @/tmp/game-fixtures/<slug>.json command for each of the 12 GameConfig registry entries, reusing the fixture-dump script already documented earlier in the doc. Values cross-checked against src/model/games/gameConfigs.ts. Makes explicit that this is DCR's own local POST-based endpoint, not a real end-user-facing browsable URL. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com>
…ent (e.g. .copilot) Pre-existing, unrelated bug — confirmed present identically on origin/main, since this file was untouched by this branch prior to this fix (verified with 'git diff origin/main...afs/puzzles-game-page -- webpack/webpack.config.dev-server.js', which showed zero differences). It happens to fully block local dev in certain checkout-path environments, similar in spirit to the pre-existing container.scala.html bug found on the frontend side of this same effort. Root cause: the '/' handler in webpack/webpack.config.dev-server.js called res.sendFile() with a single absolute path and no 'root' option. Express's underlying 'send' package (send@1.2.1) treats every segment of that absolute path as subject to its dotfile security check when no 'root' is given, and 404s (dotfiles: 'ignore' is the default) if any segment starts with a dot. Repos checked out under a dot-prefixed directory (e.g. ~/.copilot/repos/...) therefore 404 on every request to '/', because the '.copilot' segment trips the check — even though dev-index.html itself is not a dotfile. Fix: pass 'dev-index.html' as a plain relative filename plus an explicit 'root' option, so send's dotfile check only sees ['dev-index.html'] instead of every segment of the full absolute path. Verified: - Isolated repro script against this repo's actual installed express@5.2.1/send@1.2.1: old code -> 404 (NotFoundError, matching the reported stack trace exactly), new code -> 200 with real dev-index.html content, both run from a path containing a '.copilot' segment. - make dev / manual webpack serve + 'curl -i http://localhost:3030/' -> 200 with the real dev-index.html body. - No regression: 'curl -X POST http://localhost:3030/GamePage' with a real fixture still returns 200 after this change. - Repo-wide search confirms this is the only res.sendFile( call in the webpack/ or src/ trees, so no other latent instances of the same bug. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com>
|
As a general comment, let's try to keep the terminology consistent across the platform. I'd use "puzzles" rather than "games". One thing to consider is that when we originally started this work, the idea was also to have a /puzzles URL. I still think it makes sense for the frontend to have a convention where /puzzles maps to the Puzzles controller, but we've now changed the public URL to /puzzles-and-games. I'd still keep using "puzzles" when referring to this concept internally. |
| * 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) => ( |
There was a problem hiding this comment.
As I mentioned further down, you'll probably need to make a few changes to this implementation for the user handling. Basically, we need to pass the user ID to the iframe and reload the iframe when the user logs in.
There was a problem hiding this comment.
This is done, the iframe src now gets the reader's user ID appended as a query param (sourced from idToken.claims.legacy_identity_id), and we also send it via postMessage({ type: 'guardian-puzzle-user', userId }, '*') after load. The iframe reloads automatically when the sign-in state changes, so a login/logout mid-session is picked up. Documented in docs/puzzle-page.md under "User identity passed to the puzzle iframe". Marking this resolved. Let me know if AmuseLabs/Wordiply need a different message shape once they've had a chance to confirm on their end.
| design: ArticleDesign.Standard, | ||
| theme: Pillar.News, | ||
| }; | ||
| const { darkModeAvailable } = useConfig(); |
There was a problem hiding this comment.
I don't think we currently support dark mode, I didn't do anything for the main puzzle list, but we could double-check this just in case.
There was a problem hiding this comment.
I looked into this. DCR does have real dark mode support, and the Puzzle Page's chrome (masthead, footer, etc.) follows it automatically like every other page — no extra work needed there. The part I can't confirm is the iframe content itself, since that's entirely controlled by AmuseLabs/Wordiply and we have no visibility or control over it. I haven't tested it live in dark mode against either provider yet. I've logged this as an open question in the doc rather than guessing. Happy to test it properly once we have a way to preview both providers side by side.
| <button | ||
| type="button" | ||
| css={printButtonStyles} | ||
| onClick={() => window.print()} |
There was a problem hiding this comment.
The print button is a little bit more complicated. We're not looking to print the entire page, only the content of the iframe, and only for sudoku because that functionality currently exists in the old sudoku which is currently a static page. For now, we can print whatever the iframe provides, which isn't ideal because it includes some extra things like menus and controls, but that's the approach we're aiming for in V0.
There was a problem hiding this comment.
Agreed this isn't ideal. Right now it's a plain window.print() on the whole page, not scoped to just the iframe content, and it's not sudoku-specific. Given this was already flagged as an accepted V0 compromise ("print whatever the iframe provides"), I've left the behaviour as-is for now rather than building custom print handling, but I'll add an explicit note to the doc's limitations section so it doesn't get lost. Let me know if this needs to be prioritised before V0 ships, since a proper fix would mean coordinating with AmuseLabs on what they expose for print.
| /> | ||
| )} | ||
| {showPrint && <PrintButton />} | ||
| {showComments && instance.discussionId && ( |
There was a problem hiding this comment.
This is what I was telling you, that maybe we could have a shared layout down the road, but if we're not going to include the crossword, I wouldn’t add extra elements that we won't be releasing, like comments, for example. If you look at the design in Figma, this page actually looks pretty simple, I mean, there aren’t many elements besides the iframe and a few icons where needed.
There was a problem hiding this comment.
Agreed, and this is already how it's built, since crossword isn't part of Puzzle Page, the layout doesn't include a comments section, setter byline, or PDF link at all. It's just the iframe plus the share/print controls where enabled, matching the Figma design.
| it('has an entry for every documented slug', () => { | ||
| expect(Object.keys(gameConfigs).sort()).toEqual( | ||
| [ | ||
| 'codeword', |
There was a problem hiding this comment.
I know this is a test but just wanted to flag I originally added these to the POC because they were part of the design, but we don't have Codeword, Futoshiki, or Suguru for now.
There was a problem hiding this comment.
Removed all three from the registry (and the tests), the V0 set is now exactly the 6 puzzles we agreed on (4 Sudoku variants, Word wheel, Wordiply). There's a comment in the registry noting these three were descoped and may come back later.
| 'crosswords', | ||
| 'logic-puzzles', | ||
| 'word-games', | ||
| 'trivia-and-quizzes', |
There was a problem hiding this comment.
We're not going to include Trivia and Quizzes either.
There was a problem hiding this comment.
Same change as the Codeword/Futoshiki/Suguru removal. On the Ball and Film Reveal (Trivia and Quizzes group) are also out of the registry now.
| } | ||
|
|
||
| const amuseLabsUrlTemplate = | ||
| 'https://tg.amuselabs.com/guardian/date-picker?set=guardian-{slug}&embed=1&idx=1'; |
There was a problem hiding this comment.
This URL is supposed to point to the AmuseLabs archive. In the POC it was only there as an example, so we'd need to find the correct URL.
There was a problem hiding this comment.
Still open... I haven't found or been given a real AmuseLabs archive URL, so I didn't want to guess one. hasArchive is still just a boolean flag in the registry; there's no archive URL field anywhere yet, and nothing currently renders an archive link. I've written this up explicitly as an open question in the doc. Could someone confirm the correct URL(s) so I can wire this up properly?
| renderMode: 'iframe', | ||
| iframe: { | ||
| provider: 'moviegrid', | ||
| urlTemplate: 'https://moviegrid.io/guardian', |
There was a problem hiding this comment.
Just want to mention, this game is Moviegrid. I don't think we'll include it in V0, it's a nice-to-have rather than a must-have. The reason we're not including Moviegrid is that they didn't originally have an API that we could use for the archive. They have since added one, but we haven't tested it yet, and given the timelines, we'd rather not take the risk for V0.
There was a problem hiding this comment.
Removed, same change as items 5/6 above, film-reveal is out of the registry entirely for now given it's a nice-to-have and the archive API hasn't been tested yet.
| throw new TypeError('Unable to validate request body for puzzles page.'); | ||
| }; | ||
|
|
||
| const isGamePageInstance = (value: unknown): boolean => { |
There was a problem hiding this comment.
I also added this here originally, but I'm wondering if it might be better placed in something like puzzles.validate.ts. It might be worth checking whether we can keep it there so we're not mixing too much code with the user-related logic. Just a thought though, don't change it if you're not sure.
There was a problem hiding this comment.
Done ✅ Puzzle Page validation now lives in its own src/model/validate.puzzlePage.ts rather than the shared validate.ts. I went with that name instead of puzzles.validate.ts to match this repo's existing per-page-type file naming convention, but the goal (keeping it separate from user-related code) is met either way.
| @@ -0,0 +1,50 @@ | |||
| import type { EditionId } from '../lib/edition'; | |||
There was a problem hiding this comment.
Instead of GamePage, I originally added a puzzlesPage type here. Maybe we could use puzzlePage or something along those lines instead.
There was a problem hiding this comment.
Done ✅ renamed everything from "Game"/"GamePage" to "Puzzle"/"PuzzlePage" across the board: types, files, components, the endpoint itself (/GamePage → /PuzzlePage), fixtures, and tests. The frontend repo went through the same rename to match.
|
A few things more to keep in mind, at least from what I can remember right now:
|
…ePageType Scope change per product decision: crosswords stay on their existing, separate /crosswords/* flow (ArticleDesign.Crossword / CrosswordLayout.tsx, untouched) and will not be unified into Game Page. Game Page is now scoped to iframe-based games only. The frontend repo side has already confirmed this and removed its crossword-specific code in a parallel session. - src/model/games/gameConfigs.ts: removed the 'crossword' registry entry (now 11 iframe-only slugs). Since renderMode has no remaining variation (every entry was always iframe once crossword's 'component' entry was removed), the renderMode field and its 'component'/'iframe' union were removed entirely rather than kept as unused, always-'iframe' data - ditto for componentKey, which only had meaning for renderMode: 'component'. iframe is now a required (non-optional) field. Also removed setterEnabled and commentsEnabled: every remaining entry had these permanently false once crossword (their only true case) was removed, and their corresponding rendering (a setter byline, a comment count/section) is being removed in a follow-up commit - keeping the fields around with no true case and no consumer would be dead data. shareEnabled and printEnabled are kept (still true for all entries but plausibly could vary; still gate real rendering). validateGameConfigs/isValidGameConfig simplified accordingly - it now only checks slug/gameGroup validity and that iframe.provider/iframe.urlTemplate are non-empty. - src/types/gamePage.ts: removed puzzleType, setterName, date, specialInstructions, discussionId, crosswordData from GamePageInstance - these were only ever populated for the crossword case. frontend has already stopped sending all of them. Kept title and moreFromPuzzlesAndGames. - src/model/validate.ts: isGamePageInstance updated to match - only validates title (required) and moreFromPuzzlesAndGames (optional, same isPuzzleItem check as before). Reasoning for removing renderMode/componentKey rather than keeping them for future-proofing: this is a new, first-phase feature with no external consumers to keep compatible, and the user explicitly asked to simplify the contract now to match reality rather than keep unused flexibility 'just in case'. If a genuinely different render mode is needed again in future, re-adding a discriminated union is a small, well-understood change against a real requirement, rather than carrying speculative branching today. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com>
Follow-up to the previous commit's registry/type simplification, removing
all rendering code that's now dead as a result:
- src/layouts/GameLayout.tsx:
- Removed the component/crossword branch from GameContent entirely
(renderMode: 'component' no longer exists) - it now unconditionally
renders GameIframe. Removed the CrosswordComponent/CrosswordProps
imports and the hasCrosswordData helper.
- Removed the CrosswordLinks import and the 'PDF version' link
rendering added in bd8560e - that was crossword-only
(crossword.pdf), and there is no crosswordData any more.
- Removed the CrosswordSetter import and the setter byline rendering
(showSetter/instance.setterName) - setterName no longer exists on
GamePageInstance and setterEnabled no longer exists on GameConfig.
- Removed the comments section entirely: CommentCount.island and
DiscussionLayout imports, the showComments/instance.discussionId
gating, the CommentCount render in the meta row, and the whole
comments Section further down the page. This is genuinely dead code
removal, not a simplification of a still-needed path: commentsEnabled
was only ever true for the (now removed) crossword entry, and
discussionId no longer exists on GamePageInstance, so no remaining
registry entry could ever have reached this code.
- Removed the puzzleGroupHrefs special-casing that linked the kicker
label to /crosswords for the 'crosswords' gameGroup, and the
puzzleTypeLabelLink styling that went with it (which was closely
modelled on SeriesSectionLink's link styling) - there is no crossword
entry to special-case for any more. The label now always renders as
plain text via the existing puzzleTypeLabel span for every gameGroup.
- Removed instance.date and instance.specialInstructions rendering from
the meta row, since both fields were removed from GamePageInstance.
- Simplified the header grid (removed the now-unused 'links'/'setter'
grid areas).
- src/lib/gameComponents.ts: deleted entirely. This was already flagged as
unused dead code in docs/game-page.md before this change (GameContent
hardcoded the crossword case rather than consulting this registry), and
there is now no renderMode: 'component' case left at all for it to
serve.
Verified manually against a running dev server: all 11 remaining slugs
(sudoku-easy/medium/hard/killer, futoshiki, suguru, word-wheel, codeword,
wordiply, on-the-ball, film-reveal) return 200 and render their iframe
correctly; the removed 'crossword' slug now correctly 404s.
Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com>
Follow-up to the previous two commits' registry/type/layout simplification. - fixtures/manual/gamePage.ts: removed the sampleCrosswordData (CrosswordProps['data']) fixture and the isCrossword branching in createGamePage - every slug in the registry is now iframe-only, so createGamePage always builds a plain instance (just title + moreFromPuzzlesAndGames). Removed the now-unused CrosswordProps import. - src/model/games/gameConfigs.test.ts: removed the 'crossword' entry from the expected slug list (now 11), and replaced the crossword-specific validation tests (missing componentKey, mixed componentKey/iframe) with equivalent tests against the simplified GameConfig shape (invalid gameGroup, empty iframe.urlTemplate). getGameConfig/resolveIframeUrl tests now use sudoku-easy/wordiply instead of crossword. - src/model/validate.gamePage.test.ts: all test cases switched from the 'crossword' fixture to 'sudoku-easy' (an iframe slug); no crossword-only assertions existed to remove beyond the fixture swap itself. - src/server/handler.gamePage.web.test.ts: switched the happy-path test from 'crossword' to 'sudoku-easy', and expanded the iframe-slug it.each(...) to cover all 11 remaining slugs (previously a 4-slug spot-check, now exhaustive since there's no separate component case left to contrast against). - src/layouts/GameLayout.test.tsx: removed all crossword-specific test cases (PDF link present/absent, crosswords-group label linking to /crosswords) since that rendering no longer exists. Replaced with tests for what GameLayout still does: renders the title, renders the group label as plain non-linked text, and renders/hides the related-games rail based on moreFromPuzzlesAndGames. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com>
Rewrites docs/game-page.md to match the code changes in the previous three commits (crossword removed from Game Page's GameConfig registry, FEGamePageType, and GameLayout rendering): - 'What it is': states plainly that crosswords are out of scope by product decision and remain on the existing, separate /crosswords/* flow; notes that the frontend repo's GamePageController no longer exists either (merged into PuzzlesPageController, crossword-fetching code removed). - 'Current status': now describes 11 iframe-only slugs, framed as the by-design final shape rather than a partial/interim state. - Per-slug local access table: dropped the crossword row (now 11 rows). - 'How to configure/add a new game type': simplified to 2 steps now that there's only one render mode - both are genuinely config-only, no more caveat about GameLayout needing hand-written branches for a second component-rendered game type. - Field reference: removed componentKey/renderMode rows (fields removed from GameConfig) and the crossword-only instance fields (puzzleType, setterName, date, specialInstructions, discussionId, crosswordData), with an explicit note listing what was removed and why, for anyone who read the previous version of this doc. - 'Known limitations': removed the crossword-component-related caveats (gameComponents.ts being unused dead code, crosswordData not being validated) since that code no longer exists; added notes that there is now no setter/comments/PDF-link rendering at all (removed along with crossword, not lurking as dead code), and that crosswords being out-of-scope is by design, not a gap. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com>
Per PR #16700 review feedback ('I'd use puzzles rather than games' / comment on src/types/gamePage.ts:1), renamed consistently across the codebase: - src/types/gamePage.ts -> src/types/puzzlePage.ts (FEGamePageType -> FEPuzzlePageType, GamePageInstance -> PuzzlePageInstance) - src/model/games/gameConfigs.ts -> src/model/puzzles/puzzleConfigs.ts (directory games/ -> puzzles/; GameConfig -> PuzzleConfig, gameConfigs -> puzzleConfigs, getGameConfig -> getPuzzleConfig, validateGameConfigs -> validatePuzzleConfigs, GameIframeConfig -> PuzzleIframeConfig, gameGroups/GameGroup -> puzzleGroups/PuzzleGroup, the gameGroup field -> puzzleGroup, amuseLabsGame() -> amuseLabsPuzzle()) - src/layouts/GameLayout.tsx -> src/layouts/PuzzlePageLayout.tsx (GameLayout -> PuzzlePageLayout, ResolvedGamePage -> ResolvedPuzzlePage, GameContent -> PuzzlePageContent, RelatedGamesRail -> RelatedPuzzlesRail). Named PuzzlePageLayout rather than PuzzleLayout to avoid a one-letter-apart naming collision with the existing, unrelated PuzzlesLayout.tsx (Puzzles Hub). - src/components/GamePage.tsx -> src/components/PuzzlePage.tsx (GamePage -> PuzzlePage) - src/components/GameIframe.island.tsx -> src/components/PuzzleIframe.island.tsx (GameIframe -> PuzzleIframe) - src/server/handler.gamePage.web.ts -> src/server/handler.puzzlePage.web.ts (handleGamePage -> handlePuzzlePage) - src/server/render.gamePage.web.tsx -> src/server/render.puzzlePage.web.tsx (renderGamePage -> renderPuzzlePage) - The HTTP endpoint: POST /GamePage -> POST /PuzzlePage, and the dev-only GET /GamePage/*url -> GET /PuzzlePage/*url, in both server.prod.ts and server.dev.ts. *** BREAKING CONTRACT CHANGE: the frontend repo (handled in a parallel session) must update its POST target from /GamePage to /PuzzlePage. *** - fixtures/manual/gamePage.ts -> fixtures/manual/puzzlePage.ts (gamePageFixtures -> puzzlePageFixtures, createGamePage -> createPuzzlePage) - All related test files renamed and updated to match: GameLayout.test.tsx -> PuzzlePageLayout.test.tsx, gameConfigs.test.ts -> puzzleConfigs.test.ts (now under model/puzzles/), validate.gamePage.test.ts -> validate.puzzlePage.test.ts (still imports from validate.ts at this point - moved to its own file in a follow-up commit per a separate review comment), handler.gamePage.web.test.ts -> handler.puzzlePage.web.test.ts. - src/model/validate.ts: isGamePageInstance -> isPuzzlePageInstance, validateAsGamePageType -> validateAsPuzzlePageType (still in this file for now; moved out in the next commit). Confirmed via repo-wide grep: no remaining GamePage/gamePage/GameConfig/ gameConfig/GameLayout/GameIframe references anywhere in src/ or fixtures/ after this rename (docs/ references are handled in a later, dedicated documentation-consolidation commit). Verified: tsc --noEmit clean, eslint clean, 4 suites / 41 tests passing for the renamed Puzzle Page code, plus no regression in the unrelated Puzzles Hub / general validate suites (4 suites / 48 tests). Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com>
Per PR #16700 review comments ('we don't have Codeword, Futoshiki, or Suguru for now' / 'We're not going to include Trivia and Quizzes either' / concern about the Moviegrid iframe URL being unverified for V0), removed: codeword, futoshiki, suguru, on-the-ball, film-reveal. The registry now contains exactly 6 entries: sudoku-easy, sudoku-medium, sudoku-hard, sudoku-killer, word-wheel, wordiply. - src/model/puzzles/puzzleConfigs.ts: removed the 5 entries above and the now-fully-unused 'trivia-and-quizzes' PuzzleGroup value (only on-the-ball/film-reveal used it). Also removed 'crosswords' from PuzzleGroup - confirmed no registry entry has ever used it since crossword itself was removed from Puzzle Page entirely in an earlier commit, so it was fully dead, not just currently-unused. puzzleGroups is now ['logic-puzzles', 'word-games'] only. Added a doc comment noting the removed puzzles may return later. - src/layouts/PuzzlePageLayout.tsx: puzzleGroupLabels (a Record keyed by the full PuzzleGroup union) updated to only have entries for 'logic-puzzles'/'word-games', matching the narrowed type. - src/model/puzzles/puzzleConfigs.test.ts: updated the expected slug list (6 slugs) accordingly. - src/server/handler.puzzlePage.web.test.ts: the iframe-slug it.each(...) list narrowed to the 6 remaining slugs. Verified via repo-wide grep: no remaining references to codeword/ futoshiki/suguru/on-the-ball/film-reveal anywhere in src/ or fixtures/ (docs/ references are handled in the documentation-consolidation commit). tsc --noEmit clean, eslint clean, 4 suites / 36 tests passing. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com>
Per PR #16700 review feedback ('wondering if it might be better placed in something like puzzles.validate.ts... so we're not mixing too much code with the user-related logic'), moved isPuzzlePageInstance/ validateAsPuzzlePageType out of the general src/model/validate.ts into a new dedicated src/model/validate.puzzlePage.ts. - src/model/validate.ts: exported the small set of generic helpers Puzzle Page validation needs (isRecord, isNonEmptyString, isPuzzlesConfig, isPuzzleItem, editions) so the new file can reuse them rather than duplicating logic. Removed isPuzzlePageInstance/validateAsPuzzlePageType and the now-unused FEPuzzlePageType import. - src/model/validate.puzzlePage.ts (new): exports validateAsPuzzlePageType, built on top of the exported helpers above. - src/server/handler.puzzlePage.web.ts: import updated to the new file. - src/model/validate.puzzlePage.test.ts: import updated to the new file (test contents/behaviour unchanged). Note on the 'mirror the existing pattern' framing: I checked first, and there was actually no pre-existing validate.<pageType>.ts-style file for any other page type in this repo (including the unrelated Puzzles Hub) - every other page type's validator, including validateAsPuzzlesPageType, still lives in the shared validate.ts; only their *tests* are split into per-page-type files. This commit establishes a new, more separated convention for Puzzle Page specifically, per this review comment, rather than genuinely mirroring an existing one - flagging this so the distinction is clear for future reference. tsc --noEmit clean, eslint clean, 8 suites / 82 tests passing (Puzzle Page + Puzzles Hub + general validate, no regressions). Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com>
…iew) Per PR #16700 review feedback ('we need to pass the user ID to the iframe and reload the iframe when the user logs in'), implemented both parts requested: Investigation first (per the task's 'stop and report if no clear mechanism' instruction): - Is-signed-in / user-ID mechanism: YES, clear and already established. src/lib/useAuthStatus.ts's useAuthStatus()/useIsSignedIn() hooks, backed by src/lib/identity.ts, are already used by several existing components (TopBarMyAccount.tsx, FeastContextualNudge.island.tsx, useNewsletterSubscription.ts) to read authStatus.idToken.claims.sub or .legacy_identity_id as the user's ID. - Login/logout-event mechanism: no DCR-level event bus exists for this today (existing call sites only check auth status once, on mount). BUT the underlying @guardian/identity-auth client DCR already depends on and uses (via getIdentityAuth(), already called in src/lib/identity.ts) exposes a genuine, documented public API for exactly this - authStateManager.subscribe/unsubscribe, backed by an internal 'authStateChange' event. This is a real capability of an already-integrated library, not an invented mechanism - but it is not used anywhere else in DCR yet, so it should get careful review before being relied on more broadly. Flagging this clearly rather than silently building on an unproven assumption. Implementation: - src/lib/identity.ts: added subscribeToAuthStateChange(callback), a thin wrapper around getIdentityAuth().authStateManager.subscribe/unsubscribe, documented as above. - src/components/PuzzleIframe.island.tsx: - usePuzzleUserId(): resolves the signed-in user's idToken.claims.legacy_identity_id (their Guardian 'identity ID', the same one already used to build MyAccount links in TopBarMyAccount.tsx - not the OIDC 'sub' claim some newer API integrations use instead; which ID format AmuseLabs/Wordiply actually want has not been confirmed - see docs/puzzle-page.md), re-checking whenever subscribeToAuthStateChange fires. - buildPuzzleIframeSrc(src, userId): appends '?userId=<id>' (or '&userId=<id>' if the src already has query params, e.g. AmuseLabs' '?set=...&embed=1&idx=1') to the iframe src when signed in; returns src unchanged when signed out. - The iframe's src is derived directly from the reactive userId, so React naturally gives the <iframe> a new src value whenever sign-in state changes (mount, sign-in, sign-out) - the browser reloads the iframe on any src change, satisfying 'reload the iframe when the user logs in' without a manual reload trick. - postMessage({ type: 'guardian-puzzle-user', userId }, '*') is sent to the iframe's contentWindow on every load (initial load and any subsequent reload caused by a sign-in state change) - the message shape is documented in code via the exported PuzzleUserMessage type, since AmuseLabs/Wordiply will need to know this shape to consume it (their side is a product/frontend-team coordination concern, not something implemented here). - src/components/PuzzleIframe.island.test.tsx (new): 9 tests covering buildPuzzleIframeSrc directly, and the component's behaviour signed out, signed in, postMessage on load, reacting to a subscribed auth state change (sign-out while mounted), and unsubscribing on unmount. tsc --noEmit clean, eslint clean. 5 suites / 45 tests passing for the directly affected Puzzle Page files (including the 9 new PuzzleIframe tests), plus no regressions in adjacent identity-consuming code (identity-component-event, useNewsletterSubscription, FeastContextualNudge - 3 suites / 21 tests). Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com>
Per the user's request to simplify documentation now that this is no
longer 'ongoing work' - one file describing what is implemented and why,
plus open questions, with no phase/tracker/'will be deleted later' framing.
- Deleted docs/puzzles-game-page-plan.md (the old phase-tracking planning
doc) and docs/game-page.md (the prior ongoing-reference doc, now
superseded by the rename/scope changes in this same PR) entirely.
- Added docs/puzzle-page.md, consolidating their still-relevant factual
content, rewritten and re-verified against the current code after this
PR's rename (Game Page -> Puzzle Page), V0 scope reduction (6 puzzles),
validation file move, and the new user-ID/postMessage/reload-on-login
iframe behaviour:
- 'What is implemented': the V0 puzzle set, iframe-only scope with
crosswords explicitly out of scope, hitting /PuzzlePage locally
(dev server, fixture generation, per-slug curl commands - all
re-verified against a live dev server before writing this up),
how to add a new puzzle, the FEPuzzlePageType request contract, and
the user-identity-to-iframe mechanism (userId query param + postMessage
+ reload-on-auth-change).
- 'Open questions / known limitations': the PuzzleUserMessage shape and
userId format still needing confirmation with AmuseLabs/Wordiply, the
auth-state-change subscription being an unproven-in-this-codebase
mechanism, no saved puzzle state/progress persistence yet, the real
AmuseLabs archive URL still being unknown (hasArchive is unconsumed
dead data with no archive URL field at all), dark mode support existing
for the page chrome but being unverified/unconfirmed for the
third-party puzzle iframes themselves, responsive/mobile layout not
explicitly verified, and no AB-test kill-switch mechanism that doesn't
require a code change and redeploy.
Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com>
Per new PR review feedback from the frontend companion PR (reviewed by
the same reviewer), adding prep work for a future V1 calendar-navigation
feature ('you'll be coming from the calendar to the game page for a
particular date, so we won't always be showing today's game').
Confirmed as a no-op verification first: the crossword-only fields
(puzzleType, setterName, date, specialInstructions, discussionId,
crosswordData) were already fully removed from FEPuzzlePageType/
PuzzlePageInstance, validateAsPuzzlePageType/isPuzzlePageInstance, and all
rendering code, in an earlier commit on this branch (the crossword-scope
reduction). Re-checked via grep across src/ and fixtures/ - no leftover
references found, so no further removal was needed here.
- src/types/puzzlePage.ts: added puzzleDate?: string to
PuzzlePageInstance, alongside the existing title/
moreFromPuzzlesAndGames. Documented as a request/selection input (which
day's puzzle the reader wants to see, e.g. "2026-09-11") - explicitly
distinct from the removed crossword-only date field, which was a
formatted display string like "Mon 7 Sep 2026". Intentionally NOT wired
into any rendering logic or the iframe URL yet - accepted/validated only,
deferred to V1 pending investigation into whether/how AmuseLabs/Wordiply
iframe URLs support requesting a specific historical date.
- src/model/validate.ts: exported the existing isOptionalString helper
(was private) so validate.puzzlePage.ts can reuse it without duplicating
the same string-or-undefined check.
- src/model/validate.puzzlePage.ts: isPuzzlePageInstance now also checks
isOptionalString(value.puzzleDate) - accepts a string, accepts absent,
rejects any other type.
- fixtures/manual/puzzlePage.ts: default fixture instance now includes
puzzleDate: '2026-09-11'.
- src/model/validate.puzzlePage.test.ts: 3 new tests - accepts puzzleDate
present, accepts it absent, rejects a non-string value.
tsc --noEmit clean, eslint clean, 7 suites / 80 tests passing (Puzzle
Page + Puzzles Hub, no regressions).
Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com>
…c URL
Per new PR review feedback item 3:
- docs/puzzle-page.md:
- Added instance.puzzleDate to the FEPuzzlePageType field reference
table, documented as accepted-and-validated-only for now, with an
explicit new "Open questions" bullet noting that wiring it into the
actual iframe URL is deferred to V1 pending investigation into
whether/how AmuseLabs/Wordiply support requesting a specific
historical date.
- Added a note that frontend is separately re-introducing an AB-test
gate around the puzzle-page routes on its side, reusing its existing
PuzzlesHubExperiment/puzzles-new-hub test, and clarified that DCR's
own /PuzzlePage endpoint remains entirely ungated - access control
lives on the frontend side only. Expanded the "no access control"
open-question bullet accordingly (previously implied no gating
existed anywhere at all; now clarifies where gating actually lives
and what DCR-side gap remains if frontend's gating is ever bypassed).
- Updated the public URL shape referenced for context from the old
/puzzles/... to frontend's new /puzzles-and-games/... (e.g.
/puzzles-and-games/sudoku/easy, /puzzles-and-games/word-wheel) -
this doesn't affect DCR's own /PuzzlePage endpoint/contract, only the
example URLs used for context in this doc. The "Hitting it locally"
instructions already only relied on DCR's own endpoint directly (not
frontend's routes), so no other local-dev-instruction changes were
needed.
- fixtures/manual/puzzlePage.ts: updated the example canonicalUrl fixture
value from theguardian.com/games/<slug> to
theguardian.com/puzzles-and-games/<slug> to match.
tsc --noEmit clean, full-repo eslint clean, full test suite passing
(175 suites / 1265 tests, no regressions).
Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com>
Per PR review follow-up: frontend is moving individual puzzle-page public URLs OUT of the /puzzles-and-games prefix to top-level paths mirroring crosswords (e.g. /sudoku/easy, /word-wheel, /wordiply instead of /puzzles-and-games/sudoku/easy etc). The hub itself stays at /puzzles-and-games, unaffected. Confirmed this requires NO functional/contract change on DCR's side: grepped the whole repo for any hardcoded assumption about the public URL shape - the only hit anywhere in src/ or fixtures/ was the illustrative canonicalUrl fixture value updated here. canonicalUrl itself is validated only as a non-empty string (src/model/validate.puzzlePage.ts) and passed straight through, unparsed, to the HTML template (src/server/render.puzzlePage.web.tsx) - DCR never inspects, matches, or enforces its shape. No registry, layout, or validation code depends on it. - fixtures/manual/puzzlePage.ts: canonicalUrlForSlug() now builds theguardian.com/sudoku/<variant> for the four sudoku-* slugs (e.g. sudoku-easy -> /sudoku/easy) and theguardian.com/<slug> (top-level, no prefix) for word-wheel/wordiply, replacing the old theguardian.com/puzzles-and-games/<slug> shape. - docs/puzzle-page.md: updated the "What is implemented" intro to describe frontend's new top-level URL shape (mirroring crosswords, nested only where a puzzle has variants like sudoku's difficulty levels), and clarified this is distinct from the Puzzles Hub, which remains at /puzzles-and-games. Updated the access-control open-question bullet's wording to no longer reference the old /puzzles-and-games/... URL shape for individual puzzle pages. tsc --noEmit clean, full-repo eslint clean, full test suite passing (175 suites / 1265 tests, no regressions). Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com>
Per confirmed PR review feedback: replace the userId-only query
param/postMessage mechanism with a single, richer PuzzleContext carrying
both the user's ID and whether dark mode is currently active.
- src/components/PuzzleIframe.island.tsx:
- New PuzzleContext type: { userId: string | null; darkMode: boolean }.
userId is the same source as before (legacy_identity_id via
getAuthStatus()) but now string | null (null for signed-out) rather
than string | undefined, since it's now always a serialized object
field rather than an omitted query param.
- New PuzzleContextMessage type ({ type: 'guardian-puzzle-context',
context }), replacing the old PuzzleUserMessage
({ type: 'guardian-puzzle-user', userId }).
- New usePuzzleDarkMode(darkModeAvailable) hook: returns false
immediately (without touching matchMedia at all) when
darkModeAvailable is false; otherwise reuses the EXISTING generic
src/lib/useMatchMedia.ts hook (already used elsewhere in DCR, e.g.
ArticleMeta.web.tsx) to check - and stay reactively subscribed to -
'(prefers-color-scheme: dark)', so it updates live if the reader
switches their OS theme while the page is open. No new matchMedia
wiring was invented; this is the same reactive mechanism DCR already
has, just applied here.
- buildPuzzleIframeSrc -> buildPuzzleIframeSrcWithContext: now encodes
the whole PuzzleContext as JSON into a single
?guardian-puzzle-context=<encoded> query param, always included (the
old mechanism omitted ?userId entirely when signed out; the new
shape always carries both fields, so there's no "nothing to add"
case). Still preserves existing query params (e.g. AmuseLabs'
?set=...&embed=1&idx=1) and still returns src unchanged if it can't
be parsed as an absolute URL.
- postUserMessage -> postContextMessage: posts the new
PuzzleContextMessage shape on iframe load. Unchanged behaviour
otherwise - src is derived reactively from both usePuzzleUserId() and
usePuzzleDarkMode(), so a change in either sign-in state or OS colour
scheme causes a fresh src and a natural iframe reload, with
postMessage firing again after.
- PuzzleIframe now takes a new required darkModeAvailable: boolean prop.
- src/layouts/PuzzlePageLayout.tsx / src/components/PuzzlePage.tsx:
threaded darkModeAvailable down from PuzzlePage.tsx's existing
useConfig() (the same flag already passed to rootStyles() for the page
chrome's own dark mode support) through PuzzlePageLayout to
PuzzlePageContent to PuzzleIframe - no new source of truth introduced,
reusing the config value that already existed for this exact purpose.
- src/components/PuzzleIframe.island.test.tsx: rewritten (not just
renamed) to cover the new context shape and, specifically, dark-mode
reactivity: userId: null + darkMode: false while signed out with dark
mode unavailable; userId populated once signed in; darkMode staying
false when darkModeAvailable is false regardless of the OS preference
(confirming prefers-color-scheme is never even consulted in that case);
darkMode true only when both darkModeAvailable AND the (mocked)
useMatchMedia result are true; a live-reactivity test simulating an
OS-level colour-scheme change via a re-render with a new mocked
useMatchMedia value, matching this codebase's existing convention of
mocking useMatchMedia at the module boundary (see
ArticleMeta.web.test.tsx, PuzzlePageLayout.test.tsx) rather than mocking
window.matchMedia directly; postMessage now asserted with the new
PuzzleContextMessage shape; auth-state-change subscription and unmount
cleanup tests carried over, updated for the new context shape.
- src/layouts/PuzzlePageLayout.test.tsx: added the new required
darkModeAvailable prop (false, matching its existing
ConfigProvider/darkModeAvailable: false setup).
Verified manually against a live dev server: POSTing a fixture renders
200, and the server-rendered iframe src correctly contains
guardian-puzzle-context=%7B%22userId%22%3Anull%2C%22darkMode%22%3Afalse%7D
(decodes to {"userId":null,"darkMode":false}), the expected default before
client-side hydration resolves the real auth/media-query state.
tsc --noEmit clean, full-repo eslint clean, full test suite passing
(175 suites / 1268 tests, no regressions).
Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com>
Updates docs/puzzle-page.md to match the previous commit's replacement of
the userId-only iframe mechanism with a single, combined PuzzleContext.
- Retitled "User identity passed to the puzzle iframe" to "User/context
info passed to the puzzle iframe" and rewrote it to describe the new
PuzzleContext shape ({ userId: string | null; darkMode: boolean }), the
new guardian-puzzle-context query param (always included, JSON-encoded)
and postMessage shape, and exactly how darkMode is derived: the existing
darkModeAvailable server-side AB flag (already threaded through
PuzzlePage.tsx -> rootStyles() for the page chrome) AND the reader's
real OS/browser prefers-color-scheme preference (via DCR's existing,
generic useMatchMedia hook) - both reused, no new mechanism invented for
either half.
- Updated the "message shape needs confirming" open question to reference
PuzzleContextMessage/guardian-puzzle-context instead of the old
PuzzleUserMessage/guardian-puzzle-user, and to note the dark-mode signal
specifically also needs confirming with providers.
- Updated the dark mode open-question bullet to reflect that a dark-mode
signal is now actually sent to the iframe (previously it said no such
signal existed), while still flagging that whether AmuseLabs/Wordiply
read or honour it at all remains unconfirmed and unverified.
No functional/code changes in this commit - documentation only, following
the implementation commit.
tsc --noEmit clean, full-repo eslint clean, full test suite passing
(175 suites / 1268 tests).
Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com>
Per PR review: today render.puzzlePage.web.tsx passes description: '' to
htmlPageTemplate, which falls back to DCR's generic, site-wide description.
With no per-puzzle description, Google and social share previews fall
back to auto-generating a snippet from page content, which risks pulling
in noisy/irrelevant text (the same failure mode visible in an existing,
unrelated example: a paginated crossword archive search result showing a
garbled, listing-style description instead of a clean one - see the new
docs/puzzle-page.md SEO-risks note added in a following commit).
- src/model/puzzles/puzzleConfigs.ts: added a required description: string
field to PuzzleConfig. Wrote genuinely distinct, human-quality copy for
each of the 6 registry entries - not a template with only the slug
swapped in:
- sudoku-easy: "Play easy Sudoku online for free with the Guardian. A
gentle, relaxed number puzzle perfect for beginners or a quick
warm-up between the harder grids."
- sudoku-medium: "Play medium Sudoku online for free with the Guardian.
A step up from easy, this classic number puzzle offers just enough
challenge to keep you thinking."
- sudoku-hard: "Play hard Sudoku online for free with the Guardian. A
tough, testing number puzzle for experienced solvers who want a real
workout for their logic."
- sudoku-killer: "Play Killer Sudoku online for free with the Guardian.
This fiendish variant adds coloured cages and hidden sums to the
classic grid for a tougher challenge."
- word-wheel: "Play Word Wheel online for free with the Guardian. Find
as many words as you can from nine letters, then try to crack the
nine-letter word that uses them all."
- wordiply: "Play Wordiply online for free with the Guardian. Build the
longest word you can from a short string of letters, then see how
your vocabulary stacks up."
isValidPuzzleConfig/validatePuzzleConfigs updated to also reject an
entry with a missing or whitespace-only description, so a bad registry
entry fails fast at module load rather than silently shipping a blank
meta description.
- src/model/puzzles/puzzleConfigs.test.ts: added tests asserting every
entry has a non-empty description, that all 6 descriptions are
genuinely distinct from each other (not templated), and that
validatePuzzleConfigs rejects an empty or whitespace-only description.
tsc --noEmit clean, eslint clean, 13/13 tests passing in this suite (4 new
tests added).
Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com>
…tter
- src/server/render.puzzlePage.web.tsx: replaced the hardcoded
description: '' with puzzlePage.puzzleConfig.description - resolved
config is already merged into ResolvedPuzzlePage (set by
handler.puzzlePage.web.ts before calling this function), so this reuses
the existing pattern for accessing it rather than re-fetching via
getPuzzleConfig(slug) separately.
- Added openGraphData/twitterData, matching the key format
htmlPageTemplate's generateMetaTags()/BaseProps already expect (see how
render.article.web.tsx populates the same fields from
frontendData.openGraphData/twitterData): { 'og:title': webTitle,
'og:description': description } and { 'twitter:title': webTitle,
'twitter:description': description }. Puzzle Page has no separate OG/
Twitter copy source (frontend doesn't send any), so these are derived
directly from the title/new description already available, rather than
requiring bespoke copy - a low-risk enhancement using data already on
hand.
Verified manually against a live dev server (no render.*.web.tsx unit
test convention exists elsewhere in this repo to extend - render
functions are mocked, not directly unit-tested, in every existing handler
test, so this follows the same convention rather than introducing a new
one): POSTing sudoku-easy and wordiply fixtures to /PuzzlePage both
render 200, and the response HTML contains the correct curated
<meta name="description">, <meta property="og:title/og:description">,
and <meta name="twitter:title/twitter:description"> tags, e.g. for
sudoku-easy:
<meta name="description" content="Play easy Sudoku online for free
with the Guardian. A gentle, relaxed number puzzle perfect for
beginners or a quick warm-up between the harder grids." />
tsc --noEmit clean, eslint clean, 4 suites / 43 tests passing (Puzzle
Page, no regressions).
Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com>
…e risks
- Updated "How to configure/add a new puzzle" step 1 to mention the new
required `description` field on PuzzleConfig, with an explicit
instruction to write real, distinct, human-quality copy per entry
rather than a templated string.
- Added a new "SEO: meta description, Open Graph, Twitter card"
subsection describing exactly how render.puzzlePage.web.tsx derives
<meta name="description">, og:title/og:description, and
twitter:title/twitter:description from puzzleConfig.description and
webTitle.
- Added a new, clearly-flagged "SEO risks to revisit before shipping
calendar/archive features" subsection under "Open questions / known
limitations", covering (not implementing) two risks for future work:
- Date-specific URLs (V1 calendar navigation, building on
instance.puzzleDate) risking duplicate/thin indexable pages unless a
canonical-vs-deliberate-indexing decision is made explicitly upfront.
- Archive/pagination features (PuzzleConfig.hasArchive exists but is
unused/unbuilt) risking poor search indexing if built carelessly,
referencing a concrete existing example elsewhere on the Guardian
site (the crossword archive/search listing currently indexed with a
generic "Crossword | Page 2 of 1082" title and a garbled,
auto-scraped listing-style description) as a cautionary precedent to
avoid repeating.
- Notes this isn't an active problem today (no archive/pagination UI
exists yet) but should be raised as a design question at the start
of that future work, not discovered after launch.
No functional/code changes in this commit - documentation only.
tsc --noEmit clean, full-repo eslint clean, full test suite passing
(175 suites / 1272 tests).
Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com>
Per new PR review feedback, adding a placeholder capability for a per-puzzle preview/share image, to eventually populate og:image/ twitter:image. Confirmed by investigation (per the review request) that DCR has no site-wide default/fallback share image anywhere for pages without one - frontend's MetaData.opengraphProperties/SimplePage only add og:image via explicit per-page overrides, never a default. So an unset image is expected to simply omit og:image/twitter:image, matching existing sitewide behaviour, rather than needing a placeholder asset. - src/model/puzzles/puzzleConfigs.ts: added an optional image?: string field to PuzzleConfig, documented as intentionally unset on every current registry entry - none of the 6 V0 games have a real, licensed preview image yet, and this commit deliberately does NOT invent placeholder image URLs for any of them. isValidPuzzleConfig updated to allow image being absent, but reject it if present-and-empty or whitespace-only (same validation style already used for description). - src/model/puzzles/puzzleConfigs.test.ts: added tests confirming every current entry has no image set, that validatePuzzleConfigs does not throw when a valid non-empty image is present, and that it rejects an empty-string or whitespace-only image. tsc --noEmit clean, eslint clean, 17/17 tests passing in this suite (4 new tests added). Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com>
…overage - src/server/render.puzzlePage.web.tsx: extracted the description/ openGraphData/twitterData construction into a new, pure, exported buildPuzzlePageMetaData(webTitle, puzzleConfig) function (previously inline in renderPuzzlePage). This is specifically so it's directly unit testable without needing to invoke the full render pipeline, which requires a webpack build manifest not present in the test environment - there is no render.*.web.tsx unit test convention anywhere else in this repo to extend, so extracting the pure logic avoids inventing new render-pipeline test infrastructure just for this. openGraphData/ twitterData now conditionally spread in 'og:image'/'twitter:image' only when puzzleConfig.image is set - when it's unset, the keys are omitted entirely (not sent as an empty string or placeholder), matching how htmlPageTemplate's generateMetaTags() only emits a <meta> tag for keys actually present in the object (verified by reading its Object.entries()-based implementation before assuming this). - fixtures/manual/puzzlePage.ts: added samplePuzzleImageUrl (a clearly fixture-only placeholder image URL) and createPuzzleConfigWithImage(slug), a fixture-only helper returning a copy of a real registry PuzzleConfig with image set - used to exercise the with-image branch in tests without touching the real registry itself (which still has no image configured on any of the 6 V0 entries, per the previous commit). - src/server/render.puzzlePage.web.test.ts (new): 4 tests for buildPuzzlePageMetaData - description sourced from puzzleConfig, og:title/twitter:title from webTitle, og:image/twitter:image omitted entirely when image is unset, and included with the correct value when set (using the new fixture helper). tsc --noEmit clean, eslint clean, 5 suites / 51 tests passing across all Puzzle Page test files (no regressions). Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com>
Updates docs/puzzle-page.md to match the previous two commits (optional image field on PuzzleConfig, wired into og:image/twitter:image). - "How to configure/add a new puzzle" step 1: mentions the new optional `image` field, that `amuseLabsPuzzle()` doesn't take it (set it afterwards on the returned object if needed), and that validatePuzzleConfigs rejects a present-but-empty image. - "SEO: meta description, Open Graph, Twitter card": documents buildPuzzlePageMetaData (the new pure function render.puzzlePage.web.tsx uses) including image, and explicitly states - as a deliberate, confirmed decision rather than an oversight - that DCR has no site-wide default/fallback share image anywhere (confirmed against frontend's MetaData.opengraphProperties/SimplePage, which likewise only add og:image via explicit per-page overrides, never a default), so an unset `image` simply omits og:image/twitter:image entirely, matching existing sitewide behaviour. Also notes none of the 6 current V0 puzzles have a real image configured yet - a placeholder capability for whenever real, licensed images are provided, not filled in as part of this change. No functional/code changes in this commit - documentation only. tsc --noEmit clean, full-repo eslint clean, full test suite passing (176 suites / 1280 tests). Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com>
Per the product rollout plan the user shared (v0 = w/c 5 Oct launch,
v1 = w/c 12 Oct launch, v2 = no date confirmed yet), sets up a 3-tier,
cumulative AB-test/kill-switch structure so each rollout phase can be
turned on/off (or rolled back) without a code change/redeploy, directly
addressing the PR review feedback about needing exactly this.
- ab-testing/config/abTests.ts: added `puzzles-new-hub-v1` and
`puzzles-new-hub-v2`, mirroring the existing `puzzles-new-hub` entry's
shape/fields/conventions exactly (same owners, ON status,
audienceSize: 0/100, audienceSpace "A", control/variant groups). Both
start invisible to the public, same as `puzzles-new-hub` today.
- Added a JSDoc-style comment block directly above all three entries
(including the pre-existing `puzzles-new-hub`, which previously had no
such comment) documenting:
- `puzzles-new-hub` (v0): the master switch. Gates the baseline
experience - the new Puzzles Hub page and the 6 V0 puzzle pages
(sudoku x4, word-wheel, wordiply) with no archive/calendar/progress
indicators/sign-in prompt/related rail, and a hub sub-nav with no
links yet. Turning it off hides the entire Puzzles & Games
experience, including every later tier (by the cumulative design).
- `puzzles-new-hub-v1`: the w/c 12 Oct layer, ON TOP OF v0 - does
nothing unless v0 is also enabled. Activates: full hub sub-nav
links, sign-in-to-track-progress message, calendar/archive view for
crosswords/logic-puzzles/word-games (not Wordiply), progress
indicators, the "More from Puzzles & Games" rail, newsletter signup,
and existing-crossword-page changes (print CTA repositioning, "play
other puzzles" container). Documents the rollback mechanism: flip
just this test's audienceSize/status while leaving v0 untouched.
- `puzzles-new-hub-v2`: future layer, ON TOP OF v0+v1 - no launch date
yet. Activates: On the Ball/Film Reveal (Trivia and Quizzes),
"Most played" container, EventKit-driven navigation, migrating
existing crossword pages onto the new Puzzle Page template, and
search-engine mobile app nudges. Kept at 0% until that work begins.
Verified: ab-testing's own `config/scripts/validation/index.ts` passes
("AB test validations passed" - the only warning shown is a pre-existing,
unrelated expired test), eslint clean on the changed file, and the
config package's full node --test suite passes (131/131, no regressions).
A handful of pre-existing tsc --noEmit errors in unrelated
scripts/validation/*.ts files (unrelated 'webex-*' test-name typos)
predate this change - confirmed identical via git stash before/after.
Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com>
New src/lib/puzzlesHubVersionExperiment.ts, mirroring the exact style of the existing src/lib/puzzlesHubExperiment.ts (config.serverSideABTests lookup, testName === 'variant' checks), but composed cumulatively across the puzzles-new-hub / puzzles-new-hub-v1 / puzzles-new-hub-v2 tiers added to ab-testing/config/abTests.ts in the previous commit: - isPuzzlesHubV1Enabled(config): true only when BOTH puzzles-new-hub (v0) AND puzzles-new-hub-v1 are in their 'variant' group. - isPuzzlesHubV2Enabled(config): true only when puzzles-new-hub, puzzles-new-hub-v1, AND puzzles-new-hub-v2 are ALL in their 'variant' group (built on top of isPuzzlesHubV1Enabled, so v2 automatically requires v1's own cumulative check to pass too). - puzzlesHubV1Participation/puzzlesHubV2Participation helpers, mirroring puzzlesHubParticipation, for building test fixtures/payloads. Kept as a new, separate file rather than extending puzzlesHubExperiment.ts, so the v0-only gate (still used for the existing, unrelated Puzzles Hub feature) stays untouched and minimal, while the newer cumulative version-tier concept - specific to the Puzzles & Games rollout - has its own clearly-named home. src/lib/puzzlesHubVersionExperiment.test.ts: 12 tests covering all meaningful on/off combinations for both functions, including the cases that matter most for the cumulative design - v1 in variant with v0 off (must be false), and v2 in variant with either v0 or v1 off (must be false in both cases). tsc --noEmit clean, eslint clean, 12/12 new tests passing. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com>
Per the Puzzles & Games rollout plan, the "More from Puzzles & Games" related-content rail is a v1-scoped feature, not v0 - but it's already implemented and today renders unconditionally whenever instance.moreFromPuzzlesAndGames happens to be non-empty (currently always empty in practice, since frontend never populates it yet - but that's an accident of current data, not an explicit, reliable gate). - src/layouts/PuzzlePageLayout.tsx: showRelated now also requires isPuzzlesHubV1Enabled(config) (from the previous commit's puzzlesHubVersionExperiment.ts), in addition to the existing moreFromPuzzlesAndGames-non-empty check. This means the rail can be reliably prevented from ever appearing before v1 launches, even if moreFromPuzzlesAndGames were accidentally populated early by a bug or exploratory testing on frontend's side - it now takes both data AND an explicit v1 gate to show it. - src/layouts/PuzzlePageLayout.test.tsx: rewrote the rail's test coverage (grouped under a new describe block) to cover the meaningful combinations: renders with data present AND v0+v1 both enabled; does not render with empty data even when v0+v1 are enabled; does not render with data present but neither test enabled (the current default fixture state); does not render with data present and only v1 enabled (v0 off); does not render with data present and only v0 enabled (v1 off). The two "only one tier on" cases are the ones that most directly prove the cumulative gate is wired correctly, not just "some AB test is on". No other v1/v2-scoped feature exists in this codebase yet (calendar, progress indicators, sign-in message, on-the-ball/film-reveal), so no other gating was added - those should check isPuzzlesHubV1Enabled/ isPuzzlesHubV2Enabled respectively once that work begins. tsc --noEmit clean, eslint clean, 7 suites / 72 tests passing across Puzzle Page + puzzlesHub-related test files (no regressions). Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com>
Replaces the previous "DCR's /PuzzlePage endpoint itself has no access control or kill-switch..." limitation bullet (added in an earlier task, before this structure existed) with an accurate description of what's now in place, and adds a new "Feature-tier rollout gating (v0/v1/v2)" subsection to "Open questions / known limitations" documenting: - What each of the three cumulative AB test tiers (puzzles-new-hub / puzzles-new-hub-v1 / puzzles-new-hub-v2) actually gates, mirroring the JSDoc content added to ab-testing/config/abTests.ts. - The cumulative dependency rule (v1 requires v0, v2 requires v0+v1) and how to roll back a single phase without a deploy. - That all three tiers currently sit at 0% audience (hidden from the public), same as before this structure existed. - That today only the "More from Puzzles & Games" rail is actually gated at the DCR render level (behind isPuzzlesHubV1Enabled) - every other v0-scoped feature currently in this codebase renders unconditionally; v0's real "gating" today is frontend's route-level PuzzlesHubExperiment check deciding whether a request reaches /PuzzlePage at all, not a DCR-side render-time check. - An explicit pointer that future v1/v2 work (calendar, progress indicators, sign-in message, on-the-ball/film-reveal) should be gated behind isPuzzlesHubV1Enabled/isPuzzlesHubV2Enabled respectively, using the helpers added in this task's earlier commits - so this is discoverable later without rediscovering the whole design. - That no frontend repo changes are needed for any of this - frontend doesn't render Puzzle Page UI itself, so feature-tier gating naturally lives entirely on the DCR side, and frontend's existing route-level PuzzlesHubExperiment gate (already reusing puzzles-new-hub) is unaffected by v1/v2 and doesn't need to check them. No functional/code changes in this commit - documentation only. tsc --noEmit clean, full-repo eslint clean, full test suite passing (177 suites / 1295 tests). Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com>
Thanks for the detailed context! 💪 Here's where things stand against each point:
|
Pure copy-editing pass, no functional/behavioural change. Replaces every em dash (—) in docs/puzzle-page.md and the Puzzle Page source comments touched by this branch with a comma, a new sentence, or a plain hyphen, whichever reads most naturally, per a new permanent house-style rule against using em dashes anywhere. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com>
What does this change?
Adds "Puzzle Page" to dotcom-rendering, a generic page template for the Guardian's iframe-based
puzzles, currently covering 6 V0 games: Sudoku (easy, medium, hard, killer), Word wheel, and
Wordiply. Crosswords are explicitly out of scope and remain entirely on their own, separate
ArticleDesign.Crossword/CrosswordLayout.tsxflow (untouched).POST /PuzzlePageendpoint (src/server/handler.puzzlePage.web.ts/render.puzzlePage.web.tsx), acceptingFEPuzzlePageType(seesrc/types/puzzlePage.ts).PuzzleConfigregistry (src/model/puzzles/puzzleConfigs.ts), the single source of truth foreach puzzle's iframe URL, group, and per-puzzle SEO description. All 6 entries share the same
AmuseLabs URL template (parameterised by slug), aside from Wordiply, which uses its own provider.
PuzzlePageLayout.tsx/PuzzlePage.tsx, a fresh, self-contained layout, not a fork ofCrosswordLayout.tsx, reusing existing generic building blocks (Masthead, Section, Footer,AdSlot, ShareButton.island) rather than duplicating them.
PuzzleIframe.island.tsx, a generic sandboxed iframe wrapper. It passes aguardian-puzzle-contextobject (signed-in user id, or null, plus whether dark mode is active) to the puzzle provider, both
as a URL parameter and via
postMessage, kept in sync if the reader signs in or out while on thepage.
descriptionper game (not a generic or templated string),wired into the page's meta description plus Open Graph and Twitter card tags. An optional
imagefield also exists onPuzzleConfigfor a future per-puzzle share/preview image; none ofthe 6 V0 games has one configured yet, so no
og:image/twitter:imagetag is emitted for any ofthem today. This is a deliberate decision, not an oversight: no site-wide default/fallback share
image exists anywhere in DCR or frontend today, so an unset
imagesimply omits the tag, matchingexisting sitewide behaviour for any other page without an image.
ab-testing/config/abTests.ts(
puzzles-new-hub,puzzles-new-hub-v1,puzzles-new-hub-v2), all currently at 0% audience.Each tier only takes effect if the tier(s) below it are also enabled, so the team can roll
forward or back between feature phases, or switch everything off, purely via config (deployed
independently via Fastly), with no code change or redeploy. Only one feature is gated behind a
tier so far: the "More from Puzzles & Games" related-content rail is gated behind
puzzles-new-hub-v1, since it's a later-phase feature per the product rollout plan.docs/puzzle-page.md: what's implemented, the request contract field reference, local testinginstructions, and an "Open questions / known limitations" section covering things like the real
AmuseLabs archive URL still being unknown, dark mode honouring being unconfirmed on the provider
side, and SEO risks to revisit before shipping calendar/archive features (with a concrete,
cautionary example already seen elsewhere on the site).
Why?
To give readers a consistent, dedicated page for playing Sudoku, Word wheel, and Wordiply, replacing
ad hoc/one-off pages per game with a single, shared, maintainable template, while keeping the
existing crossword experience completely untouched and this new work hidden from the public until
the team is ready to roll it out.
How has this change been tested?
tsc --noEmitclean, full-repoeslintclean).make dev), POSTing real fixtures(
fixtures/manual/puzzlePage.ts) for all 6 V0 slugs to/PuzzlePageand inspecting the renderedHTML directly, not just unit test assertions, for: correct iframe src per game, correct meta
description / Open Graph / Twitter tags, and the "More from Puzzles & Games" rail correctly
appearing or not appearing depending on the rollout tier flags.
("Game Page" to "Puzzle Page"), a scope reduction (removing Codeword, Futoshiki, Suguru,
On the Ball, and Film Reveal, none of which are part of V0), and the SEO/rollout-gating work
described above. See individual commits for the full history.
Screenshots
Not applicable. No visual/user-facing change to any existing page or design system component.
Puzzle Page is a new, currently unreleased (0% audience) page type.