Skip to content

Add Puzzle Page: generic template for iframe-based puzzles, print scoped to Sudoku - #16700

Merged
andresilva-guardian merged 56 commits into
mainfrom
afs/puzzles-game-page
Sep 16, 2026
Merged

andresilva-guardian merged 56 commits into
mainfrom
afs/puzzles-game-page

Conversation

@andresilva-guardian

@andresilva-guardian andresilva-guardian commented Sep 8, 2026

Copy link
Copy Markdown
Contributor

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.tsx flow (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 frontend side, via its existing PuzzlesHubExperiment A/B gate, currently
at 0% audience across all 3 rollout tiers described below. No real reader can reach a Puzzle Page
today.

  • POST /PuzzlePage endpoint (src/server/handler.puzzlePage.web.ts /
    render.puzzlePage.web.tsx), accepting FEPuzzlePageType (see src/types/puzzlePage.ts).
  • PuzzleConfig registry (src/model/puzzles/puzzleConfigs.ts), the single source of truth for
    each puzzle's group, per-puzzle SEO copy, print eligibility, and iframe identity. Each entry's
    iframe config is a discriminated union keyed by provider (amuselabs or wordiply), holding
    only the minimal, provider-specific identity data (e.g. AmuseLabs' confirmed set identifier),
    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 provider
    accepts 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.
  • Print is scoped to Sudoku only (printEnabled: true for the 4 Sudoku variants,
    false for Word wheel/Wordiply), per explicit product guidance: an existing static Sudoku print
    page 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 of
    CrosswordLayout.tsx, reusing existing generic building blocks (Masthead, Section, Footer,
    AdSlot, ShareButton.island) rather than duplicating them. The page displays instance.puzzleDate
    next 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 a
    guardian-puzzle-context object (signed-in user id or null, whether dark mode is active, and
    which puzzle date is being shown), both as a URL parameter and via postMessage. For AmuseLabs
    specifically, it also appends uid (signed-in user id, omitted when signed out) and
    darkMode=0|1 (a plain literal value), matching AmuseLabs' own real, confirmed integration
    contract. The iframe's minimum height increases at narrower viewports (below the tablet
    breakpoint) 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.md as needing revisiting once tested against a real embed on a
    real device.
  • Per-puzzle SEO metadata: a curated, date-templated title/description per game (sourced
    verbatim from the product team's SEO spreadsheet), wired into the page's <title>, meta
    description, and Open Graph/Twitter card tags. An optional image field also exists on
    PuzzleConfig for a future per-puzzle share/preview image, none of the 6 V0 games has one
    configured yet, a deliberate decision (no site-wide default/fallback share image exists anywhere
    in DCR or frontend today), not an oversight.
  • A 3-tier, cumulative rollout gating structure in 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: only
    this repo currently has these v1/v2 tiers wired up anywhere, frontend only checks the v0 tier
    today, since it has no v1/v2-scoped feature of its own yet, this will need mirroring on the
    frontend side once the first v1-scoped frontend feature is built.
  • docs/puzzle-page.md: what's implemented, the request contract field reference, local testing
    instructions, 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 PuzzleConfig registry is a deliberate V0-only stopgap, expected to
    be 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?

  • Full test suite passing (tsc --noEmit clean, full-repo eslint clean).
  • Manual verification against a running local dev server (make dev), POSTing real fixtures
    (fixtures/manual/puzzlePage.ts) for all 6 V0 slugs to /PuzzlePage and inspecting the rendered
    HTML 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.
  • This branch went through several rounds of review feedback and product direction changes since
    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.

andresilva-guardian and others added 9 commits September 7, 2026 15:45
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>
@github-actions

github-actions Bot commented Sep 8, 2026

Copy link
Copy Markdown

🚀 Image pushed to AWS ECR

Image digest: sha256:9f65c51414dcb647bd0fa2e6953b8f2f6d7dcfc8434161deefc0cd97fe39ca27

🐛 Run the image locally

The 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

From guardian/actions-publish-image.

@github-actions

github-actions Bot commented Sep 8, 2026

Copy link
Copy Markdown

andresilva-guardian and others added 2 commits September 8, 2026 15:39
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>
@gustavo-olmedo

Copy link
Copy Markdown
Contributor

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.

Comment thread dotcom-rendering/src/components/GameIframe.island.tsx Outdated
Comment thread dotcom-rendering/src/components/PuzzlePage.tsx
Comment thread dotcom-rendering/src/layouts/PuzzlePageLayout.tsx
Comment thread dotcom-rendering/src/layouts/GameLayout.tsx Outdated
Comment thread dotcom-rendering/src/model/puzzles/puzzleConfigs.test.ts Outdated
Comment thread dotcom-rendering/src/model/puzzles/puzzleConfigs.ts Outdated
Comment thread dotcom-rendering/src/model/puzzles/puzzleConfigs.ts Outdated
Comment thread dotcom-rendering/src/model/puzzles/puzzleConfigs.ts Outdated
Comment thread dotcom-rendering/src/model/validate.ts Outdated
Comment thread dotcom-rendering/src/types/puzzlePage.ts
@gustavo-olmedo

Copy link
Copy Markdown
Contributor

A few things more to keep in mind, at least from what I can remember right now:

  • For the 18th deadline: the main thing we need is the puzzle page where users can actually play, plus the print functionality for Sudoku. There might also be some ad slots, but I'm not sure about that yet. If there's time, we could add the "More from Puzzles & Games" section at the bottom. We'd need to ask what should go there, though , specifically, what rules we need to use to decide which puzzles to show, because we don't know that yet. Comments are not needed for this deadline. The subnav is also something I'd double-check, although I think it should be fine given that the page lives under /puzzles-and-games. This is mainly about the new page. For Crossword, I'd find out what changes are actually required by asking Murray or Karolina, or we can work through that together later. The priority for now is getting the iframe page working, and once you finish that part, let me know and we can look at Crossword.

  • The iframe page: it needs to be generic enough to support different games, but for this version we're mainly looking for it to work with Wordiply, which we own, and Word Wheel and Sudoku from AmuseLabs. AmuseLabs has its own platform, and there's a representation of the Guardian user on their platform. So when you render an AmuseLabs iframe, you'll need to pass the user information through. We should also validate that this is working correctly from an analytics perspective. One thing to be aware of is that their platform can sometimes take around an hour to update, so don't be surprised if changes aren't reflected immediately. You could ask Victoria about access to the AmuseLabs platform, although I think access might be limited.

  • AmuseLabs dark mode: they also said that if the user switches to dark mode, the device can detect it and potentially change the state inside the iframe. It would be worth checking whether this actually works as expected. I think it might be possible to handle it by adding an extra parameter to the URL, but we'd need to test it.

  • Responsive behaviour: before starting on the postMessage work for saving the user's game state, I'd first try playing the games and checking whether there are any issues with the responsive design. Hopefully there aren't any, but we should verify this first, and again.

  • Saving game state / postMessages: although there have been discussions about communicating with the iframe and saving the user's game state, I'd leave that until the end. In fact, without the API there's not much we can do with it yet because the API doesn't exist. This is what we need for the 25th deadline, rather than the 18th. So I'd focus on getting the iframe page itself working first, and leave the state management integration until later.

  • A/B testing: one thing I'd think about from the beginning is how we're going to handle the A/B test experiments. We have one experiment that will be used for the 18th, and then we'll be launching more things on the 25th. We should make sure we're able to move backwards if needed, for example, if we're asked to go back from the 25th version to the 18th version, or to switch everything off, we should be able to do that without having to make code changes or redeploy everything.

andresilva-guardian and others added 5 commits September 10, 2026 10:47
…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>
andresilva-guardian and others added 3 commits September 15, 2026 11:24
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>
@gustavo-olmedo

Copy link
Copy Markdown
Contributor
  • A/B testing rollback: this is sorted. We've set up three cumulative, independent flags in dotcom-rendering's A/B config, one for the 18th baseline, one for whatever ships on the 12th, and one reserved for future work after that, all currently at 0% audience so nothing is public yet. Each flag only takes effect if the one(s) below it are also on, so we can cleanly roll back from the 12th version to the 18th, or switch the whole thing off, purely by changing config (deployed independently via Fastly). No code changes or redeploys needed on either the frontend or DCR side.
  • 18th deadline essentials: yes, the only issue with "More from Puzzles & Games" is that we still need to define the selection rules as you said, I don't think that's particularly difficult. In fact I always try to flag that, but that still takes time, and that time is better spent on the things we've already prioritised for the deadline. We can add this later without any problem, but I'd prefer to make sure we've finished everything we need for the 18th first.

  • AmuseLabs/Wordiply user info + analytics: I've already contacted Victoria to get you access to the AmuseLabs platform, so you should be able to test this there.

  • AmuseLabs dark mode: Yes, I saw that this is already being used on mobile, but I haven't tested it myself yet.

  • Responsive design: The responsive behaviour is important for v0, one thing I remember being important here is that the iframe has its own responsive behaviour. For example, we might define a width and height for the iframe, but the required height can change at smaller screen sizes on AmuseLabs. Some puzzles have a menu on the right that moves below the puzzle on smaller screens, so we need to make sure the iframe has enough height to accommodate that. I'd check this before considering the responsive work done.

  • Saving game state: Sounds good.

  • A/B testing rollback: Sounds good. Just remember that this also needs to be added/used on the Frontend side.

andresilva-guardian and others added 6 commits September 15, 2026 15:10
…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>
@andresilva-guardian andresilva-guardian changed the title Add Puzzle Page: generic template for iframe-based puzzles, with per-day URLs Add Puzzle Page: generic template for iframe-based puzzles, with per-provider URL strategy Sep 15, 2026
andresilva-guardian and others added 3 commits September 15, 2026 15:58
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>
@andresilva-guardian andresilva-guardian changed the title Add Puzzle Page: generic template for iframe-based puzzles, with per-provider URL strategy Add Puzzle Page: generic template for iframe-based puzzles, print scoped to Sudoku Sep 15, 2026
@andresilva-guardian
andresilva-guardian marked this pull request as ready for review September 15, 2026 15:06
@andresilva-guardian
andresilva-guardian requested a review from a team as a code owner September 15, 2026 15:06
# Conflicts:
#	dotcom-rendering/src/model/validate.ts
@andresilva-guardian andresilva-guardian added feature Departmental tracking: work on a new feature run_chromatic Runs chromatic when label is applied labels Sep 15, 2026
@github-actions github-actions Bot removed the run_chromatic Runs chromatic when label is applied label Sep 16, 2026
@andresilva-guardian
andresilva-guardian added this pull request to the merge queue Sep 16, 2026
Merged via the queue into main with commit 3c177d3 Sep 16, 2026
40 of 41 checks passed
@andresilva-guardian
andresilva-guardian deleted the afs/puzzles-game-page branch September 16, 2026 09:18
@gu-prout

gu-prout Bot commented Sep 16, 2026

Copy link
Copy Markdown

Seen on PROD (merged by @andresilva-guardian 9 minutes and 26 seconds ago) Please check your changes!

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

feature Departmental tracking: work on a new feature Seen-on-PROD

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants