Skip to content

Add Puzzle Page: generic template for iframe-based puzzles (Sudoku, Word wheel, Wordiply) - #16700

Draft
andresilva-guardian wants to merge 36 commits into
mainfrom
afs/puzzles-game-page
Draft

Add Puzzle Page: generic template for iframe-based puzzles (Sudoku, Word wheel, Wordiply)#16700
andresilva-guardian wants to merge 36 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).

  • 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 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 of
    CrosswordLayout.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 a guardian-puzzle-context
    object (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 the
    page.
  • Per-puzzle SEO metadata: a curated description per game (not a generic or templated string),
    wired into the page's meta description plus Open Graph and 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, so no og:image/twitter:image tag is emitted for any of
    them 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 image simply omits the tag, matching
    existing sitewide behaviour for any other page without an image.
  • 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 (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 testing
    instructions, 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?

  • 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, 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.
  • This branch went through several rounds of review feedback since first opened: a naming pass
    ("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.

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:493c1fdb5e278375392ac6a81ec555516a365258f0280f8e5292a8d0e3be55d9

🐛 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: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

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.

* 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) => (

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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();

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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()}

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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 && (

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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',

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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',

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

We're not going to include Trivia and Quizzes either.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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';

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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',

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Comment thread dotcom-rendering/src/model/validate.ts Outdated
throw new TypeError('Unable to validate request body for puzzles page.');
};

const isGamePageInstance = (value: unknown): boolean => {

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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';

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Instead of GamePage, I originally added a puzzlesPage type here. Maybe we could use puzzlePage or something along those lines instead.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

@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 19 commits September 10, 2026 15:44
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>
@github-actions

github-actions Bot commented Sep 14, 2026

Copy link
Copy Markdown

@andresilva-guardian

Copy link
Copy Markdown
Contributor Author

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.

Thanks for the detailed context! 💪 Here's where things stand against each point:

  • 18th deadline essentials: the puzzle page itself works end-to-end for all 6 V0 puzzles, ad slots are already in the layout, print is there, comments are correctly not included, and the subnav is in place (the page lives under /puzzles-and-games as you expected). One thing worth flagging: "More from Puzzles & Games" is technically built (there's a component ready to render it), but based on the rollout spreadsheet you shared, this is clearly a 12th-deadline (v1) feature rather than an 18th one, so I've gated it to only appear once a v1 flag is switched on, keeping it hidden for the 18th launch. We'll still need the selection rules for what shows there whenever we do turn it on, let me know who to loop in on that.

  • AmuseLabs/Wordiply user info + analytics: the puzzle iframe now gets the reader's identity passed through, specifically a small "puzzle context" object (user ID, or null if signed out, plus whether dark mode is active) sent both as a URL parameter and via postMessage once the iframe loads, and kept in sync if the reader signs in/out while on the page. What I haven't done yet is validate any of this from an analytics perspective on AmuseLabs' side, or confirm the exact message shape is what they expect. Could you help me get in touch with Victoria for AmuseLabs platform access so I can check this properly? Good to know changes can take up to an hour to show up there too.

  • AmuseLabs dark mode: we're already sending a dark-mode signal (derived from the reader's real OS/browser preference, not just a static flag), so we're ready on our end whenever they can tell us how to use it. If you've got the URL parameter they mentioned, send it my way and I'll wire it in.

  • Responsive design: haven't explicitly verified this yet, will do this before touching anything postMessage/game-state related, exactly as you suggested.

  • Saving game state: correctly left alone for now, there's no API for it yet, so this is parked for the 25th once that's available.

  • 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.

@andresilva-guardian andresilva-guardian changed the title Afs/puzzles game page Add Puzzle Page: generic template for iframe-based puzzles (Sudoku, Word wheel, Wordiply) Sep 14, 2026
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>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants