Questify

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's instructions — 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. With NEXT_PUBLIC_FEATURE_CHRONICLE_LISTEN on, 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 — through GET /api/quests/[id]/narration and GET /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's CHRONICLE_NARRATION_ENABLED switch 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, where NEXT_PUBLIC_FEATURE_CHRONICLE_LISTEN is 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_LISTEN on, 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 web Dockerfile, docker-compose.yml, turbo.json and 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.sql adds quest_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 | failed after three attempts | skipped for insufficient_credits or not_pro), holding the narrative_generated_at it voices and, when ready, the object's path in the new private quest-narration bucket (10 MB, audio/mpeg). The row is readable under the story's own predicate — the owner and party through user_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. The quests_enqueue_narration trigger (SECURITY DEFINER, after an update of narrative / 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's credit_ref for 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) and skip_quest_narration() follow the caption functions; all are service_role only, and a row deleted before delivery refunds its lease while a delivered one keeps its charge. narration joins the ai_credit_entries reason CHECK and the charge_ai_credits_for() guard at a fixed weight — narration_credit_cost() returns 5 and ai_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 as get_shared_quest_chronicle(), and find_orphaned_quest_narration_objects() feeds the worker's reaper. Covered by supabase/tests/quest_narration.sql and a new rls.sql section; the grants are added to function_execute_grants.sql, security_advisors.sql and supabase/advisor-allowlist.txt (#966).

  • 20261205120000_quest_chronicle_sharing.sql adds quests.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 on owner_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 are SECURITY DEFINER because the token is not client-readable. The z_quests_guard_chronicle_share_id trigger refuses (42501) any other client write of the token, so a revoked token cannot be claimed by another quest, and leaves updated_at alone on a share. supabase/tests/quest_chronicle_sharing.sql covers it, rls.sql pins the column privilege and the reader's columns, and the grants are added to function_execute_grants.sql, security_advisors.sql and supabase/advisor-allowlist.txt (#964).

Tooling & Configuration

  • CHRONICLE_NARRATION_ENABLED is now "true" in apps/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 day NEXT_PUBLIC_FEATURE_CHRONICLE_LISTEN was set in Vercel's production environment, so the reading view can play what the worker produces from the same Release run; the OPENAI_API_KEY secret it needs is already on the worker. docs/ops/deployments.md and 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" in apps/worker/fly.toml) and NARRATION_POLL_INTERVAL_MS (15 seconds), registered in .env.example and turbo.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 after NEXT_PUBLIC_FEATURE_CHRONICLE_LISTEN is on in Vercel. On, it also needs the OPENAI_API_KEY secret and logs a misconfiguration otherwise. The reaper now sweeps the quest-narration bucket, 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 terminal failed, 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.sql adds 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.ts pins the script against the same shapes as parseChronicle(), chunking at paragraph and sentence boundaries that re-joins to the whole; tts.test.ts the OpenAI request (voice, instructions, MP3), concatenation and a refusal; generate.test.ts the full lease flow against faked RPCs and a scripted synthesizer — skip not_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 a complete_quest_narration whose answer was lost settled by fail_quest_narration alone — the file and the charge kept when the row had already turned ready, the file removed only when the completion had not landed; loop.test.ts the switch, the misconfiguration and the backoff; reaper.test.ts the new bucket. Web: narration-access.test.ts covers 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.ts the proportional sentence timing with the paragraph beat; use-provider-narration.test.ts drives the hook against a scripted Audio (src/test/fake-audio.ts) — ready/pending/none/error from the route, the version re-ask, playback with the highlight following timeupdate, pause/resume/stop, a second Listen, a file that will not play, a refused play(), a stale URL refreshed before playing, a URL arriving after Stop, story change, pagehide and unmount; use-chronicle-listen.test.ts the two voices behind one button, the re-ask on a pending press, the toggle, its reset, and each voice alone; narrative-panel.test.tsx and shared-chronicle-reader.test.tsx the 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.ts adds the narrated path against a stubbed route and Audio and the pending fallback against the real route (#966).

  • chronicle-reveal.test.tsx covers "The End" after a reveal finishes, after a skip and under reduced motion, its absence while the story is still being written and with ending={false}, and ChronicleText showing it only when asked; shared-chronicle-reader.test.tsx pins it on the shared page's whole story. narrative-panel.test.tsx covers the offer's completion line and rule with their animation classes and motion-reduce:animate-none counterparts, 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.ts covers the sentence split (chronicleSentences(), with SENTENCE_END now exported from lib/quests/chronicle.ts), planChronicleNarration() (title first, global sentence indices, no title, empty story), the voice choice (allowlist order, prefix matching that does not take Daniela for Daniel, every novelty voice refused even as the default, the default/local/any fallbacks, no English voice), narrationVoiceLabel() and narrationWordRange() with and without charLength. use-chronicle-narration.test.ts drives the hook against a scripted speechSynthesis (src/test/fake-speech.ts): voices present, arriving on voiceschanged, 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, pagehide and a changed story cancelling only speech it started; and Pause where it round-trips, a pause event as proof, Pause withdrawn where it does not take, and none on Android. narrative-panel.test.tsx covers Listen with the voice in its title, no Listen with the flag off, without speechSynthesis or 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.ts adds 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.ts covers 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.ts covers minting, stopping, a malformed id or flag, a signed-out caller, nothing minted and a database failure. detail-page.test.ts pins 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.tsx covers Share…, Copy share link, Stop sharing with and without Regenerate, and the pending state; narrative-panel.test.tsx covers 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 per docs/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.tsx and opengraph-image.test.tsx. redirects.test.ts and sitemap.test.ts pin the route as public and disallowed for crawlers, and apps/web/e2e/quest-chronicle.spec.ts adds an unknown link answering 404 and share → visitor reads → Stop sharing → 404 (#964).

Documentation

  • docs/ops/feature-flags.md is the production inventory of the twenty-five NEXT_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, plus CHRONICLE_LISTEN, WEEKLY_DIGEST, JAM_COPY_AI and TASK_EVIDENCE set that evening and awaiting the next Release run; only GOOGLE_LOGIN and FACEBOOK_LOGIN unset), 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-only CHRONICLE_NARRATION_ENABLED switch and the two ungated worker loops. The "Turning a flag on" runbook covers the Vercel edit, why a deploy-prod Release run is the only thing that rebuilds the bundle, the Fly-secret shadow check for the seven worker-read flags, the turbo.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 from turbo.json and playwright.config.ts today. AGENTS.md and docs/ops/deployments.md link to it, and the add-env-flag skill now ends by adding the new flag's row (#655).

  • docs/plans/966-chronicle-narration.md records what changed between the #945 spike and the code — OpenAI's 2027-01-06 shutdown of gpt-4o-mini-tts and the adapter decision, charging at claim rather than at enqueue, the share-page definer RPC, the worker switch and the quest_narrations table in place of quest columns. docs/schema.md gains the migration row, the quest_narrations section, the narration paragraph under quests, the seventeenth anon definer and the RLS coverage entry; docs/api-endpoints.md the two narration routes, their body and their 404s; docs/ops/deployments.md the worker switch and when to flip it (#966).