3.12.0
Released
Features
A Pro owner's Quest Chronicle can now be read aloud by a narrator rather than the browser's own voice. When a story is written or regenerated, the worker reads it with OpenAI's
gpt-4o-mini-tts— an unhurried storyteller's delivery, directed by the model'sinstructions— stores one MP3 per story in a private bucket, and charges the owner 5 credits once, however often the story is played or shared; a free-tier story is never queued or charged. WithNEXT_PUBLIC_FEATURE_CHRONICLE_LISTENon, the reading view's Listen plays the narration when it exists ("Narrated by Questify" in its tooltip), with the sentence being spoken highlighted from the file's progress, and Pause, Resume and Stop as before; while the reading is still pending, missing, or refused by the browser, Listen reads with the browser's voice instead; a pending reading is asked about again every fifteen seconds for up to ten minutes (so a browser with no voice of its own still gets a Listen button when the file lands), a press on one asks once more first, since the worker is usually seconds behind the story, and a stored reading that will not play hands that same press to the browser voice rather than falling silent. Where both voices exist a Browser voice toggle lets the reader choose, and the choice resets with a new story. Anyone who can read the story can hear it — the owner, the party, anyone on a public quest, and the signed-out visitor to a shared/chronicle/<id>link — throughGET /api/quests/[id]/narrationandGET /api/chronicle/[shareId]/narration, which read as the caller and hand out a ten-minute signed URL stamped with the story it voices — a page left open while the owner regenerates keeps reading its own text with the browser voice rather than playing the new file under it; a quest or link the caller cannot read is a 404. Nothing is charged until the worker'sCHRONICLE_NARRATION_ENABLEDswitch is on, so no owner pays for a file the web app cannot yet play. OpenAI announced that this model shuts down on 2027-01-06; the provider call is one adapter in the worker and the swap is tracked separately (docs/plans/966-chronicle-narration.md) (#966).Finishing a Quest Chronicle now feels more like finishing a book. The reading view — after a new story is written, from Read the Chronicle, during a Replay, and on a shared
/chronicle/<id>page — sits on paper rather than a flat colour: a faint static grain over the existing parchment tint, tuned separately for dark mode, where the page is also a little warmer so it reads as a page rather than a theme. When the story has been written out, or is shown whole, a small "The End" — a short rule, a quill and a rule over the words in small capitals — closes it under the last paragraph, fading in, or simply there under reduced motion. And when completing a quest raises the Chronicle offer, "Your quest is complete" and an amber rule under it draw in first, so there is one visible beat between Complete Quest and the question; the beat plays once, not on the offer a failed attempt returns to, and not under reduced motion. The offer's question, its buttons and the Regenerate offer are unchanged, and the text against the grain still meets WCAG AA in both themes (#965).The owner of a private quest can now share its Quest Chronicle with anyone. On a completed private quest with a story, the card's Copy link becomes Share…, which first explains that the link lets anyone who has it read the story — and only the story, not the tasks, notes or investigations — then creates the link and copies it ("Link copied"). Once shared the button reads Copy share link, and Stop sharing in the card's ⋯ menu turns the link off at once. The link opens
/chronicle/<id>, a signed-out page with the story under its own title, Replay and, whereNEXT_PUBLIC_FEATURE_CHRONICLE_LISTENis on, Listen, a line saying who finished which quest and when, when the Chronicle was written, and an invitation to sign up. A pasted link previews as the story's title on parchment. The link shows the current story, so regenerating it changes what the link shows; reopening or deleting the quest makes the link stop working until it is finished again. A revoked, unknown or mistyped link is a plain 404, the page is kept out of search engines, and it is part of Narrative Mode (NEXT_PUBLIC_FEATURE_NARRATIVE_MODE). A public quest's Copy link is unchanged, party members keep the signed-in link, and the quest page's own visibility does not change (#964).A Quest Chronicle can now be listened to as well as read. With
NEXT_PUBLIC_FEATURE_CHRONICLE_LISTENon, the reading view — after a new story is written, from the card's Read the Chronicle, or during a Replay — has a Listen button that reads the story aloud in the browser's own voice, from its title line, one sentence at a time, with the sentence being spoken highlighted and, where the browser reports words, the word underlined. Listen replaces the word-by-word reveal rather than following it, since speech runs at about 200 words a minute and the reveal at about 1,400: the whole story is shown and the reveal stops. Stop ends it at once, and Pause / Resume appear only where pausing actually works (not on Android). Closing the dialog, Escape, regenerating the story, leaving the page or unmounting all stop the voice. There is no voice picker: the app chooses a known-good English voice (Samantha, Daniel, Karen, Moira or Tessa on Apple devices, Edge's Aria or Guy, Windows' David, Zira or Mark, Chrome's Google US or UK English), then the browser's default English voice, and never one of Apple's novelty voices; the button's tooltip names it ("Read aloud by Daniel"). Where the browser has no English voice — headless Chromium in CI, for one — there is no button at all. It costs no credits and needs no provider key. Screen readers read the story as before; the highlighted text is the story itself and is not announced as the highlight moves, and reduced motion turns off only the highlight's fade. The flag is registered in.env.example,lib/env.ts, the webDockerfile,docker-compose.yml,turbo.jsonand the Playwright web server, and the dev TUI lists it (#963).
Technical changes· 10 entries for database, tooling, CI, tests and docs
Database
20261206120000_quest_narration.sqladdsquest_narrations, the queue and the result for a provider-voiced reading of a quest's Chronicle: one row per quest, in the lease-bound shape of the #551 caption queue (pending→generating→ready|failedafter three attempts |skippedforinsufficient_creditsornot_pro), holding thenarrative_generated_atit voices and, when ready, the object's path in the new privatequest-narrationbucket (10 MB,audio/mpeg). The row is readable under the story's own predicate — the owner and party throughuser_can_access_quest(), and anyone, signed out included, on a public quest — and no client inserts, updates or deletes one; the bucket has no client policy at all, since the worker writes it and the web route signs it, both with the service role, so a row exposes a path and never a URL. Thequests_enqueue_narrationtrigger (SECURITY DEFINER, after an update ofnarrative/narrative_generated_at) queues a Pro owner's new or regenerated story and removes the row for a cleared story or a non-Pro owner, charging nothing; a regenerated story keeps an in-flight lease'scredit_reffor the next claim to settle and drops a delivered one, so a paid reading is never refunded by the re-queue.claim_quest_narration()closes lapsed leases, refunds the previous claim's reference and returns the story and whether the owner is still Pro;charge_quest_narration()debits the owner under the lease;complete_quest_narration(),fail_quest_narration()(refunding in the same transaction) andskip_quest_narration()follow the caption functions; all areservice_roleonly, and a row deleted before delivery refunds its lease while a delivered one keeps its charge.narrationjoins theai_credit_entriesreason CHECK and thecharge_ai_credits_for()guard at a fixed weight —narration_credit_cost()returns 5 andai_credit_cost()short-circuits to it, because a TTS model is not a persona and would otherwise price at 0.get_shared_quest_chronicle_narration()(definer, callable signed out) resolves a Chronicle share token to a ready reading's path, model and story stamp under the same WHERE clause asget_shared_quest_chronicle(), andfind_orphaned_quest_narration_objects()feeds the worker's reaper. Covered bysupabase/tests/quest_narration.sqland a newrls.sqlsection; the grants are added tofunction_execute_grants.sql,security_advisors.sqlandsupabase/advisor-allowlist.txt(#966).20261205120000_quest_chronicle_sharing.sqladdsquests.chronicle_share_id, the token behind a shared Quest Chronicle's public link (null = not shared). It is deliberately left off the quests column allowlist from #570, so no party member, and no visitor to a quest that later goes public, can read it.set_quest_chronicle_sharing()(signed-in) mints a token for a completed quest with a story, returning the same one if called again so a link already sent keeps working, or clears it; it keys onowner_id = auth.uid()and changes nothing for anyone else.get_quest_chronicle_share()(signed-in) is the owner's read of their own token.get_shared_quest_chronicle()(callable signed out) resolves a token to the quest's title, the story, when and by what it was written, the owner's display name, the completion date and the number of done tasks, and to nothing for a deleted, unfinished or story-less quest; it never returns task titles, investigations, goal/context/constraints, images or the owner id. All three areSECURITY DEFINERbecause the token is not client-readable. Thez_quests_guard_chronicle_share_idtrigger refuses (42501) any other client write of the token, so a revoked token cannot be claimed by another quest, and leavesupdated_atalone on a share.supabase/tests/quest_chronicle_sharing.sqlcovers it,rls.sqlpins the column privilege and the reader's columns, and the grants are added tofunction_execute_grants.sql,security_advisors.sqlandsupabase/advisor-allowlist.txt(#964).
Tooling & Configuration
CHRONICLE_NARRATION_ENABLEDis now"true"inapps/worker/fly.toml, so the next worker deploy starts claiming queued Quest Chronicle narrations and charging their Pro owners 5 credits a story. It was flipped the dayNEXT_PUBLIC_FEATURE_CHRONICLE_LISTENwas set in Vercel's production environment, so the reading view can play what the worker produces from the same Release run; theOPENAI_API_KEYsecret it needs is already on the worker.docs/ops/deployments.mdand the feature-flags doc now record the switch as on and keep the rule that it never runs ahead of the flag (#655).The worker gained
CHRONICLE_NARRATION_ENABLED(off by default;"false"inapps/worker/fly.toml) andNARRATION_POLL_INTERVAL_MS(15 seconds), registered in.env.exampleandturbo.json. The enqueue trigger is database-side and cannot read the web app's Listen flag, so this switch is what stands between a Pro owner's story and a 5-credit charge: rows queue regardless, and the worker claims and charges them only once it is on, which it should be only afterNEXT_PUBLIC_FEATURE_CHRONICLE_LISTENis on in Vercel. On, it also needs theOPENAI_API_KEYsecret and logs a misconfiguration otherwise. The reaper now sweeps thequest-narrationbucket, so a regenerated story's replaced reading is removed after the usual 24-hour grace (#966).
Tests
supabase/tests/quest_narration.sql(84 assertions) pins the fixed 5-credit weight and the ledger reason; queueing (a Pro owner's story, a free owner's not, a regenerated story, an unrelated save, a cleared story, a downgraded owner); the lease-bound claim → charge → complete, a wrong reference, a spent lease, the refund-and-retry on failure to a terminalfailed, a lapsed lease closed and refunded by the next claim, skips for want of credits or Pro, a delivered reading keeping its charge through a regenerate and a quest delete — the regenerate case is what caught the re-queue carrying a delivered reading's reference into the next lease, which the trigger now drops; the share RPC for a pending, ready, regenerated, revoked and guessed link; and the orphan finder's grace period.rls.sqladds the row's readers (owner, party, anyone on a public quest, signed out included) and non-readers, that no client writes one, the bucket's privacy and absence of client policies, and the queue and share grants. Worker:narration/text.test.tspins the script against the same shapes asparseChronicle(), chunking at paragraph and sentence boundaries that re-joins to the whole;tts.test.tsthe OpenAI request (voice, instructions, MP3), concatenation and a refusal;generate.test.tsthe full lease flow against faked RPCs and a scripted synthesizer — skipnot_pro, skip for 42501 before any voice is called, superseded at the charge and at the complete (the upload left to the reaper, since a worker that outlived its lease shares the per-story path with the one that reclaimed the row), refund and fail when the voice or the upload fails, a story with nothing to read failed before charging, a refund error still failing the row, and acomplete_quest_narrationwhose answer was lost settled byfail_quest_narrationalone — the file and the charge kept when the row had already turnedready, the file removed only when the completion had not landed;loop.test.tsthe switch, the misconfiguration and the backoff;reaper.test.tsthe new bucket. Web:narration-access.test.tscovers the RLS read, the stale-story guard, the share RPC pair, signing and the response mapping; both route tests cover the flags, a malformed id, a 404 the caller cannot read (never signing), ready, pending and none;narration.test.tsthe proportional sentence timing with the paragraph beat;use-provider-narration.test.tsdrives the hook against a scriptedAudio(src/test/fake-audio.ts) — ready/pending/none/error from the route, the version re-ask, playback with the highlight followingtimeupdate, pause/resume/stop, a second Listen, a file that will not play, a refusedplay(), a stale URL refreshed before playing, a URL arriving after Stop, story change,pagehideand unmount;use-chronicle-listen.test.tsthe two voices behind one button, the re-ask on a pending press, the toggle, its reset, and each voice alone;narrative-panel.test.tsxandshared-chronicle-reader.test.tsxthe fetch to the right route, the narrator's label and toggle, playback with the highlight and no word underline, the pending fallback, Close stopping the file;apps/web/e2e/quest-chronicle.spec.tsadds the narrated path against a stubbed route andAudioand the pending fallback against the real route (#966).chronicle-reveal.test.tsxcovers "The End" after a reveal finishes, after a skip and under reduced motion, its absence while the story is still being written and withending={false}, andChronicleTextshowing it only when asked;shared-chronicle-reader.test.tsxpins it on the shared page's whole story.narrative-panel.test.tsxcovers the offer's completion line and rule with their animation classes andmotion-reduce:animate-nonecounterparts, the dialog keeping "Write your Quest Chronicle?" as its name, no replay of the beat on a retry, no completion line on the Regenerate offer, and the finished story's ending on the grained surface (#965).lib/quests/narration.test.tscovers the sentence split (chronicleSentences(), withSENTENCE_ENDnow exported fromlib/quests/chronicle.ts),planChronicleNarration()(title first, global sentence indices, no title, empty story), the voice choice (allowlist order, prefix matching that does not takeDanielaforDaniel, every novelty voice refused even as the default, the default/local/any fallbacks, no English voice),narrationVoiceLabel()andnarrationWordRange()with and withoutcharLength.use-chronicle-narration.test.tsdrives the hook against a scriptedspeechSynthesis(src/test/fake-speech.ts): voices present, arriving onvoiceschanged, never arriving (timeout), absent API, flag off; one utterance per sentence in order with the chosen voice, rate and volume;start/end→ sentence index;boundary→ word; Stop, restart, a real error, unmount,pagehideand a changed story cancelling only speech it started; and Pause where it round-trips, apauseevent as proof, Pause withdrawn where it does not take, and none on Android.narrative-panel.test.tsxcovers Listen with the voice in its title, no Listen with the flag off, withoutspeechSynthesisor without an English voice, the highlighted sentence and word, focus moving to Stop and back to Listen, Listen replacing a running Replay, Stop, Close and Escape, a new story and unmount cancelling, and Pause/Resume.apps/web/e2e/quest-chronicle.spec.tsadds Listen against stubbed voices (the allowlisted voice chosen over a novelty default, the reveal replaced, sentences highlighted in order, Stop and Close cancelling) and the button's absence where headless Chromium has no voices (#963).lib/quests/chronicle-share.test.tscovers the share-id check, the link builders, the pinned reader columns, the row mapping (a blank story, a blank name) and both RPC reads;chronicle-share-actions.test.tscovers minting, stopping, a malformed id or flag, a signed-out caller, nothing minted and a database failure.detail-page.test.tspins that only the owner of a completed, storied quest reads the link, with the flag on, and that a failed read shows the quest as not shared.chronicle-card.test.tsxcovers Share…, Copy share link, Stop sharing with and without Regenerate, and the pending state;narrative-panel.test.tsxcovers the confirmation and its focus, Cancel and Escape, minting and copying, copying an existing link, Stop sharing, the clipboard fallback field, an error, an action that fails outright, the controls staying disabled until a slow request settles, a party member's Copy link and a public quest's. The page is split perdocs/testing-server-components.md:shared-chronicle-page.test.ts(every dead link a 404, the flag, a failed read, metadata kept out of indexes),shared-chronicle-view.test.tsx,shared-chronicle-reader.test.tsx(Replay with focus moving to Skip and back, reduced motion, Listen),page.test.tsxandopengraph-image.test.tsx.redirects.test.tsandsitemap.test.tspin the route as public and disallowed for crawlers, andapps/web/e2e/quest-chronicle.spec.tsadds an unknown link answering 404 and share → visitor reads → Stop sharing → 404 (#964).
Documentation
docs/ops/feature-flags.mdis the production inventory of the twenty-fiveNEXT_PUBLIC_FEATURE_*flags: one row per flag with what it gates, its Vercel production state as of 2026-10-28 (the nineteen the 3.11.0 deploy inlined, plusCHRONICLE_LISTEN,WEEKLY_DIGEST,JAM_COPY_AIandTASK_EVIDENCEset that evening and awaiting the next Release run; onlyGOOGLE_LOGINandFACEBOOK_LOGINunset), whether the worker reads it, the migrations, secrets and provider setup that must be in place before it is turned on, and the trap specific to it — plus the worker-onlyCHRONICLE_NARRATION_ENABLEDswitch and the two ungated worker loops. The "Turning a flag on" runbook covers the Vercel edit, why adeploy-prodRelease run is the only thing that rebuilds the bundle, the Fly-secret shadow check for the seven worker-read flags, theturbo.json/ Playwright passthrough for a new flag, and how to re-read production state from the deployed bundle and the Release log without a Vercel login; a "Known registration gaps" section lists the flags missing fromturbo.jsonandplaywright.config.tstoday.AGENTS.mdanddocs/ops/deployments.mdlink to it, and theadd-env-flagskill now ends by adding the new flag's row (#655).docs/plans/966-chronicle-narration.mdrecords what changed between the #945 spike and the code — OpenAI's 2027-01-06 shutdown ofgpt-4o-mini-ttsand the adapter decision, charging at claim rather than at enqueue, the share-page definer RPC, the worker switch and thequest_narrationstable in place of quest columns.docs/schema.mdgains the migration row, thequest_narrationssection, the narration paragraph underquests, the seventeenth anon definer and the RLS coverage entry;docs/api-endpoints.mdthe two narration routes, their body and their 404s;docs/ops/deployments.mdthe worker switch and when to flip it (#966).