Skip to content

Themes

Status: Live

Implemented and deployed to dev (CSL-93, CSL-137).

A Theme is a portal — a navigable "world" the app skins itself to (colour + design assets). Themes are a global controlled vocabulary spanning every content type (not per-book): every piece of content, and every Journey, is assigned one or more Themes by multi-select. Switching a Theme recolours the whole UI — the portal metaphor is literal, not decorative.

What a Theme is

Themes are static reference data: the FE fetches the whole set once, caches it, and every theme_id used elsewhere (passage cards, journeys, Home) resolves against that cache. A theme is a Wagtail snippet, not a routable page — a theme's own content surface (browsing content within a theme) is out of scope for the current API; these are the visual portal + skin only.

One theme can be marked featured — a single slot, set by editors, that surfaces as the Home "Theme of the Week". Featuring a theme has no effect on the theme set or its ordering; it's a separate slot layered on top.

How themes relate to content

Content pieces and Journeys are assigned Themes by multi-select — a piece may sit in several worlds at once. See the Glossary for the canonical definition.

Endpoints

  • GET /api/themes/ returns the full portal set for the Themes tab, in default order (by name).
  • GET /api/themes/{slug}/ returns one portal by slug, with 404 on an unknown slug.
  • GET /api/themes/{slug}/pieces/ returns the theme's "explore in depth" pieces carousel.
  • GET /api/themes/{slug}/journeys/ returns the theme's journeys carousel.
  • GET /api/themes/{slug}/passages/ returns the theme's passage pool, which serves both the opening and closing slots.
  • GET /api/themes/{slug}/explore/ returns the theme's whole collection, paged for scrolling.
  • GET /api/themes/featured/ returns the single featured theme, the source for the Home "Theme of the Week".

All are public, app-key-gated (X-API-Key), user-agnostic, and cacheable (Cache-Control: public), except the theme passage pool (/passages/), which returns full passage text: it needs a content-access token and is cached by the app only (see Content access). List and detail items carry id, slug, name, a framing_line (a short one-line theme description), and a design object.

The design block

The server assembles design at read time from a color object, the portal_icon and background image URLs, and a spread of the theme's metadata catch-all. Key behaviour to know:

  • color is the theme's palette: accent, arch, and background (the three core hexes), plus the accent's opacity_50, opacity_20, and opacity_0 tints (8-digit hex). Palettes are shared across themes. For example, Friendship and Courage are both the "orange" palette. A theme that is not part of the design library serves "color": {}, an empty object, never null and never a partial palette. The client should fall back to its own default in that case.
  • portal_icon (the theme's arch glyph, one per theme) and background (the full-screen wash, shared per palette) are served as their original PNG URLs, with no Wagtail renditions, because the Figma exports are pre-optimised. Either is null when unset.
  • portal_animation is the theme's animated portal, a Rive .riv file URL, one per theme. The background .riv is common to all themes and bundled in the app, so this block does not serve it. It is null when unset.
  • New design tokens appear without a schema change or client release. Assets that are not yet final (lottie, colour variants) ride in metadata, and the server spreads them into design as editors add them. Treat unrecognised keys as optional.
  • Concrete fields win. A stray color, background, or portal_icon key in metadata cannot shadow the real one.

The theme feed's "explore in depth" carousel. The response is {"items": [ ...content cards ]}, up to 10 of the theme's long-form pieces. It returns 404 when the slug is not a theme, and empty items when the theme has none.

How it behaves:

  • Auto-populated, not curated. Pieces are drawn from published content, not an editorial per-theme list. Editors control the carousel by publishing and theme-tagging. Membership is by primary_theme, the single editor's-pick theme and the shared theme-feed contract, not the multi-select tag set.
  • The pool is Lewis's essays and letters (Writings) and editorial Articles. It excludes Passages (the theme's passages are a separate endpoint), Books, and chapters.
  • Order is newest-published first, and deterministic.
  • Each item is the shared content card: kind (the type chip), title, supporting_line (the subtitle), image (the artwork), read_time_minutes, and theme_id. A tap opens that piece's reading screen.

The theme feed's journeys carousel. The response is {"items": [ ...journey cards ]}, up to 10 of the theme's journeys. It returns 404 when the slug is not a theme, and empty items when the theme has none.

How it behaves:

  • Auto-populated, not curated. Journeys are drawn from theme tagging, not an editorial per-theme list. Editors control the carousel by tagging a journey and publishing it. Membership is by primary_theme, the same contract the pieces carousel uses, not the multi-select tag set. A journey tagged with a theme that is not its primary theme does not appear in that theme's feed.
  • Order is newest-published first, with undated journeys last, and deterministic.
  • Only published journeys appear. A draft is skipped.
  • chapters_count is what a reader can open, which for a live journey is every chapter it was authored with: the CMS refuses to unpublish a piece a live journey is built on. A journey with nothing readable left would be dropped rather than advertised. See Journeys.
  • Each item is the journey card, the same shape the Home Featured Journeys carousel serves: id, slug, name, description (the pitch), primary_theme_id, emblem (the artwork as a set of image sizes), and chapters_count. The two visual fields do different jobs: emblem is the journey's own artwork — the picture on the card and the thing the reader collects — while primary_theme_id resolves the card's theme treatment, its colour and surrounding design, from the client's cached theme set. A journey has no colour of its own. That id is null when an editor has not set a primary theme, and the client falls back to the active theme, the same rule every other themed card follows. A tap opens the journey.
  • No reader state. No progress, no completed chapters, no collected emblem, so the response stays cacheable for everyone.

The difference from Home's carousel is only where the list comes from. Home is an editor's hand-picked, drag-ordered list of up to 5. This is every journey whose primary theme is this one, newest first, up to 10. A journey that merely carries the theme in its multi-select tags is not here.

Passages: GET /api/themes/{slug}/passages/

The theme portal's passages. The response is {"items": [ ...passage cards ]}, up to 10 of the theme's passages. It returns 404 when the slug is not a theme, and empty items when the theme has none. This is the Home hero mechanics (the daily-quote rotation) scoped to one theme.

One pool, two slots

The portal shows a passage at the top (the opening) and, optionally, one at the bottom (the closing). Both come from this single response. Fetch it once per portal visit and draw two different items:

items   = GET /api/themes/{slug}/passages/
opening = items[i]        i random in 0 ... n-1
closing = items[j]        j random in 0 ... n-1, j != i
  • Fewer than 2 items means no closing passage. A theme with a single passage has an opening and nothing to close with, so hide the slot.
  • The client draws both from the pool it already holds, so the closing passage can never repeat the opening. There is no second request and no exclusion for the server to apply.

?exclude= is a cursor, not a filter

?exclude=1,2,3 advances the window past those ids and wraps, so on a small pool it hands them straight back. A theme with three passages, excluding the first, returns all three with the excluded one last. Use it to rotate what a returning visitor sees across sessions. Do not use it to keep the closing passage away from the opening. Draw both from one pool instead.

How it behaves:

  • Journey passages never appear. A passage written for a journey chapter is read only there. See Journey passages.
  • Membership is by primary_theme, the single editor's-pick theme and the shared theme-feed contract. This is the same basis as the pieces carousel, not the multi-select tag set that the reading-screen rails use.
  • A pool, not one item. The response is a rotating window of up to 10 passages in the fixed published order. The same input returns the same output, so it caches. The per-session pick is the client's precisely so this response stays identical for everyone.
  • Each item is the shared passage card (id, slug, quote, source, audio, theme_id), the same shape the passage list and Home hero return. A tap opens that passage's reading screen.
  • GET /api/themes/{slug}/opening-passages/ still works and returns the same response. It is the name this pool shipped under when it served the opening slot alone. Prefer /passages/.

Keep exploring: GET /api/themes/{slug}/explore/

The vertical list that closes the theme feed. The response is {"results": [ ...content cards ], "next": "<url>"}. It returns 404 when the slug is not a theme, and empty results when the theme has none.

How it behaves:

  • Same pool as the pieces carousel, complete instead of capped at 10. Membership is by primary_theme, and the order is newest-published first, so the carousel is the top of this list. The client carries on from where the carousel stopped rather than showing those pieces again.
  • Passages are not here. They have their own portal slots, and a passage card leads with a quote rather than a thumbnail.
  • Paged by cursor. Request next as it comes, without parsing it. next is null on the last page, including when the collection ends exactly on a page boundary. Use ?limit= to set the page size, default 30, maximum 100. next carries your limit forward, so the page size holds for the whole scroll.
  • A malformed cursor is refused with 400 rather than quietly restarting the list, so a client bug surfaces instead of looking like a short list. The cursor is a position, not a credential: it is not signed, so a caller who hand-builds a well-formed one gets the page at that position. Nothing here is private, so that costs the caller a page they could have reached by paging anyway.
  • No total count. A scrolling list does not display one, and counting the collection on every page would cost what the cursor saves.
  • Ties are resolved, not left to chance. Our importers stamp a whole batch with one first_published_at, so equal timestamps are common. On an equal timestamp, letters and essays come before articles, then higher ids first.
  • Each item is the shared content card, the same shape the carousel returns.

Why a cursor and not ?offset=:

  • OFFSET n makes the database count past n rows, so each page costs more than the last. A cursor names a position, so page 50 costs what page 1 costs.
  • An offset window moves when a piece is published mid-scroll, which repeats or skips a row. A cursor is unaffected by anything ahead of the reader.

Change detection: themes_hash

Themes change rarely, so the app fetches GET /api/themes/ once and refetches only when the set changes. GET /api/config/ carries a themes_hash, an opaque fingerprint of the served set that is cached server-side and recomputed only when a Theme changes. Compare it to the last value held. If it differs, refetch and replace the cache.

GET /api/themes/featured/ returns the featured theme as an id plus a slot label: theme_id (resolve it against the cached set) and header (the editor's slot label, for example "Theme of the Week"). It never returns an embedded theme object. Both are null when nothing is featured, and the endpoint never returns 404. Featuring has no effect on GET /api/themes/, GET /api/themes/{slug}/, or themes_hash.

Notes

  • slug auto-derives from the name once, then stays stable across renames. It is the deep-link and analytics identifier, so cached theme_id and slug references never break.
  • Ordering is by name. There is no featured-first ordering in the list or detail responses. The featured theme is its own endpoint above.

See Swagger for the precise field list, cache TTLs, and status codes.