Add Puzzle Page: generic template for iframe-based puzzles, print scoped to Sudoku - #16700
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:9f65c51414dcb647bd0fa2e6953b8f2f6d7dcfc8434161deefc0cd97fe39ca27"
# Refer to image using branch tag
# IMAGE_IDENTIFIER=":branch-afs-puzzles-game-page"
# Refer to image using build tag
# IMAGE_IDENTIFIER=":build-31166"
# 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/artifacts --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. |
|
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>
BUG: every AmuseLabs-hosted PuzzleConfig entry's iframe URL was built
from one shared template, substituting DCR's own slug into
AmuseLabs' set=guardian-{slug} query param. That assumption was
unsafe and already silently wrong for sudoku-killer: the real,
confirmed AmuseLabs set (from the native Android/iOS apps' own
working integration) is killer-sudoku-medium, not sudoku-killer, a
different word order plus an unexplained "-medium" suffix that is
genuinely part of the real identifier. Readers using killer sudoku
were very likely being served the wrong puzzle or an AmuseLabs error.
FIX: removes the shared URL template and {slug} substitution
mechanism entirely, not just this one instance. PuzzleIframeConfig's
urlTemplate field is replaced with url, holding each entry's own
complete, explicit, hardcoded iframe URL. resolveIframeUrl now just
returns config.iframe.url verbatim, there is nothing left to
substitute, and nothing left to be silently wrong about for any
future entry.
validatePuzzleConfigs now checks iframe.url is present and a
well-formed absolute URL. Tests hardcode and check each of the 6
entries' own exact expected URL individually (this is what would
have caught the original bug), rather than testing a substitution
mechanism in the abstract.
Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com>
Adopts a separately-confirmed mechanism from the native (Android/iOS) apps' real AmuseLabs integration: append uid=<userId> as a plain query parameter when the reader is signed in, omitted entirely (not uid=null or empty) when signed out. This is additive, not a replacement for guardian-puzzle-context, which still carries dark mode and puzzle date, for which there is no separately-confirmed mechanism yet. Applies uniformly to all 6 registry entries via the shared buildPuzzleIframeSrc function (renamed from buildPuzzleIframeSrcWithContext, since it now does more than just the context), not per-puzzle-type, since the mechanism is provider-level. uid's value is sourced identically to the existing guardian-puzzle-context.userId (idToken.claims.legacy_identity_id). The native apps call their equivalent value a "puzzleId", there is no independent confirmation the identifier format matches ours, only that this exact query parameter name/pattern is what they use. Adds test coverage for uid present/correct when signed in, uid entirely absent when signed out, and guardian-puzzle-context coexisting correctly alongside uid in both cases. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com>
Documents why the shared, slug-derived AmuseLabs URL template was removed (the killer-sudoku incident) and that each PuzzleConfig entry now carries its own complete, explicit iframe URL. Documents the new uid query parameter (source, when included/omitted) alongside the existing guardian-puzzle-context. Updates the open question about message-shape confirmation to note uid specifically is now confirmed via the native apps' integration, while guardian-puzzle-context (dark mode, puzzle date, and the JSON-blob mechanism itself) remains unconfirmed. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com>
|
…re context Documents, source-grounded from the AmuseLabs integration doc and product/design lead guidance: - idx=1 (used by all 5 AmuseLabs entries today) is a "today only" hack confirmed against native app behaviour, archive URLs need the stable id instead, not idx=1. Flags this as a hard blocker for future calendar/archive work, and notes the current gap that instance.puzzleDate does not actually change which puzzle instance the iframe shows. - Today's hardcoded PuzzleConfig URLs are a deliberate V0-only stopgap, expected to be superseded by a future "Puzzles Server"/API layer (Archive API, phased Progress API) described in the shared "Puzzles hub 3P API requirements" architecture document. Explicitly notes this is documentation only, not something to build against now. Docs-only change, no code touched. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com>
Replaces the single, opaque, fully-baked iframe.url: string with a
discriminated union keyed by provider (PuzzleProvider = 'amuselabs' |
'wordiply'), each variant holding only the minimal, provider-specific
identity data needed to build its own URL, not a pre-baked final
string:
- AmuseLabsIframeConfig { provider: 'amuselabs', set: string }
- WordiplyIframeConfig { provider: 'wordiply', baseUrl: string }
This preserves the earlier killer-sudoku fix (each entry's set is
still independently, explicitly written out, confirmed against the
native apps, no shared slug-derivation), it just moves "final URL
assembly" responsibility out of the registry to a shared, per-provider
builder strategy (added in the next commit), rather than baking a
final URL string into config.
validatePuzzleConfigs now validates set/baseUrl per provider, with an
exhaustive (never-checked) switch so adding a new PuzzleProvider
without a validation branch is a compile error. Tests hardcode and
check each of the 6 entries' own exact provider identity data
independently.
Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com>
….ts)
Replaces the previous design of applying the same query params
(uid, and DCR's own guardian-puzzle-context) to every provider
regardless of what it actually supports, with a proper per-provider
strategy, confirmed against the real "Puzzles hub 3P API requirements"
doc:
- buildAmuseLabsUrl(config, context): set/embed=1/idx=1, then
conditional uid (only when signed in) and always darkMode=0|1 (a
plain literal value, never conditionally omitted, unlike uid).
idx=1 is flagged as a "today only" hack, swapping it for
id={realProviderPuzzleId} for archive/calendar support is future V1
work, structured as a small, contained change here later.
- buildWordiplyUrl(config, context): returns baseUrl unmodified for
now, no confirmed query params exist yet for Wordiply.
- resolvePuzzleIframeUrl(config, context): dispatches on
config.iframe.provider, exhaustively type-checked (never in the
default branch) so adding a new PuzzleProvider without a matching
builder is a compile error.
guardian-puzzle-context deliberately stays outside this module: it is
DCR's own generic, additional channel, applied uniformly to every
provider in PuzzleIframe.island.tsx, not a provider-specific
mechanism.
Tests cover each builder in isolation (signed-in/out,
dark-mode-on/off) and each of the 6 real registry entries resolving
to their exact correct real-world URL via resolvePuzzleIframeUrl.
Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com>
PuzzleIframe.island.tsx now receives the full PuzzleConfig (replacing a previously pre-resolved src: string prop), since resolving the iframe URL now needs the reactive userId/darkMode context this component itself computes client-side. buildPuzzleIframeSrc's responsibility is now cleanly split in two: resolvePuzzleIframeUrl (src/lib/puzzleIframeUrl.ts) builds the provider-specific base URL (including AmuseLabs' uid/darkMode=0|1), then this function layers DCR's own guardian-puzzle-context JSON blob on top, unchanged, still applied uniformly to every provider regardless of config.iframe.provider. PuzzlePageLayout.tsx now passes puzzleConfig straight through to PuzzleIframe instead of pre-resolving a src via the old resolveIframeUrl. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com>
… split Renders now pass a real PuzzleConfig (puzzleConfig prop) instead of a pre-resolved src string. buildPuzzleIframeSrc's tests now assert the provider URL is resolved via resolvePuzzleIframeUrl before guardian-puzzle-context is layered on top, and add coverage for a non-AmuseLabs provider (wordiply) confirming uid/darkMode are not added for it while guardian-puzzle-context still is, i.e. the two mechanisms coexist correctly and neither breaks the other. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com>
Documents the new PuzzleProvider/discriminated-union PuzzleIframeConfig shape, the src/lib/puzzleIframeUrl.ts strategy module and why it replaces the previous provider-blind design, that darkMode is now sent to AmuseLabs as a literal darkMode=0|1 query param (in addition to the generic guardian-puzzle-context blob), and that uid/darkMode are AmuseLabs-specific, not sent to Wordiply. Updates "how to add a new puzzle type" to distinguish adding another AmuseLabs-hosted game (just needs a set value) from adding a genuinely new provider (needs its own config variant and builder function). Updates remaining references to the old iframe.url/ hardcoded-URL wording to match the new registry shape. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com>
Per PR #16700 review (Gustavo): AmuseLabs' iframe content has its own internal responsive behaviour independent of the iframe element's own dimensions, some puzzles have a side menu that moves below the puzzle grid at narrower screen sizes, needing more vertical space than the previous single, fixed min-height: 500px accounted for. Uses this codebase's existing until breakpoint mixin (@guardian/source/foundations, same convention as PuzzlePageLayout.tsx) to raise min-height to 900px below the tablet breakpoint. This is a deliberately generous, documented best-effort estimate, not a confirmed value: there is no confirmed pixel figure from AmuseLabs, and it has not been tested against a real embed on a real mobile device yet (platform access is being arranged separately). Flagged as such in a code comment and in docs/puzzle-page.md, to be revisited once real testing is possible. Checked for an existing, reusable auto-resize mechanism first: this codebase's iframeMessenger.enableAutoResize() convention (UnsafeEmbedBlockComponent.island.tsx/InstagramBlockComponent.island.tsx) requires Guardian's own script to run inside the iframe's content, which isn't possible for third-party-controlled AmuseLabs/Wordiply pages, so it doesn't apply here. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com>
Per PR #16700 review (Gustavo): "I think this should probably be Sudoku-specific for now, since that's the only iframe-based puzzle we're planning to support printing for... users print Sudoku to play because there isn't really another way to do that on the web... I would prioritise having the print functionality working for V0." printEnabled was true for all 6 registry entries via the shared amuseLabsPuzzle helper plus the wordiply literal. amuseLabsPuzzle now takes an explicit printEnabled parameter (sudoku x4 pass true, word-wheel passes false); wordiply's printEnabled literal is changed to false. Adds test coverage: puzzleConfigs.test.ts asserts printEnabled is true only for the 4 sudoku entries, and PuzzlePageLayout.test.tsx (which previously had no print-button test coverage at all) now covers both a sudoku case (print button present) and word-wheel/ wordiply cases (print button absent). Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com>
Adds a new "Iframe height at narrower viewports" section documenting the min-height increase below tablet, explicitly flagged as an unconfirmed, best-effort estimate pending real AmuseLabs embed testing on a real mobile device, and why the existing iframeMessenger auto-resize convention doesn't apply here. Cross- references this from the existing "Responsive/mobile layout has not been explicitly verified" open question. Documents printEnabled as Sudoku-only by explicit product decision in the "how to add a new puzzle" section and PuzzleConfig's own doc comment, not a generic per-puzzle toggle without rationale. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com>
# Conflicts: # dotcom-rendering/src/model/validate.ts
|
Seen on PROD (merged by @andresilva-guardian 9 minutes and 26 seconds ago) Please check your changes! |
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, no plans to change that here).Access control: this endpoint has no route-level gate of its own. Reader-facing access control
lives entirely on the
frontendside, via its existingPuzzlesHubExperimentA/B gate, currentlyat 0% audience across all 3 rollout tiers described below. No real reader can reach a Puzzle Page
today.
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 group, per-puzzle SEO copy, print eligibility, and iframe identity. Each entry's
iframe config is a discriminated union keyed by provider (
amuselabsorwordiply), holdingonly the minimal, provider-specific identity data (e.g. AmuseLabs' confirmed
setidentifier),not a pre-baked final URL. A dedicated per-provider URL-building strategy
(
src/lib/puzzleIframeUrl.ts) assembles the final iframe URL per provider, since each provideraccepts a genuinely different set of query parameters (confirmed against a real product
integration requirements document and the native Android/iOS apps' own working AmuseLabs
integration), rather than one function blindly applying the same parameters to every provider.
printEnabled: truefor the 4 Sudoku variants,falsefor Word wheel/Wordiply), per explicit product guidance: an existing static Sudoku printpage is being retired at V0 launch, so the new Sudoku puzzle page's print button is the only
remaining way for those readers to print and play, making it a priority to have working
correctly for V0. No other puzzle currently has a product need for print.
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. The page displays
instance.puzzleDatenext to the title in a human-readable format (e.g. "15 September 2026").
PuzzleIframe.island.tsx, a generic sandboxed iframe wrapper. For every provider, it passes aguardian-puzzle-contextobject (signed-in user id or null, whether dark mode is active, andwhich puzzle date is being shown), both as a URL parameter and via
postMessage. For AmuseLabsspecifically, it also appends
uid(signed-in user id, omitted when signed out) anddarkMode=0|1(a plain literal value), matching AmuseLabs' own real, confirmed integrationcontract. The iframe's minimum height increases at narrower viewports (below the
tabletbreakpoint) to leave room for AmuseLabs' own internal responsive reflow (a side menu that moves
below the puzzle grid on smaller screens, per direct product/design guidance). The exact height
is a best-effort, generous estimate, not a confirmed pixel value from AmuseLabs, and is flagged
in code and in
docs/puzzle-page.mdas needing revisiting once tested against a real embed on areal device.
title/descriptionper game (sourcedverbatim from the product team's SEO spreadsheet), wired into the page's
<title>, metadescription, and Open Graph/Twitter card tags. An optional
imagefield also exists onPuzzleConfigfor a future per-puzzle share/preview image, none of the 6 V0 games has oneconfigured yet, a deliberate decision (no site-wide default/fallback share image exists anywhere
in DCR or frontend today), not an oversight.
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, with no
code change or redeploy. Only one feature is gated behind a tier so far: the "More from Puzzles &
Games" related-content rail, gated behind
puzzles-new-hub-v1, a later-phase feature. Note: onlythis repo currently has these v1/v2 tiers wired up anywhere,
frontendonly checks the v0 tiertoday, since it has no v1/v2-scoped feature of its own yet, this will need mirroring on the
frontendside once the first v1-scoped frontend feature is built.docs/puzzle-page.md: what's implemented, the request contract field reference, local testinginstructions, and an "Open questions / known limitations" section covering the real AmuseLabs
archive URL/id mechanism still needing to be wired up for future calendar navigation, dark mode
and iframe-height honouring being unconfirmed against a real AmuseLabs embed, and a documented
note that today's hardcoded
PuzzleConfigregistry is a deliberate V0-only stopgap, expected tobe superseded by a future server-side "Puzzles Server" / Archive API, per direct product
guidance, not the intended long-term architecture.
Why?
To give readers a consistent, dedicated page for playing Sudoku, Word wheel, and Wordiply,
including being able to reach a specific day's puzzle by URL and print Sudoku (replacing an
existing static print page being retired at V0 launch), 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 for: the exact, correct, real-world iframe URL per puzzle (including a previously
broken killer sudoku identifier, now fixed and independently verified), correct meta/OG/Twitter
tags, correct puzzle-date display, correct per-provider query parameters, correct print-button
presence/absence per puzzle, and the responsive iframe height media query.
first opened: a naming pass ("Game Page" to "Puzzle Page"), a scope reduction, the SEO/rollout
gating work described above, fixing a real, confirmed bug where killer sudoku pointed at the
wrong AmuseLabs identifier, a per-provider URL-building refactor, and the print/responsive
refinements described above, driven by direct, ongoing product/design review. 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.