diff --git a/ab-testing/config/abTests.ts b/ab-testing/config/abTests.ts
index 1c3a6f2ef42..765823c2a3f 100644
--- a/ab-testing/config/abTests.ts
+++ b/ab-testing/config/abTests.ts
@@ -157,6 +157,26 @@ const ABTests: ABTest[] = [
groups: ["control", "variant"],
shouldForceMetricsCollection: false,
},
+ /**
+ * Puzzles & Games rollout, tier v0 (the master switch).
+ *
+ * Gates the baseline Puzzles & Games experience: the new Puzzles Hub
+ * page, and the 6 V0 puzzle pages (sudoku easy/medium/hard/killer,
+ * word-wheel, wordiply). At v0, there is no archive, no calendar, no
+ * progress indicators, no sign-in-to-track-progress prompt, no "more
+ * from puzzles" rail, and the hub's sub-nav has no links yet.
+ *
+ * This is the master switch for the whole Puzzles & Games experience:
+ * turning it off (or down to 0%) hides everything: the hub, the V0
+ * puzzle pages, and (by the cumulative design below) every later tier
+ * too, since v1/v2 only take effect when this is also enabled.
+ *
+ * See `puzzles-new-hub-v1`/`puzzles-new-hub-v2` below for the later
+ * rollout tiers, and `src/lib/puzzlesHubVersionExperiment.ts` /
+ * `src/lib/puzzlesHubExperiment.ts` in dotcom-rendering for the
+ * corresponding cumulative gate-check helpers
+ * (`isPuzzlesHubEnabled`/`isPuzzlesHubV1Enabled`/`isPuzzlesHubV2Enabled`).
+ */
{
name: "puzzles-new-hub",
description: "Rollout of the new Puzzles Hub experience",
@@ -169,6 +189,68 @@ const ABTests: ABTest[] = [
groups: ["control", "variant"],
shouldForceMetricsCollection: false,
},
+ /**
+ * Puzzles & Games rollout, tier v1 (w/c 12 Oct launch).
+ *
+ * Only takes effect when `puzzles-new-hub` (v0) is ALSO enabled for the
+ * reader. This test does nothing on its own, by design, so the
+ * rollout can never end up in an inconsistent state (e.g. v1 features
+ * showing while the v0 baseline they build on is switched off).
+ *
+ * On top of v0, this tier activates: the full hub sub-nav links (to
+ * /word-games, /logic-puzzles, /trivia-and-quizzes), a
+ * sign-in-to-track-progress message, a calendar/archive view for
+ * crosswords/logic-puzzles/word-games (not Wordiply, which has no
+ * archive), progress indicators (Available/Completed), the "More from
+ * Puzzles & Games" related-content rail, newsletter signup, and
+ * changes to the existing crossword page (print CTA repositioning, a
+ * "play other puzzles" container).
+ *
+ * To roll back from v1 to v0 without a deploy: flip this test's
+ * `audienceSize` to `0 / 100` (or `status` to `"OFF"`) while leaving
+ * `puzzles-new-hub` untouched.
+ */
+ {
+ name: "puzzles-new-hub-v1",
+ description:
+ "Rollout of the v1 Puzzles & Games features (w/c 12 Oct), on top of the puzzles-new-hub v0 baseline",
+ owners: ["puzzles.team@guardian.co.uk"],
+ status: "ON",
+ expirationDate: "2026-12-31",
+ type: "server",
+ audienceSize: 0 / 100,
+ audienceSpace: "A",
+ groups: ["control", "variant"],
+ shouldForceMetricsCollection: false,
+ },
+ /**
+ * Puzzles & Games rollout, tier v2 (future, no launch date confirmed
+ * yet as of this writing).
+ *
+ * Only takes effect when BOTH `puzzles-new-hub` (v0) AND
+ * `puzzles-new-hub-v1` are ALSO enabled for the reader, same
+ * cumulative-by-design principle as v1 above, applied one tier further.
+ *
+ * On top of v0+v1, this tier activates: the On the Ball and Film Reveal
+ * iframe games (Trivia and Quizzes group), a "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; there is nothing to roll back yet.
+ */
+ {
+ name: "puzzles-new-hub-v2",
+ description:
+ "Rollout of the v2 Puzzles & Games features (no date confirmed yet), on top of the puzzles-new-hub/puzzles-new-hub-v1 baseline",
+ owners: ["puzzles.team@guardian.co.uk"],
+ status: "ON",
+ expirationDate: "2026-12-31",
+ type: "server",
+ audienceSize: 0 / 100,
+ audienceSpace: "A",
+ groups: ["control", "variant"],
+ shouldForceMetricsCollection: false,
+ },
{
name: "commercial-prebid-transaction-ids",
description:
diff --git a/dotcom-rendering/docs/puzzle-page.md b/dotcom-rendering/docs/puzzle-page.md
new file mode 100644
index 00000000000..ae3dc19ae08
--- /dev/null
+++ b/dotcom-rendering/docs/puzzle-page.md
@@ -0,0 +1,508 @@
+# Puzzle Page
+
+## What is implemented
+
+Puzzle Page is a single, generic page template for the Guardian's
+**iframe-based** puzzles (sudoku, word games, etc.). **dotcom-rendering
+(DCR) owns the whole thing on the rendering side**: the `POST /PuzzlePage`
+endpoint, the `PuzzleConfig` registry that decides how each puzzle behaves
+and renders, and the `PuzzlePageLayout` layout/styling. The `frontend`
+(Play/Scala) repo is responsible for fetching/assembling per-instance
+content and POSTing it to this endpoint as JSON. See the `frontend` repo's
+`docs/puzzle-page.md` (formerly `docs/game-page.md`; its standalone
+`GamePageController` no longer exists either, having been merged into
+`PuzzlesPageController` there) for the exact JSON payload it sends and how
+to wire up a new puzzle from the content-fetching side.
+
+**Crosswords are explicitly out of scope**, by product decision, and remain
+entirely on their existing, separate `/crosswords/*` flow
+(`ArticleDesign.Crossword` / `src/layouts/CrosswordLayout.tsx` / the generic
+Article pipeline). That flow is unrelated to Puzzle Page and is not
+described further in this file.
+
+Readers reach individual puzzles via `frontend`'s public, top-level URLs,
+mirroring how crosswords are already routed, e.g. `/sudoku/easy`,
+`/word-wheel`, `/wordiply` (nested only where the puzzle itself has
+variants, like sudoku's difficulty levels). This is separate from the
+Puzzles Hub (the directory/listing page, unrelated to Puzzle Page), which
+stays at `/puzzles-and-games`. None of this is DCR's own routing, it's
+`frontend`'s public URL structure, and does not affect DCR's `/PuzzlePage`
+endpoint/contract at all; it's mentioned here only so example URLs
+elsewhere in this doc stay accurate.
+
+**Access control lives entirely on the `frontend` side, not here.** DCR's
+own `/PuzzlePage` endpoint is, and remains, ungated (see "Hitting it
+locally" below). `frontend` gates reader access to these routes via its
+existing `PuzzlesHubExperiment`/`puzzles-new-hub` AB test before it ever
+POSTs to DCR. DCR does not re-implement or duplicate that gating.
+
+### The V0 puzzle set
+
+The `PuzzleConfig` registry (`src/model/puzzles/puzzleConfigs.ts`) currently
+contains exactly 6 slugs, all rendered via the generic sandboxed
+`PuzzleIframe.island.tsx` component:
+
+| `slug` | `puzzleGroup` | provider |
+| --------------- | --------------- | ---------------------- |
+| `sudoku-easy` | `logic-puzzles` | AmuseLabs |
+| `sudoku-medium` | `logic-puzzles` | AmuseLabs |
+| `sudoku-hard` | `logic-puzzles` | AmuseLabs |
+| `sudoku-killer` | `logic-puzzles` | AmuseLabs |
+| `word-wheel` | `word-games` | AmuseLabs |
+| `wordiply` | `word-games` | bespoke (wordiply.com) |
+
+Codeword, futoshiki, suguru, and the trivia/quizzes puzzles (on-the-ball,
+film-reveal) were removed from the registry for V0 and may return later.
+All AmuseLabs-hosted entries share one URL template
+(`https://tg.amuselabs.com/guardian/date-picker?set=guardian-{slug}&embed=1&idx=1`),
+differing only by the `{slug}` substitution.
+
+### Hitting it locally
+
+Start the dev server (from the `dotcom-rendering` sub-directory):
+
+```
+make dev
+```
+
+This starts webpack-dev-server on `http://localhost:3030`
+(`webpack/webpack.config.dev-server.js`).
+
+There is currently **no AB gate** on this route.
+`src/server/handler.puzzlePage.web.ts` validates the body
+(`validateAsPuzzlePageType`), looks up the `PuzzleConfig` for the request's
+`slug` (`404` if unknown), and renders unconditionally otherwise, with no
+`serverSideABTests`/participation check of any kind (see "Open questions"
+below for the AB-gate/kill-switch situation).
+
+Generate fixture JSON for all 6 slugs using the `tsx` devDependency (no
+extra install needed) and `fixtures/manual/puzzlePage.ts`'s
+`createPuzzlePage`/`puzzlePageFixtures`:
+
+```
+cat > /tmp/dump-puzzle-fixtures.ts <<'EOF'
+import * as fs from 'fs';
+import { puzzlePageFixtures } from './fixtures/manual/puzzlePage';
+
+fs.mkdirSync('/tmp/puzzle-fixtures', { recursive: true });
+for (const [slug, page] of Object.entries(puzzlePageFixtures)) {
+ fs.writeFileSync(`/tmp/puzzle-fixtures/${slug}.json`, JSON.stringify(page, null, 2));
+}
+console.log('wrote', Object.keys(puzzlePageFixtures).length, 'fixtures to /tmp/puzzle-fixtures');
+EOF
+pnpm exec tsx /tmp/dump-puzzle-fixtures.ts
+```
+
+Then hit the route directly. **This is DCR's own local `POST` endpoint, not
+a real, browsable end-user URL.** `/PuzzlePage` only accepts `POST`
+requests with a JSON body; DCR is not directly browsable by real users
+without `frontend` in front of it constructing and sending that body.
+
+| `slug` | local command |
+| --------------- | -------------------------------------------------------------------------------------------------------------------------------------- |
+| `sudoku-easy` | `curl -i -X POST http://localhost:3030/PuzzlePage -H "Content-Type: application/json" --data @/tmp/puzzle-fixtures/sudoku-easy.json` |
+| `sudoku-medium` | `curl -i -X POST http://localhost:3030/PuzzlePage -H "Content-Type: application/json" --data @/tmp/puzzle-fixtures/sudoku-medium.json` |
+| `sudoku-hard` | `curl -i -X POST http://localhost:3030/PuzzlePage -H "Content-Type: application/json" --data @/tmp/puzzle-fixtures/sudoku-hard.json` |
+| `sudoku-killer` | `curl -i -X POST http://localhost:3030/PuzzlePage -H "Content-Type: application/json" --data @/tmp/puzzle-fixtures/sudoku-killer.json` |
+| `word-wheel` | `curl -i -X POST http://localhost:3030/PuzzlePage -H "Content-Type: application/json" --data @/tmp/puzzle-fixtures/word-wheel.json` |
+| `wordiply` | `curl -i -X POST http://localhost:3030/PuzzlePage -H "Content-Type: application/json" --data @/tmp/puzzle-fixtures/wordiply.json` |
+
+All six should return `200`, hydrating into a sandboxed iframe pointed at
+that slug's resolved provider URL. An unknown slug (or a known slug renamed
+to something else in the request body) returns `404`.
+
+### How to configure/add a new puzzle
+
+Both steps are config-only. The layout does not need any changes for a new
+iframe-based slug:
+
+1. Add a new key to `src/model/puzzles/puzzleConfigs.ts`'s `puzzleConfigs`
+ record (`slug`, `puzzleGroup`, `iframe: { provider, urlTemplate }`,
+ `shareEnabled`, `printEnabled`, `hasArchive`, `title`, `description`,
+ optional `image`). If it's another AmuseLabs-hosted puzzle, reuse the
+ `amuseLabsPuzzle(slug, puzzleGroup, title, description)` helper (note:
+ this helper doesn't take `image`, set it afterwards on the returned
+ object if/when a real image is available for that puzzle).
+ `validatePuzzleConfigs` runs once at module load and throws immediately
+ if the entry is malformed (mismatched `slug`, unknown `puzzleGroup`,
+ empty `iframe.provider`/`iframe.urlTemplate`, empty `title`/
+ `description`, or a present-but-empty `image`).
+ **Write real, curated copy for `title`/`description`**, sourced from
+ the product team's SEO spreadsheet for that puzzle (see "SEO" below for
+ the exact `{date}` templating mechanism). It becomes the page's
+ `
` and `` and their derived Open
+ Graph/Twitter equivalents, don't copy-paste one template string across
+ entries with only the slug swapped in.
+2. Nothing else changes on the DCR side: `PuzzlePageLayout.tsx`'s
+ `PuzzlePageContent` unconditionally renders `PuzzleIframe` pointed at
+ `resolveIframeUrl(puzzleConfig)` for every registry entry. The only thing
+ needed from `frontend` is a request whose `slug` matches the new
+ registry key exactly (see the `frontend` repo's `docs/puzzle-page.md`).
+
+### SEO: title, meta description, Open Graph, Twitter card
+
+Each `PuzzleConfig` entry carries `title` and `description` templates
+(sourced verbatim from the product team's SEO spreadsheet, confirmed
+against per-field character budgets, title max 60 characters, description
+max 157 characters, both accounting for the date's length) and an
+optional `image` (a full preview/share image URL). Both `title` and
+`description` may contain a `{date}` placeholder token, substituted at
+render time with `instance.puzzleDate` formatted as a short "d MMM yy"
+date (e.g. `"2026-09-15" -> "15 Sep 26"`, via `formatPuzzleDateShort` in
+`src/lib/puzzleDate.ts`, deliberately distinct from `formatPuzzleDate`'s
+long, human-readable on-page display form, e.g. "15 September 2026").
+For example, `sudoku-easy`'s `title` template
+`"Easy sudoku {date} - logic puzzle | The Guardian"` resolves, for
+`puzzleDate: "2026-09-15"`, to
+`"Easy sudoku 15 Sep 26 - logic puzzle | The Guardian"`. If `puzzleDate`
+is absent, the placeholder and any now-redundant surrounding
+whitespace/punctuation are tidied up automatically (see
+`resolvePuzzleTitle`/`resolvePuzzleDescription`'s implementation), rather
+than leaving a literal double space or a stray space before a full stop.
+
+`render.puzzlePage.web.tsx` derives the page's SEO metadata from these via
+a small, pure, directly-unit-tested function,
+`buildPuzzlePageMetaData(puzzleConfig, puzzleDate)`
+(`src/server/render.puzzlePage.web.test.ts`):
+
+- The resolved `title` becomes the page's `` tag.
+- The resolved `description` becomes the page's
+ `` (previously hardcoded to `''`, which
+ silently fell back to DCR's generic, site-wide description, a real SEO
+ gap, since a generic/absent description risks Google or social previews
+ auto-generating a snippet from page content instead of showing clean,
+ curated copy).
+- `openGraphData: { 'og:title': title, 'og:description': description }`,
+ plus `'og:image': image` **only when `puzzleConfig.image` is set**.
+- `twitterData: { 'twitter:title': title, 'twitter:description': description }`,
+ plus `'twitter:image': image` **only when `puzzleConfig.image` is set**.
+
+**`webTitle` (the plain string `frontend` sends, e.g. `"Sudoku (easy)"`)
+is _not_ used for the `` tag or `og:title`/`twitter:title` any
+more.** It has no date and no SEO suffix, so it can't satisfy the
+spreadsheet's exact copy. Investigating its other uses in the render
+pipeline before this change confirmed exactly one other real use:
+`PuzzlePageLayout.tsx` still passes `webTitle` to `ShareButton.island.tsx`
+for the share button's pre-filled share text/subject (native share sheet
+title/text, email subject line), which is unrelated to SEO metadata and
+is unaffected by this change. `webTitle` remains a required field in the
+`FEPuzzlePageType` contract for that reason.
+
+**When `image` is unset, `og:image`/`twitter:image` are omitted entirely**
+(not sent empty, not defaulted to a placeholder). `htmlPageTemplate`'s
+`generateMetaTags()` only emits a `` tag for keys actually present in
+the object it's given, so an absent key simply produces no tag. This is a
+deliberate, confirmed decision, not an oversight: **DCR has no site-wide
+default/fallback share image anywhere** for pages without one (checked
+`frontend`'s `MetaData.opengraphProperties`/`SimplePage`, no image is set
+by default there either, only via explicit per-page overrides), so an
+unset `image` here matches existing sitewide behaviour rather than needing
+a new default asset. **None of the 6 current V0 puzzles have a real image
+configured.** This is a placeholder capability for whenever real,
+licensed preview images are provided by the team, not filled in as part of
+adding the field.
+
+Puzzle Page has no separate source of Open Graph/Twitter copy (unlike
+Article, where `frontend` sends its own `openGraphData`/`twitterData`), so
+these are derived directly from `title`/`description`/`image` rather than
+requiring bespoke copy per field.
+
+**Target search terms are documented, not implemented as a meta tag.**
+The product spreadsheet also includes a "Search terms" column per puzzle
+(e.g. for `word-wheel`: "daily word wheel, word wheel puzzle, word wheel
+online, word wheel game, guardian word wheel, word wheel for today,
+guardian word wheel today"). This is content/SEO-strategy reference, the
+search terms the copy should naturally support, not a literal meta tag:
+major search engines ignore `` entirely today, so it
+provides no real SEO benefit. Each puzzle's search-term list is recorded
+as a code comment directly above its registry entry in
+`puzzleConfigs.ts`, for content-team/future-maintainer traceability, and
+is **not** rendered as a `` tag anywhere.
+
+**Crawl/index behaviour already matches the product requirement (no code
+change needed).** The spreadsheet asks for all 6 V0 puzzle pages to allow
+robots.txt and be indexed. `htmlPageTemplate.ts`'s `doNotIndex()` only
+forces `noindex` outside `PROD`, or for canonical URLs containing
+`tracking/commissioningdesk` (an unrelated, allow-listed exception for a
+couple of specific URLs). None of the 6 puzzle pages' canonical URLs match
+that pattern, so none of them hit the `noindex` branch in production;
+they are indexed normally, as required.
+
+**Out of scope for this registry, not implemented:** the product
+spreadsheet includes SEO copy for several other pages, the Puzzles & Games
+hub page, a "Word games" landing page, a "Logic puzzles" landing page, a
+"Trivia and quizzes" landing page, a generic "Sudoku" landing page, and
+Crosswords/Word games/Logic puzzles archive pages. None of these pages
+exist in this codebase yet (no routes, no controllers), some are
+explicitly future V1/V2 work per the rollout plan (see "Feature-tier
+rollout gating" below). Their SEO copy is not implementable here until
+those pages are actually built (elsewhere, e.g. the separate, existing
+Puzzles Hub feature for the hub page, or future archive/landing page
+work), this doc note exists so that work isn't discovered as a surprise
+gap later.
+
+### The `FEPuzzlePageType` request contract
+
+`POST /PuzzlePage` validates the body against `FEPuzzlePageType`
+(`src/types/puzzlePage.ts`, validated by `validateAsPuzzlePageType` in
+`src/model/validate.puzzlePage.ts`):
+
+| Field | Type | Notes |
+| ---------------------------------- | ------------------------------------------------------ | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ |
+| `id` | `string` | Any stable identifier for the page instance. |
+| `slug` | `string` | Looked up in the `PuzzleConfig` registry; unknown slug → `404`. |
+| `webTitle` | `string` | Used for the share button's pre-filled share text/subject only. **Not** used for the `` tag or `og:title`/`twitter:title` (see "SEO" above), those come from the resolved `PuzzleConfig.title` instead. |
+| `config` | `ConfigType` | Same shape frontend sends for `/Article`, `/PuzzlesPage`, etc. Only checked for a `serverSideABTests: Record` shape, content otherwise unused (no AB gate today). |
+| `nav` | `FENavType` | Same shape as other routes. |
+| `pageFooter` | `FooterType` | Same shape as other routes. |
+| `canonicalUrl` | `string` | Canonical link tag. |
+| `editionId` | `EditionId` (`'UK' \| 'US' \| 'AU' \| 'INT' \| 'EUR'`) | Validated against the known edition set. |
+| `instance.title` | `string` (required) | Rendered as the page `` and the iframe `title` attribute. |
+| `instance.puzzleDate` | `string?` (e.g. `"2026-09-11"`) | Which day's puzzle the reader wants to see. Rendered as a human-readable date (e.g. "11 September 2026") next to the page title, passed through unformatted as `PuzzleContext.puzzleDate` to the puzzle iframe (see below), and used (short-formatted) to resolve the `{date}` placeholder in `PuzzleConfig.title`/`description` (see "SEO" above). `frontend` now always resolves and sends this for every request (its Puzzle Page URLs carry a date segment), though DCR still treats the field as optional and simply omits/tidies up the display/context/SEO value when absent. |
+| `instance.moreFromPuzzlesAndGames` | `PuzzleItem[]?` (from `src/types/puzzlesPage.ts`) | Rendered as a plain "More from Puzzles & games" list when present and non-empty. |
+
+### User/context info passed to the puzzle iframe
+
+`src/components/PuzzleIframe.island.tsx` passes a combined `PuzzleContext`
+about the current reader to the puzzle provider two ways:
+
+- As a single JSON-encoded `guardian-puzzle-context` query parameter on the
+ iframe `src` (e.g.
+ `?set=guardian-sudoku-easy&embed=1&idx=1&guardian-puzzle-context=%7B%22userId%22%3Anull%2C%22darkMode%22%3Afalse%2C%22puzzleDate%22%3Anull%7D`,
+ which decodes to `{"userId":null,"darkMode":false,"puzzleDate":null}`),
+ present from the iframe's very first request. Unlike the parameter's
+ previous `userId`-only form, this is always included: the context shape
+ always carries all three fields, so there's no "nothing to add" case to
+ omit it for.
+- Via `window.postMessage({ type: 'guardian-puzzle-context', context }, '*')`
+ (the `PuzzleContextMessage` shape), sent to the iframe once it has loaded.
+
+```ts
+interface PuzzleContext {
+ userId: string | null;
+ darkMode: boolean;
+ puzzleDate: string | null;
+}
+```
+
+- **`userId`** is the reader's `idToken.claims.legacy_identity_id` (resolved
+ via `src/lib/identity.ts`'s `getAuthStatus()`), the same identifier
+ already used to build MyAccount links elsewhere in DCR
+ (`TopBarMyAccount.tsx`). This is **not** the OIDC `sub` claim some other,
+ newer API integrations in DCR use instead. `null` when the reader is
+ signed out.
+- **`darkMode`** is whether dark mode is currently actually active for this
+ reader. Both of the following must be true:
+ 1. `darkModeAvailable`, the existing server-side `webx-dark-mode-web` AB
+ test flag for this page/request, already read via `useConfig()` in
+ `PuzzlePage.tsx` and threaded down through `PuzzlePageLayout.tsx` to
+ `PuzzleIframe` the same way it already reaches `rootStyles()` for the
+ page chrome's own dark mode support (see `src/lib/rootStyles.ts`). No
+ new source of truth was introduced for this.
+ 2. The reader's OS/browser actually preferring dark
+ (`prefers-color-scheme: dark`), checked reactively via DCR's existing,
+ generic `src/lib/useMatchMedia.ts` hook (already used elsewhere in DCR,
+ e.g. `ArticleMeta.web.tsx`). Not a new media-query mechanism.
+
+ When `darkModeAvailable` is `false`, `darkMode` is always `false` and the
+ media query isn't even consulted.
+
+- **`puzzleDate`** is `instance.puzzleDate` passed straight through
+ unformatted (the raw `YYYY-MM-DD` string, not the "11 September 2026"
+ display text rendered next to the title), so third-party providers get
+ the machine-readable form. `null` when `instance.puzzleDate` is absent.
+ DCR does not parse the Puzzle Page URL or own the date-in-path/
+ redirect-to-archive logic itself: it purely receives whatever date
+ `frontend` resolved and sent in the request payload, and passes it on. See
+ `frontend`'s own documentation for how it resolves and redirects on the
+ date-in-URL structure.
+
+The iframe reloads automatically whenever either half of the context
+changes while the reader is already on the page: sign in, sign out,
+switching accounts, or the reader's OS switching light/dark theme. The
+component subscribes to both auth state changes
+(`src/lib/identity.ts`'s `subscribeToAuthStateChange()`, a thin wrapper
+around the `@guardian/identity-auth` client's own
+`authStateManager.subscribe`) and colour-scheme changes (via
+`useMatchMedia`'s own reactivity), and since the iframe's `src` is derived
+directly from the current context, React gives the `